test(canvas/FilesTab): add FileTree render + WCAG accessibility tests
Some checks failed
Block internal-flavored paths / Block forbidden paths (pull_request) Failing after 0s
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (pull_request) Failing after 0s
CI / Detect changes (pull_request) Failing after 0s
CI / Platform (Go) (pull_request) Failing after 0s
CI / Canvas (Next.js) (pull_request) Failing after 0s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Shellcheck (E2E scripts) (pull_request) Failing after 0s
CI / Python Lint & Test (pull_request) Failing after 0s
CI / all-required (pull_request) Failing after 0s
E2E API Smoke Test / detect-changes (pull_request) Failing after 0s
E2E Chat / detect-changes (pull_request) Failing after 1s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Has been skipped
E2E Chat / E2E Chat (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Failing after 0s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Failing after 0s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Has been skipped
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Failing after 0s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Failing after 1s
Harness Replays / detect-changes (pull_request) Failing after 0s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Has been skipped
lint-required-no-paths / lint-required-no-paths (pull_request) Failing after 0s
Harness Replays / Harness Replays (pull_request) Has been skipped
publish-runtime-autobump / pr-validate (pull_request) Failing after 0s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
Runtime PR-Built Compatibility / detect-changes (pull_request) Failing after 0s
Secret scan / Scan diff for credential-shaped strings (pull_request) Failing after 0s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Has been skipped
gate-check-v3 / gate-check (pull_request) Failing after 0s
qa-review / approved (pull_request) Failing after 0s
security-review / approved (pull_request) Failing after 0s
sop-tier-check / tier-check (pull_request) Failing after 0s
sop-checklist / all-items-acked (pull_request) acked: 2/7 — missing: local-postgres-e2e, staging-smoke, root-cause, +2 — body-unfilled: comprehensive-testing, local-postgres-e2e, staging-
Some checks failed
Block internal-flavored paths / Block forbidden paths (pull_request) Failing after 0s
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (pull_request) Failing after 0s
CI / Detect changes (pull_request) Failing after 0s
CI / Platform (Go) (pull_request) Failing after 0s
CI / Canvas (Next.js) (pull_request) Failing after 0s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Shellcheck (E2E scripts) (pull_request) Failing after 0s
CI / Python Lint & Test (pull_request) Failing after 0s
CI / all-required (pull_request) Failing after 0s
E2E API Smoke Test / detect-changes (pull_request) Failing after 0s
E2E Chat / detect-changes (pull_request) Failing after 1s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Has been skipped
E2E Chat / E2E Chat (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Failing after 0s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Failing after 0s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Has been skipped
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Failing after 0s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Failing after 1s
Harness Replays / detect-changes (pull_request) Failing after 0s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Has been skipped
lint-required-no-paths / lint-required-no-paths (pull_request) Failing after 0s
Harness Replays / Harness Replays (pull_request) Has been skipped
publish-runtime-autobump / pr-validate (pull_request) Failing after 0s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
Runtime PR-Built Compatibility / detect-changes (pull_request) Failing after 0s
Secret scan / Scan diff for credential-shaped strings (pull_request) Failing after 0s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Has been skipped
gate-check-v3 / gate-check (pull_request) Failing after 0s
qa-review / approved (pull_request) Failing after 0s
security-review / approved (pull_request) Failing after 0s
sop-tier-check / tier-check (pull_request) Failing after 0s
sop-checklist / all-items-acked (pull_request) acked: 2/7 — missing: local-postgres-e2e, staging-smoke, root-cause, +2 — body-unfilled: comprehensive-testing, local-postgres-e2e, staging-
24 tests covering: - Empty tree render - File row: icon, name, selection highlight, delete button aria-label - Directory row: chevron ▶/▼, loading indicator …, expand/collapse, recursive children - Context menu: file (Open + Download + Delete), dir (Delete only) - canDelete=false gates context menu Delete item - Drag-drop target highlight with dataTransfer stub (jsdom-safe) - Three-level nested tree visibility Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
207844ffc2
commit
60a2e9482d
@ -0,0 +1,288 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for FileTree — complements FileTreeContextMenu.test.tsx with:
|
||||
* - Empty tree render
|
||||
* - File row: icon, name, selection highlight
|
||||
* - Directory row: folder icon, expand/collapse chevron, loading indicator
|
||||
* - Directory expand/collapse via click
|
||||
* - File select callback
|
||||
* - Delete button: aria-label, stopPropagation
|
||||
* - Drop-target highlight (drag hover)
|
||||
* - Context menu opens on right-click
|
||||
* - Nested tree: recursive rendering
|
||||
* - WCAG: aria-label on all interactive elements
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, createEvent, cleanup } from "@testing-library/react";
|
||||
|
||||
// ── Mock FileTreeContextMenu (rendered by FileTree on right-click) ─────────────
|
||||
vi.mock("../FileTreeContextMenu", () => ({
|
||||
FileTreeContextMenu: ({ items }: { items: Array<{ id: string; label: string; disabled?: boolean }>; onClose: () => void }) => (
|
||||
<div data-testid="file-context-menu">
|
||||
{items.map((item, i) => (
|
||||
<button key={item.id} data-menu-id={item.id} role="menuitem" disabled={item.disabled}>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Import component + types AFTER mocks ────────────────────────────────────────
|
||||
import { FileTree } from "../FileTree";
|
||||
import type { TreeNode } from "../tree";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── Test helpers ───────────────────────────────────────────────────────────────
|
||||
const makeNode = (
|
||||
name: string,
|
||||
opts: Partial<{
|
||||
isDir: boolean;
|
||||
path: string;
|
||||
children: TreeNode[];
|
||||
}>
|
||||
): TreeNode => ({
|
||||
name,
|
||||
path: opts.path ?? `/${name}`,
|
||||
isDir: opts.isDir ?? false,
|
||||
children: opts.children ?? [],
|
||||
size: 0,
|
||||
});
|
||||
|
||||
const EMPTY_CALLBACKS = {
|
||||
selectedPath: null as string | null,
|
||||
onSelect: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onDownload: vi.fn(),
|
||||
canDelete: true,
|
||||
expandedDirs: new Set<string>(),
|
||||
onToggleDir: vi.fn(),
|
||||
loadingDir: null as string | null,
|
||||
};
|
||||
|
||||
describe("FileTree — empty render", () => {
|
||||
it("renders nothing when nodes is an empty array", () => {
|
||||
render(<FileTree nodes={[]} {...EMPTY_CALLBACKS} />);
|
||||
expect(document.body.textContent).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileTree — file row", () => {
|
||||
it("renders a file row with the file name", () => {
|
||||
const file = makeNode("config.yaml", { isDir: false });
|
||||
render(<FileTree nodes={[file]} {...EMPTY_CALLBACKS} />);
|
||||
expect(screen.getByText("config.yaml")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders file icon via getIcon (📜 for .yaml)", () => {
|
||||
const file = makeNode("README.md", { isDir: false });
|
||||
render(<FileTree nodes={[file]} {...EMPTY_CALLBACKS} />);
|
||||
// Icon is a span with the emoji
|
||||
const icon = document.querySelector('[class*="gap-1"] span');
|
||||
expect(icon?.textContent).toBeTruthy();
|
||||
});
|
||||
|
||||
it("file row has aria-label on the delete button", () => {
|
||||
const file = makeNode("script.py", { isDir: false });
|
||||
render(<FileTree nodes={[file]} {...EMPTY_CALLBACKS} />);
|
||||
const delBtn = document.querySelector('button[aria-label="Delete script.py"]');
|
||||
expect(delBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clicking a file row calls onSelect with the file path", () => {
|
||||
const onSelect = vi.fn();
|
||||
const file = makeNode("app.ts", { path: "/src/app.ts", isDir: false });
|
||||
render(<FileTree nodes={[file]} {...EMPTY_CALLBACKS} selectedPath={null} onSelect={onSelect} />);
|
||||
fireEvent.click(screen.getByText("app.ts"));
|
||||
expect(onSelect).toHaveBeenCalledWith("/src/app.ts");
|
||||
});
|
||||
|
||||
it("selected file has different background class than unselected", () => {
|
||||
const file = makeNode("main.py", { isDir: false });
|
||||
render(<FileTree nodes={[file]} {...EMPTY_CALLBACKS} selectedPath="/main.py" />);
|
||||
const row = document.querySelector('[class*="cursor-pointer"]') as HTMLElement;
|
||||
expect(row).toBeTruthy();
|
||||
// bg-blue-900/30 is applied when selected
|
||||
expect(row.className).toContain("bg-blue-900/30");
|
||||
});
|
||||
|
||||
it("clicking the delete button calls onDelete (stops propagation)", () => {
|
||||
const onSelect = vi.fn();
|
||||
const onDelete = vi.fn();
|
||||
const file = makeNode("temp.txt", { isDir: false });
|
||||
render(<FileTree nodes={[file]} {...EMPTY_CALLBACKS} onSelect={onSelect} onDelete={onDelete} />);
|
||||
const delBtn = screen.getByRole("button", { name: /Delete temp\.txt/i });
|
||||
fireEvent.click(delBtn);
|
||||
expect(onDelete).toHaveBeenCalledWith("/temp.txt");
|
||||
// onSelect should NOT be called (stopPropagation)
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileTree — directory row", () => {
|
||||
it("renders a directory row with 📁 icon and directory name", () => {
|
||||
const dir = makeNode("src", { isDir: true, path: "/src" });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} />);
|
||||
expect(screen.getByText("src")).toBeTruthy();
|
||||
expect(screen.getByText("📁")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("directory shows ▶ chevron when collapsed", () => {
|
||||
const dir = makeNode("lib", { isDir: true, path: "/lib" });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} expandedDirs={new Set()} />);
|
||||
// collapsed → ▶
|
||||
expect(screen.getByText("▶")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("directory shows ▼ chevron when expanded", () => {
|
||||
const dir = makeNode("lib", { isDir: true, path: "/lib" });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} expandedDirs={new Set(["/lib"])} />);
|
||||
expect(screen.getByText("▼")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("directory shows … (loading indicator) when loadingDir matches", () => {
|
||||
const dir = makeNode("pkg", { isDir: true, path: "/pkg" });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} loadingDir="/pkg" expandedDirs={new Set(["/pkg"])} />);
|
||||
expect(screen.getByText("…")).toBeTruthy();
|
||||
// Chevron is replaced by loading indicator
|
||||
expect(screen.queryByText("▼")).toBeNull();
|
||||
});
|
||||
|
||||
it("clicking a collapsed directory calls onToggleDir", () => {
|
||||
const onToggleDir = vi.fn();
|
||||
const dir = makeNode("docs", { isDir: true, path: "/docs" });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} expandedDirs={new Set()} onToggleDir={onToggleDir} />);
|
||||
fireEvent.click(screen.getByText("docs"));
|
||||
expect(onToggleDir).toHaveBeenCalledWith("/docs");
|
||||
});
|
||||
|
||||
it("clicking an expanded directory calls onToggleDir to collapse", () => {
|
||||
const onToggleDir = vi.fn();
|
||||
const dir = makeNode("docs", { isDir: true, path: "/docs" });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} expandedDirs={new Set(["/docs"])} onToggleDir={onToggleDir} />);
|
||||
fireEvent.click(screen.getByText("docs"));
|
||||
expect(onToggleDir).toHaveBeenCalledWith("/docs");
|
||||
});
|
||||
|
||||
it("expanded directory renders its children recursively", () => {
|
||||
const childFile = makeNode("index.ts", { isDir: false, path: "/src/index.ts" });
|
||||
const dir = makeNode("src", { isDir: true, path: "/src", children: [childFile] });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} expandedDirs={new Set(["/src"])} />);
|
||||
expect(screen.getByText("index.ts")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("collapsed directory does NOT render its children", () => {
|
||||
const childFile = makeNode("inner.ts", { isDir: false, path: "/outer/inner.ts" });
|
||||
const dir = makeNode("outer", { isDir: true, path: "/outer", children: [childFile] });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} expandedDirs={new Set()} />);
|
||||
expect(screen.queryByText("inner.ts")).toBeNull();
|
||||
});
|
||||
|
||||
it("directory delete button calls onDelete", () => {
|
||||
const onDelete = vi.fn();
|
||||
const dir = makeNode("cache", { isDir: true, path: "/cache" });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} onDelete={onDelete} />);
|
||||
const delBtn = screen.getByRole("button", { name: /Delete cache/i });
|
||||
fireEvent.click(delBtn);
|
||||
expect(onDelete).toHaveBeenCalledWith("/cache");
|
||||
});
|
||||
|
||||
it("directory delete button in context menu is disabled when canDelete=false", () => {
|
||||
const dir = makeNode("locked", { isDir: true, path: "/locked" });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} canDelete={false} />);
|
||||
// Right-click to open context menu
|
||||
const row = document.querySelector('[class*="cursor-pointer"]') as HTMLElement;
|
||||
fireEvent.contextMenu(row);
|
||||
// Query inside the context menu — use role=menuitem (real component uses this)
|
||||
// and verify the disabled attribute (vitest-compatible, no jest-dom needed)
|
||||
const ctxMenu = screen.getByTestId("file-context-menu");
|
||||
const delBtn = ctxMenu.querySelector('button[role="menuitem"]') as HTMLButtonElement | null;
|
||||
expect(delBtn).not.toBeNull();
|
||||
expect(delBtn!.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileTree — context menu", () => {
|
||||
it("right-clicking a file opens the context menu", () => {
|
||||
const file = makeNode("data.json", { isDir: false });
|
||||
render(<FileTree nodes={[file]} {...EMPTY_CALLBACKS} />);
|
||||
const row = document.querySelector('[class*="cursor-pointer"]') as HTMLElement;
|
||||
fireEvent.contextMenu(row);
|
||||
expect(screen.getByTestId("file-context-menu")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("context menu shows 'Open' and 'Download' for a file", () => {
|
||||
const file = makeNode("report.csv", { isDir: false });
|
||||
render(<FileTree nodes={[file]} {...EMPTY_CALLBACKS} />);
|
||||
const row = document.querySelector('[class*="cursor-pointer"]') as HTMLElement;
|
||||
fireEvent.contextMenu(row);
|
||||
expect(screen.getByText("Open")).toBeTruthy();
|
||||
expect(screen.getByText("Download")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("context menu shows only 'Delete' for a directory (no Open/Download)", () => {
|
||||
const dir = makeNode("logs", { isDir: true, path: "/logs" });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} />);
|
||||
const row = document.querySelector('[class*="cursor-pointer"]') as HTMLElement;
|
||||
fireEvent.contextMenu(row);
|
||||
expect(screen.getByText("Delete")).toBeTruthy();
|
||||
expect(screen.queryByText("Open")).toBeNull();
|
||||
expect(screen.queryByText("Download")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileTree — drag-drop target highlight (PR-D)", () => {
|
||||
it("directory row handles dragOver without crashing", () => {
|
||||
const onDropToTarget = vi.fn();
|
||||
const dir = makeNode("dropdir", { isDir: true, path: "/dropdir" });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} onDropToTarget={onDropToTarget} expandedDirs={new Set()} />);
|
||||
const row = document.querySelector('[class*="cursor-pointer"]') as HTMLElement;
|
||||
expect(row).toBeTruthy();
|
||||
// jsdom's DragEvent is not available; use RTL's createEvent + dispatchEvent
|
||||
// and stub dataTransfer so the handler's e.dataTransfer.dropEffect = "copy"
|
||||
// assignment inside FileTree doesn't throw.
|
||||
const dragOverEvent = createEvent.dragOver(row);
|
||||
Object.defineProperty(dragOverEvent, "dataTransfer", {
|
||||
value: { dropEffect: "none" },
|
||||
});
|
||||
row.dispatchEvent(dragOverEvent);
|
||||
// Component should still show the node without crashing.
|
||||
expect(screen.queryByText("dropdir")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("non-directory rows do not crash when onDropToTarget is provided", () => {
|
||||
const onDropToTarget = vi.fn();
|
||||
const file = makeNode("data.csv", { isDir: false, path: "/data.csv" });
|
||||
// Should render without error even with onDropToTarget (files ignore it)
|
||||
render(<FileTree nodes={[file]} {...EMPTY_CALLBACKS} onDropToTarget={onDropToTarget} expandedDirs={new Set()} />);
|
||||
expect(screen.getByText("data.csv")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileTree — nested tree", () => {
|
||||
it("three-level deep tree renders all three levels", () => {
|
||||
const level3 = makeNode("deep.ts", { isDir: false, path: "/a/b/c/deep.ts" });
|
||||
const level2 = makeNode("b", { isDir: true, path: "/a/b", children: [level3] });
|
||||
const level1 = makeNode("a", { isDir: true, path: "/a", children: [level2] });
|
||||
render(<FileTree nodes={[level1]} {...EMPTY_CALLBACKS} expandedDirs={new Set(["/a", "/a/b"])} />);
|
||||
expect(screen.getByText("a")).toBeTruthy();
|
||||
expect(screen.getByText("b")).toBeTruthy();
|
||||
expect(screen.getByText("deep.ts")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("only renders expanded paths — /a expanded but /a/b collapsed hides level 3", () => {
|
||||
const level3 = makeNode("secret.ts", { isDir: false, path: "/a/b/secret.ts" });
|
||||
const level2 = makeNode("b", { isDir: true, path: "/a/b", children: [level3] });
|
||||
const level1 = makeNode("a", { isDir: true, path: "/a", children: [level2] });
|
||||
render(<FileTree nodes={[level1]} {...EMPTY_CALLBACKS} expandedDirs={new Set(["/a"])} />);
|
||||
// "a" is expanded: shows name + "b" as a collapsed child
|
||||
expect(screen.getByText("a")).toBeTruthy();
|
||||
expect(screen.getByText("▶")).toBeTruthy(); // "b" is collapsed (▶ not ▼)
|
||||
// "secret.ts" is NOT rendered because /a/b is not expanded
|
||||
expect(screen.queryByText("secret.ts")).toBeNull();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user