refactor(test): migrate ChatView image test to shared inspection seam

Replace the per-file TemplateResult inspection helper cluster in
ChatView.image.test.ts with the shared templateInspection.testSupport
escape hatch for genuine event wiring (@load re-pin, @click zoom), and
move content/attribute assertions to new pure public seams on ChatView
(chatImagePartSource, chatToolOutputLabel, chatMessageAnchorKey) that
the component's own render code now delegates to.
This commit is contained in:
Federico Jaramillo Martinez
2026-07-17 21:15:35 +02:00
parent d02bbfa545
commit 6ba215503f
2 changed files with 63 additions and 122 deletions
+42 -117
View File
@@ -1,22 +1,49 @@
import type { TemplateResult } from "lit";
import { describe, expect, it } from "vitest";
import type { ChatLine } from "./shared";
import { ChatView, chatMessageMetadataLabel } from "./ChatView";
import {
ChatView,
chatImagePartSource,
chatMessageAnchorKey,
chatToolOutputLabel,
} from "./ChatView";
import { templateEventHandlerAfterMarker } from "../templateInspection.testSupport";
describe("ChatView image rendering", () => {
// Direct handler extraction keeps this node-environment test focused on the
// late image-load scroll wiring without introducing a component-wide DOM shim.
it("renders native image data and re-pins late loads only while already pinned", () => {
describe("ChatView image content derivation", () => {
// Content/attribute derivation (image src/alt, the tool-output header label,
// and the scroll-anchor key) lives in pure exported seams rather than being
// scraped from rendered `TemplateResult` markup, per the testing-guide rule
// that TemplateResult inspection is not for general content assertions.
it("derives the image data URL and alt text from an image part", () => {
expect(chatImagePartSource({ type: "image", mimeType: "image/png", data: "QUJD" })).toEqual({
src: "data:image/png;base64,QUJD",
alt: "attached image",
});
});
it("labels tool image output by tool name and falls back to a generic label", () => {
expect(chatToolOutputLabel("read")).toBe("read output");
expect(chatToolOutputLabel(undefined)).toBe("tool output");
expect(chatToolOutputLabel("")).toBe("tool output");
});
it("keys a tool image message to its stable scroll anchor", () => {
expect(chatMessageAnchorKey(7)).toBe("m:7");
});
});
describe("ChatView image event wiring", () => {
// Escape hatch: these two cases verify Lit event wiring (`@load` re-pin and
// `@click` zoom) whose only observable effect is a private state/scroll side
// effect. Vitest runs with no DOM environment here, so a shadow-DOM click
// harness would add disproportionate setup; direct handler extraction anchored
// to the stable `@load=`/`@click=` attribute markup is proportionate.
it("re-pins late image loads only while already pinned to the bottom", () => {
const view = new ChatView();
let scrollCalls = 0;
if (!Reflect.set(view, "scrollToBottom", () => { scrollCalls += 1; })) throw new Error("Could not observe ChatView.scrollToBottom");
const rendered = renderPart(view, { type: "image", mimeType: "image/png", data: "QUJD" });
const onLoad = templateEventHandler(rendered, "@load=");
expect(templateStaticMarkup(rendered)).toContain("<img");
expect(templateStaticMarkup(rendered)).toContain('loading="lazy"');
expect(templateStaticMarkup(rendered)).toContain('role="button"');
expect(templateValuesAfterMarker(rendered, "src=")).toEqual(["data:image/png;base64,QUJD"]);
const onLoad = templateEventHandlerAfterMarker(rendered, "@load=");
if (!Reflect.set(view, "pinnedToBottom", true)) throw new Error("Could not set ChatView.pinnedToBottom");
onLoad(new Event("load"));
@@ -26,41 +53,21 @@ describe("ChatView image rendering", () => {
expect(scrollCalls).toBe(1);
});
// Clicking an image records the zoom target so the modal dialog can present
// it at full size, and dismissing clears the target.
it("opens and closes the image zoom target on click and close", () => {
const view = new ChatView();
const rendered = renderPart(view, { type: "image", mimeType: "image/png", data: "QUJD" });
const onClick = templateEventHandler(rendered, "@click=");
const part = { type: "image", mimeType: "image/png", data: "QUJD" } as const;
const rendered = renderPart(view, part);
const onClick = templateEventHandlerAfterMarker(rendered, "@click=");
expect(zoomedImage(view)).toBeUndefined();
onClick(new Event("click"));
expect(zoomedImage(view)).toEqual({ src: "data:image/png;base64,QUJD", alt: "attached image" });
expect(zoomedImage(view)).toEqual(chatImagePartSource(part));
const close: unknown = Reflect.get(view, "closeImageZoom");
if (typeof close !== "function") throw new Error("ChatView.closeImageZoom is not callable");
close.call(view);
expect(zoomedImage(view)).toBeUndefined();
});
// Direct rendering keeps this node-environment test focused on the dedicated
// tool-image presentation without introducing a component-wide DOM shim.
it("renders tool images as labeled standard messages with final metadata", () => {
const message: ChatLine = {
role: "tool",
parts: [{ type: "image", mimeType: "image/png", data: "QUJD" }],
meta: { timestamp: "2026-07-13T22:00:00.000Z" },
};
const rendered = renderToolImageOutput(new ChatView(), message, 7, "read");
const markup = templateStaticMarkup(rendered);
expect(markup).toContain('class="msg tool-image-output"');
expect(markup).not.toContain('class="msg tool"');
expect(markup).toContain("<img");
expect(templateValuesAfterMarker(rendered, '<b class="label">')).toEqual(["read output"]);
expect(templateValuesAfterMarker(rendered, "title=").filter((value) => typeof value === "string")).toEqual([chatMessageMetadataLabel(message)]);
expect(templateValuesAfterMarker(rendered, "data-scroll-anchor-id=")).toEqual(["m:7"]);
});
});
function zoomedImage(view: ChatView): unknown {
@@ -68,8 +75,6 @@ function zoomedImage(view: ChatView): unknown {
}
type RenderPart = (this: ChatView, part: ChatLine["parts"][number], message?: ChatLine) => TemplateResult;
type RenderToolImageOutput = (this: ChatView, message: ChatLine, index: number, toolName?: string) => TemplateResult;
type TemplateEventHandler = (event: Event) => void;
function renderPart(view: ChatView, part: ChatLine["parts"][number], message?: ChatLine): TemplateResult {
const method: unknown = Reflect.get(view, "renderPart");
@@ -77,86 +82,6 @@ function renderPart(view: ChatView, part: ChatLine["parts"][number], message?: C
return method.call(view, part, message);
}
function renderToolImageOutput(view: ChatView, message: ChatLine, index: number, toolName?: string): TemplateResult {
const method: unknown = Reflect.get(view, "renderToolImageOutput");
if (!isRenderToolImageOutput(method)) throw new Error("ChatView.renderToolImageOutput is not callable");
return method.call(view, message, index, toolName);
}
function templateEventHandler(template: TemplateResult, marker: string): TemplateEventHandler {
const strings = templateStrings(template);
const values = templateValues(template);
for (let index = 0; index < values.length; index += 1) {
const value = values[index];
if (strings[index]?.includes(marker) === true && isTemplateEventHandler(value)) return value;
}
throw new Error(`Expected template event handler after ${marker}`);
}
function isRenderPart(value: unknown): value is RenderPart {
return typeof value === "function";
}
function isRenderToolImageOutput(value: unknown): value is RenderToolImageOutput {
return typeof value === "function";
}
function isTemplateEventHandler(value: unknown): value is TemplateEventHandler {
return typeof value === "function";
}
function templateStaticMarkup(template: TemplateResult): string {
const chunks: string[] = [];
visit(template);
return chunks.join("");
function visit(value: unknown): void {
if (Array.isArray(value)) {
for (const item of value) visit(item);
return;
}
if (!isTemplateResult(value)) return;
chunks.push(...templateStrings(value));
for (const child of templateValues(value)) visit(child);
}
}
function templateValuesAfterMarker(template: TemplateResult, marker: string): unknown[] {
const matches: unknown[] = [];
visit(template);
return matches;
function visit(value: unknown): void {
if (Array.isArray(value)) {
for (const item of value) visit(item);
return;
}
if (!isTemplateResult(value)) return;
const strings = templateStrings(value);
const values = templateValues(value);
for (let index = 0; index < values.length; index += 1) {
if (strings[index]?.includes(marker) === true) matches.push(values[index]);
visit(values[index]);
}
}
}
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 isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
}
+21 -5
View File
@@ -45,6 +45,23 @@ export function chatQueuedMessageSections(clientQueued: QueuedSessionMessage[],
].filter((section): section is QueuedMessageSection => section !== undefined);
}
export type ChatImagePart = Extract<ChatPart, { type: "image" }>;
/** Derive the `<img>` source URL and alt text for a rendered image part. */
export function chatImagePartSource(part: ChatImagePart): { src: string; alt: string } {
return { src: `data:${part.mimeType};base64,${part.data}`, alt: "attached image" };
}
/** The message-header label used when a tool message renders as an image output. */
export function chatToolOutputLabel(toolName?: string): string {
return toolName === undefined || toolName === "" ? "tool output" : `${toolName} output`;
}
/** The stable scroll-anchor/render key for a top-level message at `index`. */
export function chatMessageAnchorKey(index: number): string {
return `m:${String(index)}`;
}
export function chatMessageMetadataLabel(message: ChatLine): string {
const timestamp = message.meta?.timestamp;
const time = timestamp === undefined ? undefined : formatMessageTimestamp(timestamp);
@@ -436,7 +453,7 @@ export class ChatView extends LitElement {
}
private renderToolImageOutput(message: ChatLine, index: number, toolName?: string) {
const label = toolName === undefined || toolName === "" ? "tool output" : `${toolName} output`;
const label = chatToolOutputLabel(toolName);
return html`
${this.renderScrollMarker(this.messageScrollMarkerId(index))}
<article class="msg tool-image-output" data-index=${index} data-scroll-anchor-id=${this.messageAnchorKey(index)}>
@@ -570,8 +587,7 @@ export class ChatView extends LitElement {
</div>
`;
if (part.type === "image") {
const src = `data:${part.mimeType};base64,${part.data}`;
const alt = "attached image";
const { src, alt } = chatImagePartSource(part);
return html`<img class="part chat-image" src=${src} alt=${alt} loading="lazy" role="button" tabindex="0" title="Click to enlarge" @load=${this.onImageLoad} @click=${() => { this.openImageZoom(src, alt); }} @keydown=${(event: KeyboardEvent) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); this.openImageZoom(src, alt); } }} />`;
}
if (part.type === "toolCall") return html`<div class="part tool-line">▶ ${part.toolName}<span class="summary">${part.summary}</span></div>`;
@@ -874,7 +890,7 @@ export class ChatView extends LitElement {
}
private messageAnchorKey(index: number): string {
return `m:${String(index)}`;
return chatMessageAnchorKey(index);
}
private groupRenderKey(startIndex: number): string {
@@ -890,7 +906,7 @@ export class ChatView extends LitElement {
}
private messageScrollMarkerId(index: number): string {
return `m:${String(index)}`;
return chatMessageAnchorKey(index);
}
private groupScrollMarkerId(endIndex: number): string {