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 { 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: [] });
|
||||
});
|
||||
});
|
||||
@@ -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