From d02bbfa545fc5f7317d96f09337b1b63f33795de Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 17 Jul 2026 21:06:12 +0200 Subject: [PATCH] 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. --- .../components/WorkspaceFilesPanel.test.ts | 156 ++++-------------- .../src/components/WorkspaceFilesPanel.ts | 24 ++- .../src/templateInspection.testSupport.ts | 4 - 3 files changed, 57 insertions(+), 127 deletions(-) diff --git a/src/client/src/components/WorkspaceFilesPanel.test.ts b/src/client/src/components/WorkspaceFilesPanel.test.ts index 6fcbdaf..35fd041 100644 --- a/src/client/src/components/WorkspaceFilesPanel.test.ts +++ b/src/client/src/components/WorkspaceFilesPanel.test.ts @@ -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(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(panel.render(), "
{ overwrite: false, selectUploadedFile: true, }); - expect(findOptionalTemplateEventHandler(panel.render(), " { }); 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(rendered, "src")(new Event("click")); - findTemplateClickHandlerForText(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 = (event: E) => void; - -function findTemplateEventHandler(template: TemplateResult, marker: string): TemplateEventHandler { - const handler = findOptionalTemplateEventHandler(template, marker); - if (handler === undefined) throw new Error(`Expected template event handler after ${marker}`); - return handler; -} - -function findOptionalTemplateEventHandler(template: TemplateResult, marker: string): TemplateEventHandler | undefined { - return findInTemplate(template); - - function findInTemplate(current: TemplateResult): TemplateEventHandler | 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(value)) return value; - const nestedHandler = findInValue(value); - if (nestedHandler !== undefined) return nestedHandler; - } - return undefined; - } - - function findInValue(value: unknown): TemplateEventHandler | 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(template: TemplateResult, text: string): TemplateEventHandler { - const handler = findOptionalTemplateClickHandlerForText(template, text); - if (handler === undefined) throw new Error(`Expected click handler near ${text}`); - return handler; -} - -function findOptionalTemplateClickHandlerForText(value: unknown, text: string): TemplateEventHandler | undefined { - if (Array.isArray(value)) { - for (const item of value) { - const nestedHandler = findOptionalTemplateClickHandlerForText(item, text); - if (nestedHandler !== undefined) return nestedHandler; - } - return undefined; - } - if (!isTemplateResult(value)) return undefined; - - for (const item of templateValues(value)) { - const nestedHandler = findOptionalTemplateClickHandlerForText(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(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(value: unknown): value is TemplateEventHandler { - 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; diff --git a/src/client/src/components/WorkspaceFilesPanel.ts b/src/client/src/components/WorkspaceFilesPanel.ts index a995328..7273a8e 100644 --- a/src/client/src/components/WorkspaceFilesPanel.ts +++ b/src/client/src/components/WorkspaceFilesPanel.ts @@ -95,11 +95,13 @@ export class WorkspaceFilesPanel extends LitElement { } private renderFileViewer(context: WorkspacePanelContext): TemplateResult { + const status = workspaceFileViewerStatusLabel(context); + if (status !== undefined) return html`

${status}

`; const file = context.selectedFileContent; - if (context.selectedFilePath === undefined || context.selectedFilePath === "") return html`

Select a file.

`; - if (file === undefined) return html`

Loading ${context.selectedFilePath}…

`; + // 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`

Select a file.

`; if (file.mediaType === "image") return this.renderImageViewer(context, file); - if (file.binary) return html`

Binary file: ${file.path} · ${formatFileSize(file.size)}

`; loadCodeViewer(); return html`
${file.path}${file.language ?? "text"}${file.truncated ? " · truncated" : ""}
@@ -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, +): 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, files: readonly File[], diff --git a/src/client/src/templateInspection.testSupport.ts b/src/client/src/templateInspection.testSupport.ts index ead0fce..6536759 100644 --- a/src/client/src/templateInspection.testSupport.ts +++ b/src/client/src/templateInspection.testSupport.ts @@ -242,8 +242,6 @@ export function findOptionalTemplateEventHandlerNearMarker(template: TemplateResult, marker: string): TemplateEventHandler { const handler = findOptionalTemplateEventHandlerAfterMarker(template, marker); @@ -331,8 +329,6 @@ export function findOptionalTemplateEventHandlerAfterValue(template: TemplateResult, text: string, clickMarker = "@click"): TemplateEventHandler { const handler = findOptionalTemplateClickHandlerForText(template, text, clickMarker);