Archived
refactor: extract testable image attachment capture from the composer
The paste/drop/file capture logic (supported-type filtering, unnamed-file extension fallback, per-file error collection) lived inside PromptEditor, mixing browser side effects with branching that had no tests. Move it into a pure promptAttachmentCapture module with the byte reader injected, so the FileReader side effect stays at the component boundary and the orchestration is unit-tested. Also removes a duplicated mime->ext fallback in favour of the shared extensionForImageMimeType helper.
This commit is contained in:
@@ -7,7 +7,7 @@ import { LitElement, html, type PropertyValues } from "lit";
|
|||||||
import { customElement, property, query, state } from "lit/decorators.js";
|
import { customElement, property, query, state } from "lit/decorators.js";
|
||||||
import { api, type FileSuggestion, type PromptAttachment, type SessionStatus, type SlashCommand } from "../api";
|
import { api, type FileSuggestion, type PromptAttachment, type SessionStatus, type SlashCommand } from "../api";
|
||||||
import type { PromptAttachmentDelivery } from "../../../shared/apiTypes";
|
import type { PromptAttachmentDelivery } from "../../../shared/apiTypes";
|
||||||
import { isSupportedImageMimeType } from "../../../shared/promptAttachments";
|
import { captureImageAttachments } from "../promptAttachmentCapture";
|
||||||
import { inputModeForDraft } from "../inputModes";
|
import { inputModeForDraft } from "../inputModes";
|
||||||
import { machineSessionKey } from "../machineKeys";
|
import { machineSessionKey } from "../machineKeys";
|
||||||
import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions";
|
import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions";
|
||||||
@@ -187,24 +187,11 @@ export class PromptEditor extends LitElement {
|
|||||||
|
|
||||||
private async addAttachmentFiles(files: File[]) {
|
private async addAttachmentFiles(files: File[]) {
|
||||||
this.attachmentError = undefined;
|
this.attachmentError = undefined;
|
||||||
for (const file of files) {
|
const { attachments, error } = await captureImageAttachments(files, readFileAsBase64);
|
||||||
if (!isSupportedImageMimeType(file.type)) {
|
if (attachments.length > 0) {
|
||||||
this.attachmentError = "Only PNG, JPEG, GIF, and WebP images are supported.";
|
this.attachments = [...this.attachments, ...attachments.map((attachment) => ({ id: `attachment-${String(++this.attachmentSeq)}`, ...attachment }))];
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const data = await readFileAsBase64(file);
|
|
||||||
this.attachments = [...this.attachments, {
|
|
||||||
id: `attachment-${String(++this.attachmentSeq)}`,
|
|
||||||
name: file.name !== "" ? file.name : `pasted-image.${file.type.split("/")[1] ?? "png"}`,
|
|
||||||
mimeType: file.type,
|
|
||||||
data,
|
|
||||||
size: file.size,
|
|
||||||
}];
|
|
||||||
} catch {
|
|
||||||
this.attachmentError = "Failed to read an attachment.";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (error !== undefined) this.attachmentError = error;
|
||||||
}
|
}
|
||||||
|
|
||||||
private currentAttachments(): PromptAttachment[] {
|
private currentAttachments(): PromptAttachment[] {
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { captureImageAttachments, READ_FAILURE_MESSAGE, UNSUPPORTED_IMAGE_MESSAGE, type CapturableFile } from "./promptAttachmentCapture";
|
||||||
|
|
||||||
|
function file(name: string, type: string, size = 10): CapturableFile {
|
||||||
|
return { name, type, size };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("captureImageAttachments", () => {
|
||||||
|
it("reads supported images as base64 attachments", async () => {
|
||||||
|
const result = await captureImageAttachments(
|
||||||
|
[file("shot.png", "image/png"), file("pic.webp", "image/webp")],
|
||||||
|
(f) => Promise.resolve(`data-for-${f.name}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
expect(result.attachments).toEqual([
|
||||||
|
{ name: "shot.png", mimeType: "image/png", data: "data-for-shot.png", size: 10 },
|
||||||
|
{ name: "pic.webp", mimeType: "image/webp", data: "data-for-pic.webp", size: 10 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives a name from the mime type when the file is unnamed", async () => {
|
||||||
|
const result = await captureImageAttachments([file("", "image/jpeg")], () => Promise.resolve("x"));
|
||||||
|
expect(result.attachments[0]?.name).toBe("pasted-image.jpg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips unsupported types and reports a single error while keeping valid ones", async () => {
|
||||||
|
const result = await captureImageAttachments(
|
||||||
|
[file("doc.pdf", "application/pdf"), file("ok.gif", "image/gif")],
|
||||||
|
() => Promise.resolve("x"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.error).toBe(UNSUPPORTED_IMAGE_MESSAGE);
|
||||||
|
expect(result.attachments.map((attachment) => attachment.name)).toEqual(["ok.gif"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a read failure without dropping other attachments", async () => {
|
||||||
|
const result = await captureImageAttachments(
|
||||||
|
[file("bad.png", "image/png"), file("good.png", "image/png")],
|
||||||
|
(f) => f.name === "bad.png" ? Promise.reject(new Error("boom")) : Promise.resolve("ok"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.error).toBe(READ_FAILURE_MESSAGE);
|
||||||
|
expect(result.attachments.map((attachment) => attachment.name)).toEqual(["good.png"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns no attachments and no error for an empty batch", async () => {
|
||||||
|
const result = await captureImageAttachments([], () => Promise.resolve("x"));
|
||||||
|
expect(result).toEqual({ attachments: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { extensionForImageMimeType, isSupportedImageMimeType } from "../../shared/promptAttachments";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal view of a browser File needed to capture an attachment. Keeping this
|
||||||
|
* structural (rather than depending on the DOM `File` type) lets the capture
|
||||||
|
* logic be unit-tested without a browser environment.
|
||||||
|
*/
|
||||||
|
export interface CapturableFile {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CapturedAttachment {
|
||||||
|
name: string;
|
||||||
|
mimeType: string;
|
||||||
|
/** Base64 payload without the data: URL prefix. */
|
||||||
|
data: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaptureResult {
|
||||||
|
attachments: CapturedAttachment[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UNSUPPORTED_IMAGE_MESSAGE = "Only PNG, JPEG, GIF, and WebP images are supported.";
|
||||||
|
export const READ_FAILURE_MESSAGE = "Failed to read an attachment.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a batch of files and read the supported images as base64.
|
||||||
|
*
|
||||||
|
* Pure orchestration: the actual byte reading is injected so the side effect
|
||||||
|
* (FileReader/Blob access) stays at the component boundary and tests can supply
|
||||||
|
* a fake reader. Unsupported types and read failures are collected into a single
|
||||||
|
* user-facing error while still returning every attachment that did succeed.
|
||||||
|
*/
|
||||||
|
export async function captureImageAttachments<T extends CapturableFile>(
|
||||||
|
files: readonly T[],
|
||||||
|
readBase64: (file: T) => Promise<string>,
|
||||||
|
): Promise<CaptureResult> {
|
||||||
|
const attachments: CapturedAttachment[] = [];
|
||||||
|
let error: string | undefined;
|
||||||
|
for (const file of files) {
|
||||||
|
if (!isSupportedImageMimeType(file.type)) {
|
||||||
|
error = UNSUPPORTED_IMAGE_MESSAGE;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await readBase64(file);
|
||||||
|
attachments.push({ name: attachmentName(file), mimeType: file.type, data, size: file.size });
|
||||||
|
} catch {
|
||||||
|
error = READ_FAILURE_MESSAGE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { attachments, ...(error === undefined ? {} : { error }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachmentName(file: CapturableFile): string {
|
||||||
|
return file.name !== "" ? file.name : `pasted-image.${extensionForImageMimeType(file.type)}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user