refactor(test): migrate WorkspaceFilesPanel test to shared template-inspection seam

Route genuine Lit event-wiring (upload input change, form submit, file-tree
row clicks) through the shared templateInspection.testSupport escape hatch and
add the required proportionality comment. Move viewer content messaging
(empty/loading/binary states) to a new public workspaceFileViewerStatusLabel
seam on the component instead of scraping Lit markup for text. Delete the
per-file inspection helper cluster; drop @public from the two shared helpers
that now have a real importer.
This commit is contained in:
Federico Jaramillo Martinez
2026-07-17 21:06:12 +02:00
parent e7926e173b
commit d02bbfa545
3 changed files with 57 additions and 127 deletions
@@ -1,10 +1,15 @@
import type { TemplateResult } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { FileContentResponse, FileTreeEntry } from "../api";
import { initialAppState } from "../appState";
import type { WorkspacePanelContext } from "../plugins/types";
import type { WorkspaceUploadBatchState } from "../workspaceUploadState";
import { WorkspaceFilesPanel, startDirectWorkspaceUpload, uploadBatchProgressValue, uploadBatchStatusLabel, workspaceUploadBatchesForScope, workspaceUploadReviewDefaults, workspaceUploadReviewError } from "./WorkspaceFilesPanel";
// Genuine Lit event-wiring extraction (upload input/form submit and file-tree
// row clicks) routes through the shared, type-guarded template-inspection escape
// hatch; see ../templateInspection.testSupport for the proportionality
// rationale. Viewer content messaging is asserted via the public
// workspaceFileViewerStatusLabel seam instead of scraping Lit markup.
import { findOptionalTemplateEventHandlerAfterMarker, templateClickHandlerForText, templateEventHandlerAfterMarker } from "../templateInspection.testSupport";
import { WorkspaceFilesPanel, startDirectWorkspaceUpload, uploadBatchProgressValue, uploadBatchStatusLabel, workspaceFileViewerStatusLabel, workspaceUploadBatchesForScope, workspaceUploadReviewDefaults, workspaceUploadReviewError } from "./WorkspaceFilesPanel";
afterEach(() => {
vi.unstubAllGlobals();
@@ -18,14 +23,14 @@ describe("workspace-files-panel upload review", () => {
const panel = new WorkspaceFilesPanel();
panel.context = workspacePanelContext({ workspaceUploadDefaultFolder: "project/uploads", onStartWorkspaceUpload });
const inputChange = findTemplateEventHandler<Event>(panel.render(), `id="workspace-upload-input"`);
const inputChange = templateEventHandlerAfterMarker(panel.render(), `id="workspace-upload-input"`);
const input = new FakeHTMLInputElement(files);
inputChange(new EventWithCurrentTarget("change", input));
expect(input.value).toBe("");
expect(onStartWorkspaceUpload).not.toHaveBeenCalled();
const submit = findTemplateEventHandler<SubmitEvent>(panel.render(), "<form @submit=");
const submit = templateEventHandlerAfterMarker<SubmitEvent>(panel.render(), "<form @submit=");
const submitEvent = new FakeSubmitEvent("submit", { cancelable: true });
submit(submitEvent);
@@ -36,7 +41,7 @@ describe("workspace-files-panel upload review", () => {
overwrite: false,
selectUploadedFile: true,
});
expect(findOptionalTemplateEventHandler<SubmitEvent>(panel.render(), "<form @submit=")).toBeUndefined();
expect(findOptionalTemplateEventHandlerAfterMarker<SubmitEvent>(panel.render(), "<form @submit=")).toBeUndefined();
});
});
@@ -55,20 +60,36 @@ describe("workspace-files-panel file tree boundary", () => {
});
const rendered = panel.render();
const text = collectTemplateText(rendered);
expect(text).toContain("▾");
expect(text).toContain("src");
expect(text).toContain("main.ts");
expect(text).toContain("README.md");
expect(text).toContain("Binary file: README.md · 4.0 KB");
expect(text).not.toContain("Select a file.");
findTemplateClickHandlerForText<Event>(rendered, "src")(new Event("click"));
findTemplateClickHandlerForText<Event>(rendered, "README.md")(new Event("click"));
// The nested child (main.ts) is only reachable when the expanded directory
// actually renders its children, so a working click handler on it proves the
// expanded-tree structure without scraping markup for the row text.
templateClickHandlerForText(rendered, "main.ts")(new Event("click"));
templateClickHandlerForText(rendered, "src")(new Event("click"));
templateClickHandlerForText(rendered, "README.md")(new Event("click"));
expect(onExpandDir).toHaveBeenCalledWith("src");
expect(onSelectFile).toHaveBeenCalledWith("src/main.ts");
expect(onSelectFile).toHaveBeenCalledWith("README.md");
// Viewer messaging (selected binary file) is a content concern; assert it
// through the public seam rather than the rendered template.
expect(workspaceFileViewerStatusLabel(workspacePanelContext({
selectedFilePath: "README.md",
selectedFileContent: binaryFileContent("README.md", 4096),
}))).toBe("Binary file: README.md · 4.0 KB");
});
});
describe("workspaceFileViewerStatusLabel", () => {
it("messages empty, loading, and binary viewer states while deferring to real viewers", () => {
expect(workspaceFileViewerStatusLabel(workspacePanelContext({ selectedFilePath: undefined }))).toBe("Select a file.");
expect(workspaceFileViewerStatusLabel(workspacePanelContext({ selectedFilePath: "" }))).toBe("Select a file.");
expect(workspaceFileViewerStatusLabel(workspacePanelContext({ selectedFilePath: "notes.md", selectedFileContent: undefined }))).toBe("Loading notes.md…");
expect(workspaceFileViewerStatusLabel(workspacePanelContext({
selectedFilePath: "logo.png",
selectedFileContent: { ...binaryFileContent("logo.png", 10), mediaType: "image" },
}))).toBeUndefined();
});
});
@@ -150,111 +171,6 @@ describe("workspaceUploadReviewError", () => {
});
});
type TemplateEventHandler<E extends Event> = (event: E) => void;
function findTemplateEventHandler<E extends Event>(template: TemplateResult, marker: string): TemplateEventHandler<E> {
const handler = findOptionalTemplateEventHandler<E>(template, marker);
if (handler === undefined) throw new Error(`Expected template event handler after ${marker}`);
return handler;
}
function findOptionalTemplateEventHandler<E extends Event>(template: TemplateResult, marker: string): TemplateEventHandler<E> | undefined {
return findInTemplate(template);
function findInTemplate(current: TemplateResult): TemplateEventHandler<E> | undefined {
const strings = templateStrings(current);
const values = templateValues(current);
for (let index = 0; index < values.length; index += 1) {
const staticChunk = strings[index];
const value = values[index];
if (staticChunk !== undefined && staticChunk.includes(marker) && isTemplateEventHandler<E>(value)) return value;
const nestedHandler = findInValue(value);
if (nestedHandler !== undefined) return nestedHandler;
}
return undefined;
}
function findInValue(value: unknown): TemplateEventHandler<E> | undefined {
if (Array.isArray(value)) {
for (const item of value) {
const nestedHandler = findInValue(item);
if (nestedHandler !== undefined) return nestedHandler;
}
return undefined;
}
if (isTemplateResult(value)) return findInTemplate(value);
return undefined;
}
}
// Node-based Lit tests cannot click shadow DOM here; keep direct handler extraction
// anchored to rendered file labels and assert the observable context callbacks.
function findTemplateClickHandlerForText<E extends Event>(template: TemplateResult, text: string): TemplateEventHandler<E> {
const handler = findOptionalTemplateClickHandlerForText<E>(template, text);
if (handler === undefined) throw new Error(`Expected click handler near ${text}`);
return handler;
}
function findOptionalTemplateClickHandlerForText<E extends Event>(value: unknown, text: string): TemplateEventHandler<E> | undefined {
if (Array.isArray(value)) {
for (const item of value) {
const nestedHandler = findOptionalTemplateClickHandlerForText<E>(item, text);
if (nestedHandler !== undefined) return nestedHandler;
}
return undefined;
}
if (!isTemplateResult(value)) return undefined;
for (const item of templateValues(value)) {
const nestedHandler = findOptionalTemplateClickHandlerForText<E>(item, text);
if (nestedHandler !== undefined) return nestedHandler;
}
if (!collectTemplateText(value).includes(text)) return undefined;
const strings = templateStrings(value);
const values = templateValues(value);
for (let index = 0; index < values.length; index += 1) {
const staticChunk = strings[index];
const candidate = values[index];
if (staticChunk !== undefined && staticChunk.includes("@click") && isTemplateEventHandler<E>(candidate)) return candidate;
}
return undefined;
}
function collectTemplateText(value: unknown): string {
if (Array.isArray(value)) return value.map((item) => collectTemplateText(item)).join("");
if (isTemplateResult(value)) {
const strings = templateStrings(value);
const values = templateValues(value);
return strings.map((part, index) => `${part}${index < values.length ? collectTemplateText(values[index]) : ""}`).join("");
}
return typeof value === "string" || typeof value === "number" ? String(value) : "";
}
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");
}
class FakeFileList implements FileList {
readonly length: number;
[index: number]: File;
@@ -95,11 +95,13 @@ export class WorkspaceFilesPanel extends LitElement {
}
private renderFileViewer(context: WorkspacePanelContext): TemplateResult {
const status = workspaceFileViewerStatusLabel(context);
if (status !== undefined) return html`<p class="muted">${status}</p>`;
const file = context.selectedFileContent;
if (context.selectedFilePath === undefined || context.selectedFilePath === "") return html`<p class="muted">Select a file.</p>`;
if (file === undefined) return html`<p class="muted">Loading ${context.selectedFilePath}…</p>`;
// workspaceFileViewerStatusLabel already returned for the undefined/binary
// cases above; this guard only narrows the type for the code viewer path.
if (file === undefined) return html`<p class="muted">Select a file.</p>`;
if (file.mediaType === "image") return this.renderImageViewer(context, file);
if (file.binary) return html`<p class="muted">Binary file: ${file.path} · ${formatFileSize(file.size)}</p>`;
loadCodeViewer();
return html`
<div class="viewer-header"><strong>${file.path}</strong><small>${file.language ?? "text"}${file.truncated ? " · truncated" : ""}</small></div>
@@ -399,6 +401,22 @@ export function workspaceUploadReviewDefaults(destinationFolder: string): { dest
return { destinationFolder, createDirs: true, overwrite: false };
}
/**
* The muted status message the file viewer shows instead of file content, or
* `undefined` when a real image/code viewer renders. Pure seam so tests can
* assert viewer messaging (empty/loading/binary) without scraping Lit markup.
*/
export function workspaceFileViewerStatusLabel(
context: Pick<WorkspacePanelContext, "selectedFilePath" | "selectedFileContent">,
): string | undefined {
const file = context.selectedFileContent;
if (context.selectedFilePath === undefined || context.selectedFilePath === "") return "Select a file.";
if (file === undefined) return `Loading ${context.selectedFilePath}`;
if (file.mediaType === "image") return undefined;
if (file.binary) return `Binary file: ${file.path} · ${formatFileSize(file.size)}`;
return undefined;
}
export function startDirectWorkspaceUpload(
context: Pick<WorkspacePanelContext, "workspaceUploadDefaultFolder" | "onStartWorkspaceUpload">,
files: readonly File[],
@@ -242,8 +242,6 @@ export function findOptionalTemplateEventHandlerNearMarker<E extends Event = Eve
*
* Use when the marker is not the handler's own attribute but a stable anchor
* that precedes it (e.g. a `send-button` id whose `@click=` handler follows).
*
* @public
*/
export function templateEventHandlerAfterMarker<E extends Event = Event>(template: TemplateResult, marker: string): TemplateEventHandler<E> {
const handler = findOptionalTemplateEventHandlerAfterMarker<E>(template, marker);
@@ -331,8 +329,6 @@ export function findOptionalTemplateEventHandlerAfterValue<E extends Event = Eve
*
* Anchors wiring to user-facing row/label text (e.g. a file name) rather than
* incidental handler order.
*
* @public
*/
export function templateClickHandlerForText<E extends Event = Event>(template: TemplateResult, text: string, clickMarker = "@click"): TemplateEventHandler<E> {
const handler = findOptionalTemplateClickHandlerForText<E>(template, text, clickMarker);