// @vitest-environment jsdom import { cleanup, fireEvent, render, screen, waitFor, } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ComponentProps } from "react"; import { DesignFilesPanel, type DesignFilesNavState, } from "../../src/components/DesignFilesPanel"; import type { ProjectFile, ProjectFileKind, ProjectFolder, } from "../../src/types"; import { VISUAL_STABILITY_STORAGE_KEY } from "../../src/utils/visualStability"; function folder(path: string): ProjectFolder { return { name: path.split("/").pop() ?? path, path, type: "dir", size: 0, mtime: 1700000000, }; } // Stub localStorage so the component's view-state persistence writes to an // in-memory store. Cleared in beforeEach so no test bleeds state into the next. const lsStore = new Map(); vi.stubGlobal("localStorage", { getItem: (key: string) => lsStore.get(key) ?? null, setItem: (key: string, value: string) => { lsStore.set(key, value); }, removeItem: (key: string) => { lsStore.delete(key); }, clear: () => { lsStore.clear(); }, }); beforeEach(() => { lsStore.clear(); }); function extForKind(kind: ProjectFileKind): string { if (kind === "html") return "html"; if (kind === "image") return "png"; if (kind === "sketch") return "sketch.json"; if (kind === "text") return "txt"; if (kind === "code") return "ts"; if (kind === "pdf") return "pdf"; return "bin"; } function file( overrides: Partial & Pick, ): ProjectFile { return { path: overrides.name, type: "file", size: 1024, mtime: Date.now(), kind: "html", mime: "text/html", ...overrides, }; } function generateFiles(count: number): ProjectFile[] { const kinds: ProjectFileKind[] = [ "html", "image", "sketch", "text", "code", "pdf", ]; return Array.from({ length: count }, (_, i) => { const kind = kinds[i % kinds.length]!; return file({ name: `file-${i + 1}.${extForKind(kind)}`, kind, size: 1024 * (i + 1), mtime: Date.now() - i * 60_000, mime: "text/plain", }); }); } function renderPanel( files: ProjectFile[], overrides: Partial> = {}, ) { const onOpenFile = vi.fn(); const onDeleteFiles = vi.fn(); const onClearUploadError = vi.fn(); const result = render( , ); return { ...result, onDeleteFiles, onOpenFile, onClearUploadError }; } function sectionLabels(): string[] { return Array.from( document.querySelectorAll(".df-section-label"), ).map((el) => el.textContent ?? ""); } describe("DesignFilesPanel sections", () => { afterEach(() => { cleanup(); vi.useRealTimers(); }); it("does not show grouping, sort, filter, or pagination chrome", () => { renderPanel(generateFiles(60)); expect(screen.queryByRole("group", { name: "Group by" })).toBeNull(); expect(document.querySelector(".df-table")).toBeNull(); expect(document.querySelector(".df-th-sortable")).toBeNull(); expect(document.querySelector(".df-kind-filter")).toBeNull(); expect(document.querySelector(".df-pagination")).toBeNull(); expect(document.querySelector(".df-page-btn")).toBeNull(); }); it("renders a single-line toolbar with file actions and no up/refresh buttons", () => { renderPanel([file({ name: "page.html", kind: "html" })]); expect(document.querySelector(".df-topbar")).toBeTruthy(); expect(screen.queryByRole("button", { name: "Up" })).toBeNull(); expect(screen.queryByRole("button", { name: "Refresh" })).toBeNull(); expect(document.querySelector(".df-up-btn")).toBeNull(); expect(document.querySelector(".df-refresh-control")).toBeNull(); expect(screen.getByTestId("design-files-upload-trigger")).toBeTruthy(); }); it("shows prioritized project starter actions in the empty state", () => { const onNewSketch = vi.fn(); const onOpenBrowser = vi.fn(); const onPaste = vi.fn(); renderPanel([], { onNewSketch, onOpenBrowser, onPaste, }); fireEvent.click(screen.getByTestId("design-files-empty-new-sketch")); fireEvent.click(screen.getByTestId("design-files-empty-open-browser")); fireEvent.click(screen.getByTestId("design-files-empty-create-document")); expect(onNewSketch).toHaveBeenCalledTimes(1); expect(onOpenBrowser).toHaveBeenCalledTimes(1); expect(onPaste).toHaveBeenCalledTimes(1); expect( screen.getByTestId("design-files-empty-create-document").textContent, ).toContain("Create document"); expect( screen.queryByRole("button", { name: "Create new design system" }), ).toBeNull(); }); it("groups files into semantic sections by category", () => { renderPanel([ file({ name: "page.html", kind: "html", mime: "text/html" }), file({ name: "chart.png", kind: "image", mime: "image/png" }), ]); const labels = sectionLabels(); expect(labels.some((text) => text.includes("Pages"))).toBe(true); expect(labels.some((text) => text.includes("Images"))).toBe(true); expect(screen.getByTestId("design-file-row-page.html")).toBeTruthy(); expect(screen.getByTestId("design-file-row-chart.png")).toBeTruthy(); }); it("splits stylesheets into their own section with a Stylesheet subtitle", () => { renderPanel([ file({ name: "styles.css", kind: "code", mime: "text/css" }), file({ name: "app.ts", kind: "code", mime: "text/typescript" }), ]); const labels = sectionLabels(); expect(labels.some((text) => text.includes("Stylesheets"))).toBe(true); expect(labels.some((text) => text.includes("Scripts"))).toBe(true); const cssRow = screen.getByTestId("design-file-row-styles.css"); expect(cssRow.querySelector(".df-row-sub")?.textContent).toBe("Stylesheet"); const tsRow = screen.getByTestId("design-file-row-app.ts"); expect(tsRow.querySelector(".df-row-sub")?.textContent).toBe("Script"); }); it('shows the file size beside the modified time while preserving the type subtitle', () => { renderPanel([file({ name: 'chart.png', kind: 'image', size: 4096 })]); const row = screen.getByTestId('design-file-row-chart.png'); expect(row.querySelector('.df-row-sub')?.textContent).toBe('Image'); expect(row.querySelector('.df-row-size')?.textContent).toBe('4.0 KB'); }); it("shows the upload hint in the footer while idle", () => { renderPanel([file({ name: "page.html", kind: "html" })]); expect(document.querySelector(".df-drop-hint")).toBeTruthy(); expect(document.querySelector(".df-useful-info")).toBeNull(); }); it("types out the first useful-info tip in the footer while the agent runs", async () => { localStorage.setItem(VISUAL_STABILITY_STORAGE_KEY, "1"); renderPanel([file({ name: "page.html", kind: "html" })], { running: true }); expect(document.querySelector(".df-drop-hint")).toBeNull(); expect(document.querySelector(".df-useful-info-label")?.textContent).toBe( "Useful info", ); // The tip types in character by character, so wait for the first word. await waitFor(() => expect( document.querySelector(".df-useful-info-tip")?.textContent, ).toContain("Double-click"), ); }); }); describe("DesignFilesPanel large list", () => { afterEach(() => cleanup()); it("renders all rows at once (no pagination)", () => { const { container } = renderPanel(generateFiles(500)); expect(container.querySelectorAll(".df-file-row").length).toBe(500); }); it("renders 500 files within a reasonable time", () => { const files = generateFiles(500); const start = performance.now(); renderPanel(files); const elapsed = performance.now() - start; expect(elapsed).toBeLessThan(2000); }); }); describe("DesignFilesPanel selection", () => { afterEach(() => cleanup()); it("shows the batch bar and passes every selected file to batch delete", () => { const files = generateFiles(3); const { container, onDeleteFiles } = renderPanel(files); const rows = Array.from(container.querySelectorAll(".df-file-row")); const firstName = rows[0]! .getAttribute("data-testid")! .replace(/^design-file-row-/, ""); const secondName = rows[1]! .getAttribute("data-testid")! .replace(/^design-file-row-/, ""); fireEvent.click(rows[0]!.querySelector(".df-row-check")!); fireEvent.click(rows[1]!.querySelector(".df-row-check")!); expect( container.querySelector('[data-testid="design-files-batch-bar"]'), ).toBeTruthy(); fireEvent.click( container.querySelector('[data-testid="design-files-batch-delete"]')!, ); expect(onDeleteFiles).toHaveBeenCalledTimes(1); expect(onDeleteFiles).toHaveBeenCalledWith([firstName, secondName]); }); it("does not preview or open files from row controls", () => { const files = generateFiles(1); const { container, onOpenFile } = renderPanel(files); const row = container.querySelector(".df-file-row")!; fireEvent.click(row.querySelector(".df-row-check")!); expect( container.querySelector('[data-testid="design-file-preview"]'), ).toBeNull(); expect(onOpenFile).not.toHaveBeenCalled(); fireEvent.click(row.querySelector(".df-row-menu")!); expect( container.querySelector('[data-testid="design-file-preview"]'), ).toBeNull(); expect(onOpenFile).not.toHaveBeenCalled(); }); it("copies the local file path from the row menu", async () => { const originalClipboard = Object.getOwnPropertyDescriptor( navigator, "clipboard", ); const writeText = vi.fn().mockResolvedValue(undefined); Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText }, }); try { const { container } = renderPanel([ file({ name: "alpha.html", localPath: "/tmp/open-design/projects/test-project/alpha.html", }), ]); fireEvent.click(screen.getByTestId("design-file-menu-alpha.html")); fireEvent.click( screen.getByRole("button", { name: "Copy local file path" }), ); await waitFor(() => { expect(writeText).toHaveBeenCalledWith( "/tmp/open-design/projects/test-project/alpha.html", ); }); expect( container.querySelector('[data-testid="design-file-preview"]'), ).toBeNull(); } finally { if (originalClipboard) { Object.defineProperty(navigator, "clipboard", originalClipboard); } else { Reflect.deleteProperty(navigator, "clipboard"); } } }); it("uses non-control row targets to preview and open", () => { const files = generateFiles(1); const { container, onOpenFile } = renderPanel(files); const row = container.querySelector(".df-file-row")!; fireEvent.click(row.querySelector(".df-row-icon")!); expect( container.querySelector('[data-testid="design-file-preview"]') ?.textContent, ).toContain("file-1.html"); fireEvent.doubleClick(row.querySelector(".df-row-name-btn")!); expect(onOpenFile).toHaveBeenCalledWith("file-1.html"); onOpenFile.mockClear(); fireEvent.doubleClick(row.querySelector(".df-row-time")!); expect(onOpenFile).toHaveBeenCalledWith("file-1.html"); }); }); describe("DesignFilesPanel preview", () => { afterEach(() => cleanup()); it("shows the file extension in the preview stats", () => { const { container } = renderPanel([ file({ name: "chart.png", kind: "image", size: 4096 }), ]); fireEvent.click(container.querySelector(".df-file-row .df-row-icon")!); const stats = container.querySelector(".df-preview-stats")?.textContent ?? ""; expect(stats).toContain("PNG"); }); it("does not fetch or iframe large HTML files for the Design Files thumbnail", () => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); const { container } = renderPanel([ file({ name: "large.html", kind: "html", mime: "text/html", size: 2 * 1024 * 1024, }), ]); fireEvent.click(container.querySelector(".df-file-row .df-row-name-btn")!); expect(fetchMock).not.toHaveBeenCalled(); expect(container.querySelector(".df-preview-thumb iframe")).toBeNull(); expect(container.querySelector(".df-preview-placeholder")?.textContent).toContain("⟨⟩"); }); it("builds small HTML thumbnails asynchronously without URL-loading the iframe first", async () => { const fetchMock = vi.fn( async () => new Response("
Small
", { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" }, }), ); vi.stubGlobal("fetch", fetchMock); const { container } = renderPanel([ file({ name: "small.html", kind: "html", mime: "text/html", size: 16 * 1024, mtime: 1700000000000, }), ]); fireEvent.click(container.querySelector(".df-file-row .df-row-name-btn")!); expect(container.querySelector(".df-preview-thumb iframe")).toBeNull(); await waitFor(() => { const iframe = container.querySelector(".df-preview-thumb iframe"); expect(iframe).toBeTruthy(); expect(iframe?.getAttribute("src")).toBeNull(); expect(iframe?.getAttribute("srcdoc") ?? iframe?.srcdoc ?? "").toContain("Small"); }); expect(fetchMock).toHaveBeenCalledWith( "/api/projects/test-project/raw/small.html?v=1700000000000", expect.any(Object), ); }); it("renders sketch files with the static sketch preview instead of a broken image", async () => { const fetchMock = vi.fn( async () => new Response( JSON.stringify({ version: 1, items: [ { kind: "rect", x: 20, y: 16, w: 120, h: 72, color: "#1c1b1a", size: 2, }, ], }), { status: 200, headers: { "Content-Type": "application/json; charset=utf-8" }, }, ), ); vi.stubGlobal("fetch", fetchMock); const sketchFile = file({ name: "board.sketch.json", path: "board.sketch.json", kind: "sketch", mime: "application/json; charset=utf-8", }); const { container } = renderPanel([sketchFile]); fireEvent.click(container.querySelector(".df-file-row .df-row-name-btn")!); await waitFor(() => { expect( container.querySelector('[data-testid="sketch-preview-svg"]'), ).toBeTruthy(); }); expect(container.querySelector(".df-preview-thumb img")).toBeNull(); expect(fetchMock).toHaveBeenCalledWith( "/api/projects/test-project/raw/board.sketch.json", { cache: "no-store" }, ); }); }); describe("DesignFilesPanel directory navigation", () => { afterEach(() => { cleanup(); }); it("collapses nested files into a single folder row at root with correct descendant count", () => { renderPanel([ file({ name: "assets/logo.png", kind: "image" }), file({ name: "assets/icons/star.svg", kind: "image" }), ]); const dirRows = document.querySelectorAll(".df-dir-row"); expect(dirRows.length).toBe(1); expect(dirRows[0]!.textContent).toContain("assets"); expect(dirRows[0]!.textContent).toContain("2"); }); it("pins folders into a Folders section", () => { renderPanel([ file({ name: "assets/logo.png", kind: "image" }), file({ name: "top.html", kind: "html" }), ]); expect(sectionLabels().some((text) => text.includes("Folders"))).toBe(true); }); it("clicking a folder row navigates into it and shows only basenames and nested dirs", () => { renderPanel([ file({ name: "assets/logo.png", kind: "image" }), file({ name: "assets/icons/star.svg", kind: "image" }), ]); fireEvent.click(document.querySelector(".df-dir-row .df-row-name-btn")!); expect(document.querySelector(".df-breadcrumbs")).toBeTruthy(); expect(document.querySelector(".df-breadcrumb-current")?.textContent).toBe( "assets", ); const fileRow = screen.getByTestId("design-file-row-assets/logo.png"); expect(fileRow.querySelector(".df-row-name")?.textContent).toBe("logo.png"); expect(fileRow.querySelector(".df-row-name")?.textContent).not.toContain( "assets/", ); const dirRows = document.querySelectorAll(".df-dir-row"); expect(dirRows.length).toBe(1); expect(dirRows[0]!.textContent).toContain("icons"); }); it("always renders the root breadcrumb on the default-root view", () => { // Regression: managed-storage projects have currentDir==='' and no // rootDirName, which previously collapsed the whole breadcrumb nav to null // and left the toolbar blank on the left for the most common path. The root // crumb must always render, falling back to the t('designFiles.crumbs') // label when no rootDirName exists. renderPanel([file({ name: "top.html", kind: "html" })]); expect(document.querySelector(".df-breadcrumbs")).toBeTruthy(); expect(document.querySelector(".df-breadcrumb-current")?.textContent).toBe( "Project", ); }); it("shows rootDirName as the root breadcrumb when one is provided", () => { renderPanel([file({ name: "top.html", kind: "html" })], { rootDirName: "my-folder", }); expect(document.querySelector(".df-breadcrumb-current")?.textContent).toBe( "my-folder", ); }); it("clicking the root breadcrumb navigates back to root", () => { renderPanel([ file({ name: "assets/logo.png", kind: "image" }), file({ name: "top.html", kind: "html" }), ]); fireEvent.click(document.querySelector(".df-dir-row .df-row-name-btn")!); expect(document.querySelector(".df-breadcrumbs")).toBeTruthy(); fireEvent.click(document.querySelector(".df-breadcrumb-btn")!); expect( document.querySelector(".df-breadcrumb-current")?.textContent, ).not.toBe("assets"); expect(screen.getByTestId("design-file-row-top.html")).toBeTruthy(); expect(document.querySelectorAll(".df-dir-row").length).toBe(1); }); it("includes subdirectory files in the flat root-level list", () => { renderPanel([ file({ name: "assets/logo.png", kind: "image" }), file({ name: "top.html", kind: "html" }), ]); expect(document.querySelectorAll(".df-dir-row").length).toBe(1); expect(screen.getByTestId("design-file-row-assets/logo.png")).toBeTruthy(); expect(screen.getByTestId("design-file-row-top.html")).toBeTruthy(); }); it("preserves the current directory when remounted with navState from a previous render", () => { let saved: DesignFilesNavState | undefined; function makePanel(nav?: DesignFilesNavState) { return ( { saved = state; }} onRefreshFiles={vi.fn()} onOpenFile={vi.fn()} onOpenLiveArtifact={vi.fn()} onRenameFile={vi.fn()} onDeleteFile={vi.fn()} onDeleteFiles={vi.fn()} onUpload={vi.fn()} onUploadFiles={vi.fn()} onPaste={vi.fn()} onNewSketch={vi.fn()} /> ); } const { unmount } = render(makePanel()); fireEvent.click(document.querySelector(".df-dir-row .df-row-name-btn")!); expect(document.querySelector(".df-breadcrumb-current")?.textContent).toBe( "assets", ); unmount(); render(makePanel(saved)); expect(document.querySelector(".df-breadcrumb-current")?.textContent).toBe( "assets", ); expect(screen.getByTestId("design-file-row-assets/logo.png")).toBeTruthy(); }); it("navigates up one level via the parent breadcrumb", () => { renderPanel([file({ name: "assets/icons/star.svg", kind: "image" })]); fireEvent.click(document.querySelector(".df-dir-row .df-row-name-btn")!); fireEvent.click(document.querySelector(".df-dir-row .df-row-name-btn")!); expect(document.querySelector(".df-breadcrumb-current")?.textContent).toBe( "icons", ); const crumbs = Array.from(document.querySelectorAll(".df-breadcrumb-btn")); fireEvent.click(crumbs[crumbs.length - 1]!); expect(document.querySelector(".df-breadcrumb-current")?.textContent).toBe( "assets", ); }); it("clears selection when navigating into or out of a directory", () => { renderPanel([ file({ name: "assets/logo.png", kind: "image" }), file({ name: "top.html", kind: "html" }), ]); const topRow = screen.getByTestId("design-file-row-top.html"); fireEvent.click(topRow.querySelector(".df-row-check")!); expect(topRow.classList.contains("selected")).toBe(true); fireEvent.click(document.querySelector(".df-dir-row .df-row-name-btn")!); expect(document.querySelectorAll(".df-file-row.selected").length).toBe(0); fireEvent.click(document.querySelector(".df-breadcrumb-btn")!); expect(document.querySelectorAll(".df-file-row.selected").length).toBe(0); }); it("resets currentDir automatically when all files in the current subdirectory are removed", () => { function makePanel(files: ProjectFile[]) { return ( ); } const { rerender } = render( makePanel([ file({ name: "assets/logo.png", kind: "image" }), file({ name: "top.html", kind: "html" }), ]), ); fireEvent.click(document.querySelector(".df-dir-row .df-row-name-btn")!); expect(document.querySelector(".df-breadcrumb-current")?.textContent).toBe( "assets", ); rerender(makePanel([file({ name: "top.html", kind: "html" })])); expect( document.querySelector(".df-breadcrumb-current")?.textContent, ).not.toBe("assets"); expect(screen.getByTestId("design-file-row-top.html")).toBeTruthy(); }); }); describe("DesignFilesPanel current-directory sync", () => { afterEach(() => cleanup()); it("reports the active folder so new files are created under it, not the root", () => { const onCurrentDirChange = vi.fn(); renderPanel( [ file({ name: "top.html", kind: "html" }), file({ name: "assets/logo.png", kind: "image" }), ], { onCurrentDirChange }, ); // Mounts at the root. expect(onCurrentDirChange).toHaveBeenLastCalledWith(""); // Navigate into the folder — the parent must learn the new target dir, or // upload / paste / new-sketch would create at the project root (#3358 regression). fireEvent.click(document.querySelector(".df-dir-row .df-row-name-btn")!); expect(onCurrentDirChange).toHaveBeenLastCalledWith("assets"); }); }); describe("DesignFilesPanel persisted (empty) folders", () => { afterEach(() => cleanup()); it("shows an empty persisted folder that has no files under it", () => { // Only a root file + an empty persisted folder; the folder must still // appear (it would vanish if we derived dirs from file paths alone). renderPanel([file({ name: "top.html", kind: "html" })], { folders: [folder("assets")], }); const dirRows = [...document.querySelectorAll(".df-dir-row")]; expect(dirRows.some((r) => r.textContent?.includes("assets"))).toBe(true); }); it("surfaces a nested empty persisted folder after navigating into its parent", () => { renderPanel([], { folders: [folder("assets"), folder("assets/icons")] }); // Zero files, but the persisted folder still renders the tree (not the // empty state), so 'assets' is navigable at the root. const rootDirs = [ ...document.querySelectorAll(".df-dir-row .df-row-name"), ].map((e) => e.textContent); expect(rootDirs).toContain("assets"); fireEvent.click(document.querySelector(".df-dir-row .df-row-name-btn")!); const nestedDirs = [ ...document.querySelectorAll(".df-dir-row .df-row-name"), ].map((e) => e.textContent); expect(nestedDirs).toContain("icons"); }); });