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:
Federico Jaramillo Martinez
2026-06-13 20:45:16 +02:00
parent ecede3ac7c
commit 970c0bf1d2
3 changed files with 117 additions and 18 deletions
+5 -18
View File
@@ -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[] {
@@ -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: [] });
});
});
+61
View File
@@ -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)}`;
}