import { Signal } from "@char/aftercare"; import { assert, assertEquals, assertStringIncludes } from "@std/assert"; import { decodeBase64 } from "@std/encoding/base64"; import type { ImageContent } from "@earendil-works/pi-ai"; import { IDBFactory } from "fake-indexeddb"; import { PNG, until } from "@berry/util"; import { withDom } from "@berry/util/dom.ts"; import { composer } from "./composer.tsx"; import { imageDraft } from "./image-drafts.ts"; import type { CommandInfo } from "./conversation.tsx"; import { Scope } from "./ui.ts"; Deno.test("pasted images are durable route-specific drafts and sends retire only captured attachments", async (t) => { let decode = () => Promise.resolve({ width: 1, height: 1, close() {} }); const globals = { indexedDB: new IDBFactory(), crypto: { getRandomValues: crypto.getRandomValues.bind(crypto) }, createImageBitmap: () => decode(), }; await withDom(async (window) => { const commands = new Signal([]); const scopes: Scope[] = []; const errors: string[] = []; const calls: { text: string; followUp: boolean; images: readonly ImageContent[] }[] = []; let send = () => Promise.resolve(); const file = (name = "screenshot.png") => new File([decodeBase64(PNG)], name, { type: "image/png" }); const open = async (route: string, busy = false) => { location.hash = `#/${route}`; const scope = new Scope(); scopes.push(scope); const view = composer(scope, route, new Signal(busy), commands, (text, followUp, images) => { calls.push({ text, followUp, images }); return send(); }, (message) => errors.push(message)); document.body.replaceChildren(view.element); await imageDraft(route).ready; const input = view.element.querySelector("textarea")!; return { ...view, input, paste(files: readonly File[], text = "") { const event = new window.Event("paste", { cancelable: true, bubbles: true }); Object.defineProperty(event, "clipboardData", { value: { items: files.map((file) => ({ kind: "file", type: file.type, getAsFile: () => file, })), getData: (format: string) => format === "text/plain" ? text : "", }, }); input.dispatchEvent(event as unknown as Event); return event.defaultPrevented; }, async saved() { await until(() => imageDraft(route).items.get().length > 0 && imageDraft(route).items.get().every((image) => image.status !== "loading") ); }, async submit(followUp = false) { input.dispatchEvent( new window.KeyboardEvent("keydown", { key: "Enter", altKey: followUp, }) as unknown as Event, ); await until(() => !imageDraft(route).submitting); }, leave() { scope.dispose(); view.element.remove(); }, }; }; let reloads = 0; const fresh = async (route: string) => { const module = await import( new URL(`./image-drafts.ts?reload=${++reloads}`, import.meta.url).href ) as typeof import("./image-drafts.ts"); const draft = module.imageDraft(route); await draft.ready; assertEquals(draft.error.get(), null); return draft.items.get(); }; try { await t.step( "ordinary paste stays native and mixed paste preserves the text selection", async () => { const view = await open("mixed"); assertEquals(view.paste([], "plain text"), false); view.setText("hello world"); view.input.setSelectionRange(6, 11); assertEquals(view.paste([file()], "there"), true); assertEquals(view.input.value, "hello there"); await view.saved(); assertEquals(view.element.querySelector("img")!.alt, "screenshot.png"); await view.submit(); assertEquals(calls.at(-1), { text: "hello there", followUp: false, images: [{ type: "image", mimeType: "image/png", data: PNG }], }); assertEquals(view.input.value, ""); assertEquals(view.element.querySelectorAll(".attachment").length, 0); assertEquals(await fresh("mixed"), []); }, ); await t.step( "images survive navigation and a fresh frontend without crossing branch routes", async () => { for (const route of ["draft", "draft/fork@4"]) { const view = await open(route); view.paste([file(`${route}.png`)]); await view.saved(); view.leave(); } for (const route of ["draft", "draft/fork@4"]) { const restored = await fresh(route); assertEquals(restored.length, 1); assertEquals(restored[0].name, `${route}.png`); assertEquals(restored[0].blob.type, "image/png"); assertEquals(new Uint8Array(await restored[0].blob.arrayBuffer()), decodeBase64(PNG)); const view = await open(route); assertEquals(view.element.querySelector("img")!.alt, `${route}.png`); view.leave(); } assertEquals((await open("unrelated")).element.querySelectorAll(".attachment").length, 0); }, ); await t.step( "failed sends retain images and image-only retries clear their durable draft", async () => { const view = await open("retry-images"); view.paste([file()]); await view.saved(); send = () => Promise.reject(new Error("offline")); await view.submit(); assertEquals(errors.at(-1), "offline"); assertEquals((await fresh("retry-images")).length, 1); assertEquals(view.element.querySelectorAll(".attachment").length, 1); send = () => Promise.resolve(); await view.submit(); assertEquals(calls.at(-1)!.text, ""); assertEquals(calls.at(-1)!.images[0].data, PNG); assertEquals(await fresh("retry-images"), []); }, ); await t.step( "pending sends protect later pastes and edits, even after returning to the route", async () => { const pending = Promise.withResolvers(); send = () => pending.promise; const view = await open("pending-images"); view.setText("old message"); view.paste([file("old.png")]); await view.saved(); const before = calls.length; const submitted = view.submit(); await until(() => calls.length === before + 1); view.leave(); const returned = await open("pending-images"); const duplicate = returned.submit(); assertEquals(calls.length, before + 1); returned.setText("next message"); returned.paste([file("next.png")]); await returned.saved(); pending.resolve(); await submitted; await duplicate; assertEquals(returned.input.value, "next message"); assertEquals([...returned.element.querySelectorAll("img")].map((img) => img.alt), [ "next.png", ]); assertEquals((await fresh("pending-images")).map((image) => image.name), ["next.png"]); send = () => Promise.resolve(); }, ); await t.step( "commands refuse attachments and busy sends preserve their delivery mode", async () => { let commandsRun = 0; commands.set([{ name: "local", extension: "test", description: "local", run: () => { commandsRun++; return Promise.resolve(); }, }]); const view = await open("command-images"); view.paste([file()]); await view.saved(); view.setText("/local"); const before = calls.length; await view.submit(); assertEquals(commandsRun, 0); assertEquals(calls.length, before); assertEquals(errors.at(-1), "Slash commands cannot include images"); assertEquals((await fresh("command-images")).length, 1); const busy = await open("busy-images", true); busy.paste([file()]); await busy.saved(); await busy.submit(true); assertEquals(calls.at(-1)!.followUp, true); assertEquals(calls.at(-1)!.images[0].data, PNG); }, ); await t.step( "restored messages retain attachments and never replace text or image drafts", async () => { const view = await open("restored-images"); const content = [ { type: "text" as const, text: "Bring this back" }, { type: "image" as const, mimeType: "image/png", data: PNG }, ]; view.setText(" "); assertEquals(await view.restore(content), false); assertEquals(view.input.value, " "); view.setText(""); assertEquals(await view.restore(content), true); assertEquals(view.input.value, "Bring this back"); assertEquals((await fresh("restored-images")).length, 1); view.setText(""); assertEquals(await view.restore("must not replace the image"), false); assertEquals(view.input.value, ""); view.setText("Bring this back"); await view.submit(); assertEquals(calls.at(-1), { text: "Bring this back", followUp: false, images: [{ type: "image", mimeType: "image/png", data: PNG }], }); assertEquals(await fresh("restored-images"), []); }, ); await t.step("invalid and undecodable images cannot be sent and can be removed", async () => { const view = await open("invalid-images"); const before = calls.length; view.paste([new File(["not an image"], "broken.png", { type: "image/png" })]); await view.saved(); assertStringIncludes( view.element.querySelector(".attachment-status")!.textContent!, "declared format", ); await view.submit(); assertEquals(calls.length, before); assertEquals(await fresh("invalid-images"), []); view.element.querySelector('[title="Remove image"]')!.click(); await until(() => imageDraft("invalid-images").items.get().length === 0); decode = () => Promise.reject(new Error("Cannot decode image")); view.paste([file()]); await view.saved(); assertStringIncludes( view.element.querySelector(".attachment-status")!.textContent!, "Cannot decode", ); decode = () => Promise.resolve({ width: 1, height: 1, close() {} }); const crowded = await open("crowded-images"); crowded.paste(Array.from({ length: 5 }, () => file())); await until(() => errors.at(-1)?.includes("at most 4") === true); assertEquals(crowded.element.querySelectorAll(".attachment").length, 0); }); assert(calls.length > 0); } finally { for (const scope of scopes) scope.dispose(); } }, { globals }); });