refactor(test): migrate promptAttachmentCapture.test.ts to shared template-inspection seam

This commit is contained in:
Federico Jaramillo Martinez
2026-07-17 21:47:28 +02:00
parent 9a94f41a61
commit 2d7ff629be
2 changed files with 18 additions and 141 deletions
+18 -127
View File
@@ -1,7 +1,7 @@
import type { TemplateResult } from "lit";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { PromptEditor } from "./components/PromptEditor"; import { PromptEditor } from "./components/PromptEditor";
import { capturePromptAttachments, DEFAULT_FILE_MIME_TYPE, effectivePromptAttachmentDelivery, READ_FAILURE_MESSAGE, type CapturableFile } from "./promptAttachmentCapture"; import { capturePromptAttachments, DEFAULT_FILE_MIME_TYPE, effectivePromptAttachmentDelivery, READ_FAILURE_MESSAGE, type CapturableFile } from "./promptAttachmentCapture";
import { templateEventHandlerAfterMarker, templateEventHandlerAfterValue } from "./templateInspection.testSupport";
function file(name: string, type: string, size = 10): CapturableFile { function file(name: string, type: string, size = 10): CapturableFile {
return { name, type, size }; return { name, type, size };
@@ -83,9 +83,13 @@ describe("effectivePromptAttachmentDelivery", () => {
}); });
describe("PromptEditor attachment wiring", () => { describe("PromptEditor attachment wiring", () => {
// Direct TemplateResult handler extraction keeps these node-environment tests focused on // TemplateResult handler extraction (via the shared escape hatch) verifies the paste/remove/send
// PromptEditor wiring without introducing a DOM/FileReader harness for the whole component. // event wiring here; this repo runs Vitest without a DOM environment, so a full custom-element +
it("captures pasted files, strips data URL prefixes, and surfaces read failures", async () => { // FileReader render harness would add disproportionate setup for this narrow wiring check. Each
// assertion observes a component effect (the injected `onSend` callback and its captured
// attachments), not Lit template internals. Rendered content/error text is covered at the pure
// layer by the `capturePromptAttachments` tests above.
it("captures pasted files, strips data URL prefixes, and keeps successful reads when others fail", async () => {
const editor = new PromptEditor(); const editor = new PromptEditor();
const onSend = vi.fn<NonNullable<PromptEditor["onSend"]>>(); const onSend = vi.fn<NonNullable<PromptEditor["onSend"]>>();
editor.onSend = onSend; editor.onSend = onSend;
@@ -96,7 +100,7 @@ describe("PromptEditor attachment wiring", () => {
]); ]);
try { try {
const paste = findTemplateEventHandlerAfterMarker<Event>(editor.render(), "@paste="); const paste = templateEventHandlerAfterMarker(editor.render(), "@paste=");
const pasteEvent = pasteEventWithFiles([ const pasteEvent = pasteEventWithFiles([
new File(["png"], "shot.png", { type: "image/png" }), new File(["png"], "shot.png", { type: "image/png" }),
new File(["pdf"], "report.pdf", { type: "application/pdf" }), new File(["pdf"], "report.pdf", { type: "application/pdf" }),
@@ -107,12 +111,13 @@ describe("PromptEditor attachment wiring", () => {
await flushMicrotasks(); await flushMicrotasks();
expect(preventDefault).toHaveBeenCalledOnce(); expect(preventDefault).toHaveBeenCalledOnce();
expect(templateContainsValue(editor.render(), "Remove shot.png")).toBe(true);
expect(templateContainsValue(editor.render(), READ_FAILURE_MESSAGE)).toBe(true);
const send = findTemplateEventHandlerAfterMarker<Event>(editor.render(), "send-button"); const send = templateEventHandlerAfterMarker(editor.render(), "send-button");
send(new Event("click")); send(new Event("click"));
// report.pdf failed to read, so only the successfully-read image survives to onSend — proving
// the paste is wired to capture, the data URL prefix is stripped, and a failed read does not
// drop the other attachment.
expect(onSend).toHaveBeenCalledTimes(1); expect(onSend).toHaveBeenCalledTimes(1);
expect(onSend).toHaveBeenCalledWith("inspect attachments", undefined, [ expect(onSend).toHaveBeenCalledWith("inspect attachments", undefined, [
{ kind: "image", mimeType: "image/png", data: "UE5H", name: "shot.png" }, { kind: "image", mimeType: "image/png", data: "UE5H", name: "shot.png" },
@@ -132,15 +137,14 @@ describe("PromptEditor attachment wiring", () => {
{ id: "attachment-2", kind: "image", name: "shot.png", mimeType: "image/png", data: "UE5H", size: 3 }, { id: "attachment-2", kind: "image", name: "shot.png", mimeType: "image/png", data: "UE5H", size: 3 },
]); ]);
const removeReport = findTemplateEventHandlerAfterValue<Event>(editor.render(), "Remove report.pdf", "@click="); const removeReport = templateEventHandlerAfterValue(editor.render(), "Remove report.pdf", "@click=");
removeReport(new Event("click")); removeReport(new Event("click"));
expect(templateContainsValue(editor.render(), "Remove report.pdf")).toBe(false); const send = templateEventHandlerAfterMarker(editor.render(), "send-button");
expect(templateContainsValue(editor.render(), "Remove shot.png")).toBe(true);
const send = findTemplateEventHandlerAfterMarker<Event>(editor.render(), "send-button");
send(new Event("click")); send(new Event("click"));
// onSend receives only the image, proving the remove handler dropped report.pdf while leaving
// shot.png queued (folder delivery is not forced because no generic file remains).
expect(onSend).toHaveBeenCalledTimes(1); expect(onSend).toHaveBeenCalledTimes(1);
expect(onSend).toHaveBeenCalledWith("please review", undefined, [ expect(onSend).toHaveBeenCalledWith("please review", undefined, [
{ kind: "image", mimeType: "image/png", data: "UE5H", name: "shot.png" }, { kind: "image", mimeType: "image/png", data: "UE5H", name: "shot.png" },
@@ -148,8 +152,6 @@ describe("PromptEditor attachment wiring", () => {
}); });
}); });
type TemplateEventHandler<E extends Event> = (event: E) => void;
type StubFileReaderOutcome = type StubFileReaderOutcome =
| { kind: "load"; result: string } | { kind: "load"; result: string }
| { kind: "error"; error: DOMException }; | { kind: "error"; error: DOMException };
@@ -191,6 +193,7 @@ function installFileReaderStub(outcomes: StubFileReaderOutcome[]): () => void {
}; };
} }
function pasteEventWithFiles(files: readonly File[]): Event { function pasteEventWithFiles(files: readonly File[]): Event {
const event = new Event("paste", { cancelable: true }); const event = new Event("paste", { cancelable: true });
Object.defineProperty(event, "clipboardData", { value: { files } }); Object.defineProperty(event, "clipboardData", { value: { files } });
@@ -200,115 +203,3 @@ function pasteEventWithFiles(files: readonly File[]): Event {
async function flushMicrotasks(): Promise<void> { async function flushMicrotasks(): Promise<void> {
for (let remaining = 0; remaining < 10; remaining += 1) await Promise.resolve(); for (let remaining = 0; remaining < 10; remaining += 1) await Promise.resolve();
} }
function findTemplateEventHandlerAfterMarker<E extends Event>(template: TemplateResult, marker: string): TemplateEventHandler<E> {
const handler = findOptionalTemplateEventHandlerAfterMarker<E>(template, marker);
if (handler === undefined) throw new Error(`Expected template event handler after marker ${marker}`);
return handler;
}
function findOptionalTemplateEventHandlerAfterMarker<E extends Event>(template: TemplateResult, marker: string): TemplateEventHandler<E> | undefined {
const strings = templateStrings(template);
const values = templateValues(template);
for (let index = 0; index < values.length; index += 1) {
const staticChunk = strings[index];
if (staticChunk?.includes(marker) === true) {
const handler = nextTemplateEventHandler<E>(values, index);
if (handler !== undefined) return handler;
}
const nestedHandler = findOptionalTemplateEventHandlerAfterMarkerInValue<E>(values[index], marker);
if (nestedHandler !== undefined) return nestedHandler;
}
return undefined;
}
function findOptionalTemplateEventHandlerAfterMarkerInValue<E extends Event>(value: unknown, marker: string): TemplateEventHandler<E> | undefined {
if (Array.isArray(value)) {
for (const item of value) {
const nestedHandler = findOptionalTemplateEventHandlerAfterMarkerInValue<E>(item, marker);
if (nestedHandler !== undefined) return nestedHandler;
}
return undefined;
}
if (isTemplateResult(value)) return findOptionalTemplateEventHandlerAfterMarker<E>(value, marker);
return undefined;
}
function findTemplateEventHandlerAfterValue<E extends Event>(template: TemplateResult, expectedValue: unknown, marker: string): TemplateEventHandler<E> {
const handler = findOptionalTemplateEventHandlerAfterValue<E>(template, expectedValue, marker);
if (handler === undefined) throw new Error(`Expected template event handler after value ${String(expectedValue)}`);
return handler;
}
function findOptionalTemplateEventHandlerAfterValue<E extends Event>(template: TemplateResult, expectedValue: unknown, marker: string): TemplateEventHandler<E> | undefined {
const strings = templateStrings(template);
const values = templateValues(template);
for (let index = 0; index < values.length; index += 1) {
const value = values[index];
if (value === expectedValue) {
for (let handlerIndex = index + 1; handlerIndex < values.length; handlerIndex += 1) {
const staticChunk = strings[handlerIndex];
const maybeHandler = values[handlerIndex];
if (staticChunk?.includes(marker) === true && isTemplateEventHandler<E>(maybeHandler)) return maybeHandler;
}
}
const nestedHandler = findOptionalTemplateEventHandlerAfterValueInValue<E>(value, expectedValue, marker);
if (nestedHandler !== undefined) return nestedHandler;
}
return undefined;
}
function findOptionalTemplateEventHandlerAfterValueInValue<E extends Event>(value: unknown, expectedValue: unknown, marker: string): TemplateEventHandler<E> | undefined {
if (Array.isArray(value)) {
for (const item of value) {
const nestedHandler = findOptionalTemplateEventHandlerAfterValueInValue<E>(item, expectedValue, marker);
if (nestedHandler !== undefined) return nestedHandler;
}
return undefined;
}
if (isTemplateResult(value)) return findOptionalTemplateEventHandlerAfterValue<E>(value, expectedValue, marker);
return undefined;
}
function nextTemplateEventHandler<E extends Event>(values: readonly unknown[], startIndex: number): TemplateEventHandler<E> | undefined {
for (let index = startIndex; index < values.length; index += 1) {
const value = values[index];
if (isTemplateEventHandler<E>(value)) return value;
}
return undefined;
}
function templateContainsValue(template: TemplateResult, expectedValue: unknown): boolean {
return templateValues(template).some((value) => templateValueContains(value, expectedValue));
}
function templateValueContains(value: unknown, expectedValue: unknown): boolean {
if (value === expectedValue) return true;
if (Array.isArray(value)) return value.some((item) => templateValueContains(item, expectedValue));
if (isTemplateResult(value)) return templateContainsValue(value, expectedValue);
return false;
}
function templateStrings(template: TemplateResult): readonly string[] {
const strings = Reflect.get(template, "strings");
if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable");
return strings;
}
function templateValues(template: TemplateResult): readonly unknown[] {
const values = Reflect.get(template, "values");
if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable");
return values.map((value: unknown) => value);
}
function isTemplateResult(value: unknown): value is TemplateResult {
return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values"));
}
function isTemplateEventHandler<E extends Event>(value: unknown): value is TemplateEventHandler<E> {
return typeof value === "function";
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
}
@@ -149,18 +149,6 @@ export function collectStringValues(template: TemplateResult): string[] {
} }
} }
/** True when `expectedValue` appears as an interpolated value anywhere in the tree. */
export function templateContainsValue(template: TemplateResult, expectedValue: unknown): boolean {
return templateValues(template).some((value) => valueContains(value, expectedValue));
function valueContains(value: unknown, target: unknown): boolean {
if (value === target) return true;
if (Array.isArray(value)) return value.some((item) => valueContains(item, target));
if (isTemplateResult(value)) return templateContainsValue(value, target);
return false;
}
}
/** /**
* Every interpolated value whose immediately preceding static chunk includes * Every interpolated value whose immediately preceding static chunk includes
* `marker`, collected across the whole tree in document order. * `marker`, collected across the whole tree in document order.
@@ -282,8 +270,6 @@ export function findOptionalTemplateEventHandlerAfterMarker<E extends Event = Ev
* Anchors to a stable value (e.g. an accessible label like `Remove report.pdf`) * Anchors to a stable value (e.g. an accessible label like `Remove report.pdf`)
* and then locates the handler tagged by `marker` (e.g. `@click=`) that follows * and then locates the handler tagged by `marker` (e.g. `@click=`) that follows
* it, so the wiring is tied to user-facing content rather than handler order. * it, so the wiring is tied to user-facing content rather than handler order.
*
* @public
*/ */
export function templateEventHandlerAfterValue<E extends Event = Event>(template: TemplateResult, expectedValue: unknown, marker: string): TemplateEventHandler<E> { export function templateEventHandlerAfterValue<E extends Event = Event>(template: TemplateResult, expectedValue: unknown, marker: string): TemplateEventHandler<E> {
const handler = findOptionalTemplateEventHandlerAfterValue<E>(template, expectedValue, marker); const handler = findOptionalTemplateEventHandlerAfterValue<E>(template, expectedValue, marker);