Files
wehub-resource-sync 070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:00:47 +08:00

761 lines
25 KiB
TypeScript

// @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<string, string>();
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<ProjectFile> & Pick<ProjectFile, "name">,
): 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<ComponentProps<typeof DesignFilesPanel>> = {},
) {
const onOpenFile = vi.fn();
const onDeleteFiles = vi.fn();
const onClearUploadError = vi.fn();
const result = render(
<DesignFilesPanel
projectId="test-project"
files={files}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
onOpenFile={onOpenFile}
onOpenLiveArtifact={vi.fn()}
onRenameFile={vi.fn()}
onDeleteFile={vi.fn()}
onDeleteFiles={onDeleteFiles}
onUpload={vi.fn()}
onUploadFiles={vi.fn()}
onPaste={vi.fn()}
onNewSketch={vi.fn()}
onClearUploadError={onClearUploadError}
{...overrides}
/>,
);
return { ...result, onDeleteFiles, onOpenFile, onClearUploadError };
}
function sectionLabels(): string[] {
return Array.from(
document.querySelectorAll<HTMLElement>(".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("<!doctype html><html><body><main>Small</main></body></html>", {
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<HTMLIFrameElement>(".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 (
<DesignFilesPanel
projectId="test-project"
files={[
file({ name: "assets/logo.png", kind: "image" }),
file({ name: "top.html", kind: "html" }),
]}
liveArtifacts={[]}
navState={nav}
onNavStateChange={(state) => {
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 (
<DesignFilesPanel
projectId="test-project"
files={files}
liveArtifacts={[]}
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 { 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");
});
});