From 970c0bf1d287c9bd39c1f5c51a14d776aeb7864c Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 13 Jun 2026 20:45:16 +0200 Subject: [PATCH] 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. --- src/client/src/components/PromptEditor.ts | 23 ++----- .../src/promptAttachmentCapture.test.ts | 51 ++++++++++++++++ src/client/src/promptAttachmentCapture.ts | 61 +++++++++++++++++++ 3 files changed, 117 insertions(+), 18 deletions(-) create mode 100644 src/client/src/promptAttachmentCapture.test.ts create mode 100644 src/client/src/promptAttachmentCapture.ts diff --git a/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts index 2e6312d..46b420c 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -7,7 +7,7 @@ import { LitElement, html, type PropertyValues } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; import { api, type FileSuggestion, type PromptAttachment, type SessionStatus, type SlashCommand } from "../api"; import type { PromptAttachmentDelivery } from "../../../shared/apiTypes"; -import { isSupportedImageMimeType } from "../../../shared/promptAttachments"; +import { captureImageAttachments } from "../promptAttachmentCapture"; import { inputModeForDraft } from "../inputModes"; import { machineSessionKey } from "../machineKeys"; import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions"; @@ -187,24 +187,11 @@ export class PromptEditor extends LitElement { private async addAttachmentFiles(files: File[]) { this.attachmentError = undefined; - for (const file of files) { - if (!isSupportedImageMimeType(file.type)) { - this.attachmentError = "Only PNG, JPEG, GIF, and WebP images are supported."; - 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."; - } + const { attachments, error } = await captureImageAttachments(files, readFileAsBase64); + if (attachments.length > 0) { + this.attachments = [...this.attachments, ...attachments.map((attachment) => ({ id: `attachment-${String(++this.attachmentSeq)}`, ...attachment }))]; } + if (error !== undefined) this.attachmentError = error; } private currentAttachments(): PromptAttachment[] { diff --git a/src/client/src/promptAttachmentCapture.test.ts b/src/client/src/promptAttachmentCapture.test.ts new file mode 100644 index 0000000..163d2ee --- /dev/null +++ b/src/client/src/promptAttachmentCapture.test.ts @@ -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: [] }); + }); +}); diff --git a/src/client/src/promptAttachmentCapture.ts b/src/client/src/promptAttachmentCapture.ts new file mode 100644 index 0000000..139beb6 --- /dev/null +++ b/src/client/src/promptAttachmentCapture.ts @@ -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( + files: readonly T[], + readBase64: (file: T) => Promise, +): Promise { + 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)}`; +}