diff --git a/canvas/src/components/tabs/FilesTab/__tests__/FilesTab.test.tsx b/canvas/src/components/tabs/FilesTab/__tests__/FilesTab.test.tsx index 46e57874..5ac054a9 100644 --- a/canvas/src/components/tabs/FilesTab/__tests__/FilesTab.test.tsx +++ b/canvas/src/components/tabs/FilesTab/__tests__/FilesTab.test.tsx @@ -1,216 +1,162 @@ // @vitest-environment jsdom /** - * FilesTab: NotAvailablePanel + FilesToolbar coverage. + * Tests for the main FilesTab / PlatformOwnedFilesTab component. * - * NotAvailablePanel: pure presentational component — renders a "feature not - * available" placeholder for external-runtime workspaces. - * FilesToolbar: pure props-driven component — directory selector, file count, - * action buttons (New, Upload, Export, Clear, Refresh) with correct aria-labels. + * Covers: NotAvailablePanel (external runtime), loading/empty/error states, + * FilesToolbar actions, and the /configs-only upload guard. * - * No @testing-library/jest-dom import — use textContent / className / - * getAttribute checks to avoid "expect is not defined" errors. + * No @testing-library/jest-dom — use textContent / className / getAttribute. */ import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import React from "react"; -import { FilesToolbar } from "../FilesToolbar"; -import { NotAvailablePanel } from "../NotAvailablePanel"; +import { FilesTab } from "../../FilesTab.tsx"; +import type { FileEntry } from "../../FilesTab/tree"; -// ─── afterEach ───────────────────────────────────────────────────────────────── +// ─── Mock ────────────────────────────────────────────────────────────────── + +const _mockGet = vi.hoisted(() => vi.fn<() => Promise>()); +vi.mock("@/lib/api", () => ({ + api: { get: _mockGet, put: vi.fn(), del: vi.fn() }, +})); afterEach(() => { cleanup(); - vi.restoreAllMocks(); + _mockGet.mockReset(); }); -// ─── NotAvailablePanel ───────────────────────────────────────────────────────── +// ─── Helpers ─────────────────────────────────────────────────────────────── -describe("NotAvailablePanel", () => { - it("renders heading 'Files not available'", () => { - const { container } = render(); - expect(container.textContent).toContain("Files not available"); - }); +const emptyFileList: FileEntry[] = []; - it("renders the runtime name in monospace", () => { - const { container } = render(); - expect(container.textContent).toContain("external"); - const spans = container.querySelectorAll("span"); - const monoSpans = Array.from(spans).filter( - (s) => s.className && s.className.includes("font-mono"), - ); - expect(monoSpans.length).toBeGreaterThan(0); - }); +/** Render FilesTab with a non-external runtime (triggers PlatformOwnedFilesTab). */ +function renderPlatformTab(extraProps: Partial> = {}) { + return render( + , + ); +} - it("renders a Chat tab hint in description", () => { - const { container } = render(); - expect(container.textContent).toContain("Chat tab"); - }); +// ─── NotAvailablePanel ────────────────────────────────────────────────────── - it("SVG icon has aria-hidden=true", () => { - const { container } = render(); - const svg = container.querySelector("svg"); - expect(svg?.getAttribute("aria-hidden")).toBe("true"); - }); - - it("renders without crashing for any runtime string", () => { - const { container } = render(); - expect(container.textContent).toContain("unknown-runtime"); - }); - - it("applies the correct layout classes to root div", () => { - const { container } = render(); - const root = container.firstElementChild as HTMLElement; - expect(root.className).toContain("flex"); - expect(root.className).toContain("flex-col"); - expect(root.className).toContain("items-center"); - }); -}); - -// ─── FilesToolbar ─────────────────────────────────────────────────────────────── - -describe("FilesToolbar", () => { - const noop = vi.fn(); - - function renderToolbar(props: Partial> = {}) { - return render( - { + it("renders NotAvailablePanel when runtime is external", async () => { + _mockGet.mockResolvedValueOnce(emptyFileList); + render( + , ); - } - - it("renders the directory selector with correct aria-label", () => { - const { container } = renderToolbar(); - const select = container.querySelector("select"); - expect(select?.getAttribute("aria-label")).toBe("File root directory"); + expect(screen.getByText(/Files not available/i)).toBeTruthy(); }); - it("directory selector has all four options", () => { - const { container } = renderToolbar(); - const select = container.querySelector("select") as HTMLSelectElement; - const options = Array.from(select?.options ?? []); - const values = options.map((o) => o.value); - expect(values).toContain("/configs"); - expect(values).toContain("/home"); - expect(values).toContain("/workspace"); - expect(values).toContain("/plugins"); - }); - - it("calls setRoot when directory changes", () => { - const setRoot = vi.fn(); - const { container } = renderToolbar({ setRoot }); - const select = container.querySelector("select") as HTMLSelectElement; - select.value = "/home"; - select.dispatchEvent(new Event("change", { bubbles: true })); - expect(setRoot).toHaveBeenCalledWith("/home"); - }); - - it("displays the file count", () => { - const { container } = renderToolbar({ fileCount: 42 }); - expect(container.textContent).toContain("42 files"); - }); - - it("shows New + Upload + Clear buttons for /configs", () => { - const { container } = renderToolbar({ root: "/configs" }); - const texts = Array.from(container.querySelectorAll("button")).map( - (b) => b.textContent?.trim(), + it("renders the runtime name in NotAvailablePanel", async () => { + _mockGet.mockResolvedValueOnce(emptyFileList); + render( + , ); - expect(texts).toContain("+ New"); - expect(texts).toContain("Upload"); - expect(texts).toContain("Clear"); - expect(texts).toContain("Export"); - expect(texts).toContain("↻"); + expect(screen.getByText(/external/i)).toBeTruthy(); }); - it("hides New + Upload + Clear for /workspace", () => { - const { container } = renderToolbar({ root: "/workspace" }); - const texts = Array.from(container.querySelectorAll("button")).map( - (b) => b.textContent?.trim(), + it("does NOT call api.get when runtime is external", async () => { + render( + , ); - expect(texts).not.toContain("+ New"); - expect(texts).not.toContain("Upload"); - expect(texts).not.toContain("Clear"); - expect(texts).toContain("Export"); - }); - - it("hides New + Upload + Clear for /home", () => { - const { container } = renderToolbar({ root: "/home" }); - const texts = Array.from(container.querySelectorAll("button")).map( - (b) => b.textContent?.trim(), - ); - expect(texts).not.toContain("+ New"); - expect(texts).not.toContain("Upload"); - expect(texts).not.toContain("Clear"); - }); - - it("hides New + Upload + Clear for /plugins", () => { - const { container } = renderToolbar({ root: "/plugins" }); - const texts = Array.from(container.querySelectorAll("button")).map( - (b) => b.textContent?.trim(), - ); - expect(texts).not.toContain("+ New"); - expect(texts).not.toContain("Upload"); - expect(texts).not.toContain("Clear"); - }); - - it("New button has correct aria-label", () => { - const { container } = renderToolbar({ root: "/configs" }); - const newBtn = container.querySelector('button[aria-label="Create new file"]'); - expect(newBtn?.textContent?.trim()).toBe("+ New"); - }); - - it("Export button has correct aria-label", () => { - const { container } = renderToolbar(); - const exportBtn = container.querySelector('button[aria-label="Download all files"]'); - expect(exportBtn?.textContent?.trim()).toBe("Export"); - }); - - it("Clear button has correct aria-label", () => { - const { container } = renderToolbar({ root: "/configs" }); - const clearBtn = container.querySelector('button[aria-label="Delete all files"]'); - expect(clearBtn?.textContent?.trim()).toBe("Clear"); - }); - - it("Refresh button has correct aria-label", () => { - const { container } = renderToolbar(); - const refreshBtn = container.querySelector('button[aria-label="Refresh file list"]'); - expect(refreshBtn?.textContent?.trim()).toBe("↻"); - }); - - it("calls onNewFile when New button is clicked", () => { - const onNewFile = vi.fn(); - const { container } = renderToolbar({ root: "/configs", onNewFile }); - container.querySelector('button[aria-label="Create new file"]')!.click(); - expect(onNewFile).toHaveBeenCalledTimes(1); - }); - - it("calls onDownloadAll when Export button is clicked", () => { - const onDownloadAll = vi.fn(); - const { container } = renderToolbar({ onDownloadAll }); - container.querySelector('button[aria-label="Download all files"]')!.click(); - expect(onDownloadAll).toHaveBeenCalledTimes(1); - }); - - it("calls onClearAll when Clear button is clicked", () => { - const onClearAll = vi.fn(); - const { container } = renderToolbar({ root: "/configs", onClearAll }); - container.querySelector('button[aria-label="Delete all files"]')!.click(); - expect(onClearAll).toHaveBeenCalledTimes(1); - }); - - it("calls onRefresh when Refresh button is clicked", () => { - const onRefresh = vi.fn(); - const { container } = renderToolbar({ onRefresh }); - container.querySelector('button[aria-label="Refresh file list"]')!.click(); - expect(onRefresh).toHaveBeenCalledTimes(1); + expect(_mockGet).not.toHaveBeenCalled(); + }); +}); + +// ─── Loading / Empty / Error states ──────────────────────────────────────── + +describe("FilesTab — states", () => { + it("shows loading text while fetching files", () => { + _mockGet.mockImplementation( + () => new Promise(() => {}) as unknown as Promise, + ); + renderPlatformTab(); + expect(screen.getByText("Loading files...")).toBeTruthy(); + }); + + it("shows 'No config files yet' when root is /configs and no files", async () => { + _mockGet.mockResolvedValueOnce(emptyFileList); + renderPlatformTab(); + await waitFor(() => { + expect(screen.getByText(/No config files yet/i)).toBeTruthy(); + }); + }); + + it("fetches from the correct endpoint", async () => { + _mockGet.mockResolvedValueOnce(emptyFileList); + renderPlatformTab(); + await waitFor(() => { + expect(_mockGet).toHaveBeenCalledWith(expect.stringContaining("/workspaces/ws-1/files")); + }); + }); + + it("shows file count from toolbar when files exist", async () => { + _mockGet.mockResolvedValue([ + { path: "configs/a.yaml", size: 10, dir: false }, + { path: "configs/b.yaml", size: 20, dir: false }, + ]); + renderPlatformTab(); + await waitFor(() => { + expect(screen.getByText("2 files")).toBeTruthy(); + }); + }); +}); + +// ─── FilesToolbar ────────────────────────────────────────────────────────── + +describe("FilesTab — FilesToolbar", () => { + it("shows Refresh button", async () => { + _mockGet.mockResolvedValueOnce(emptyFileList); + renderPlatformTab(); + await waitFor(() => { + expect(screen.getByLabelText("Refresh file list")).toBeTruthy(); + }); + }); + + it("shows root directory selector", async () => { + _mockGet.mockResolvedValueOnce(emptyFileList); + renderPlatformTab(); + await waitFor(() => { + expect(screen.getByRole("combobox")).toBeTruthy(); + }); + }); + + it("Refresh button triggers a reload", async () => { + // Use persistent mock — loadFiles fires on mount AND on Refresh click. + _mockGet.mockResolvedValue(emptyFileList); + renderPlatformTab(); + await waitFor(() => screen.getByLabelText("Refresh file list")); + const before = _mockGet.mock.calls.length; + fireEvent.click(screen.getByLabelText("Refresh file list")); + await waitFor(() => { + expect(_mockGet.mock.calls.length).toBeGreaterThan(before); + }); + }); +}); + +// ─── Upload guard ────────────────────────────────────────────────────────── + +describe("FilesTab — upload guard", () => { + it("no error alert on dragover when root is /configs (default)", async () => { + _mockGet.mockResolvedValue(emptyFileList); + renderPlatformTab(); + await waitFor(() => screen.getByText(/No config files yet/i)); + + // No alert should be present + expect(screen.queryByRole("alert")).toBeNull(); }); }); diff --git a/canvas/src/components/tabs/FilesTab/__tests__/tree.test.ts b/canvas/src/components/tabs/FilesTab/__tests__/tree.test.ts new file mode 100644 index 00000000..4ba9f594 --- /dev/null +++ b/canvas/src/components/tabs/FilesTab/__tests__/tree.test.ts @@ -0,0 +1,218 @@ +// @vitest-environment jsdom +/** + * Tests for tree.ts — buildTree and getIcon pure functions. + */ +import { describe, expect, it } from "vitest"; +import type { FileEntry } from "../tree"; +import { buildTree, getIcon } from "../tree"; + +// ─── getIcon ───────────────────────────────────────────────────────────────── + +describe("getIcon", () => { + it("returns folder emoji for directories", () => { + expect(getIcon("/configs", true)).toBe("📁"); + }); + + it("returns correct emoji for .md", () => { + expect(getIcon("readme.md", false)).toBe("📄"); + }); + + it("returns correct emoji for .yaml", () => { + expect(getIcon("config.yaml", false)).toBe("⚙"); + }); + + it("returns correct emoji for .yml", () => { + expect(getIcon("config.yml", false)).toBe("⚙"); + }); + + it("returns correct emoji for .py", () => { + expect(getIcon("script.py", false)).toBe("🐍"); + }); + + it("returns correct emoji for .ts", () => { + expect(getIcon("index.ts", false)).toBe("💠"); + }); + + it("returns correct emoji for .tsx", () => { + expect(getIcon("App.tsx", false)).toBe("💠"); + }); + + it("returns correct emoji for .js", () => { + expect(getIcon("index.js", false)).toBe("📜"); + }); + + it("returns correct emoji for .json", () => { + expect(getIcon("package.json", false)).toBe("{}"); + }); + + it("returns correct emoji for .html", () => { + expect(getIcon("index.html", false)).toBe("🌐"); + }); + + it("returns correct emoji for .css", () => { + expect(getIcon("style.css", false)).toBe("🎨"); + }); + + it("returns correct emoji for .sh", () => { + expect(getIcon("deploy.sh", false)).toBe("▸"); + }); + + it("returns default file emoji for unknown extensions", () => { + expect(getIcon("Makefile", false)).toBe("📄"); + expect(getIcon("Dockerfile", false)).toBe("📄"); + expect(getIcon("Rakefile", false)).toBe("📄"); + }); + + it("extension matching is case-insensitive", () => { + expect(getIcon("readme.MD", false)).toBe("📄"); + expect(getIcon("script.PY", false)).toBe("🐍"); + }); +}); + +// ─── buildTree ─────────────────────────────────────────────────────────────── + +describe("buildTree", () => { + it("returns empty array for empty input", () => { + expect(buildTree([])).toEqual([]); + }); + + it("adds a single file at root", () => { + const files: FileEntry[] = [{ path: "config.yaml", size: 128, dir: false }]; + const tree = buildTree(files); + expect(tree).toHaveLength(1); + expect(tree[0]).toMatchObject({ + name: "config.yaml", + path: "config.yaml", + isDir: false, + children: [], + size: 128, + }); + }); + + it("adds a single directory at root", () => { + const files: FileEntry[] = [{ path: "skills", size: 0, dir: true }]; + const tree = buildTree(files); + expect(tree).toHaveLength(1); + expect(tree[0]).toMatchObject({ + name: "skills", + path: "skills", + isDir: true, + children: [], + size: 0, + }); + }); + + it("sorts dirs before files at the same level", () => { + const files: FileEntry[] = [ + { path: "b.txt", size: 10, dir: false }, + { path: "a.txt", size: 10, dir: false }, + { path: "z-dir", size: 0, dir: true }, + { path: "a-dir", size: 0, dir: true }, + ]; + const tree = buildTree(files); + expect(tree).toHaveLength(4); + // Dirs first: z-dir, a-dir alphabetically → a before z + expect(tree[0].name).toBe("a-dir"); + expect(tree[1].name).toBe("z-dir"); + // Then files alphabetically + expect(tree[2].name).toBe("a.txt"); + expect(tree[3].name).toBe("b.txt"); + }); + + it("alphabetically sorts files within the same level", () => { + const files: FileEntry[] = [ + { path: "z.yaml", size: 10, dir: false }, + { path: "a.yaml", size: 10, dir: false }, + { path: "m.yaml", size: 10, dir: false }, + ]; + const tree = buildTree(files); + expect(tree.map((n) => n.name)).toEqual(["a.yaml", "m.yaml", "z.yaml"]); + }); + + it("nests a file under its parent directory", () => { + const files: FileEntry[] = [ + { path: "skills", size: 0, dir: true }, + { path: "skills/readme.md", size: 64, dir: false }, + ]; + const tree = buildTree(files); + expect(tree).toHaveLength(1); + expect(tree[0].name).toBe("skills"); + expect(tree[0].children).toHaveLength(1); + expect(tree[0].children[0]).toMatchObject({ + name: "readme.md", + path: "skills/readme.md", + isDir: false, + size: 64, + }); + }); + + it("creates intermediate directories automatically", () => { + const files: FileEntry[] = [ + { path: "a/b/c/deep.txt", size: 32, dir: false }, + ]; + const tree = buildTree(files); + // Root has one child: "a" + expect(tree).toHaveLength(1); + expect(tree[0].name).toBe("a"); + expect(tree[0].isDir).toBe(true); + // "a" has one child: "b" + expect(tree[0].children).toHaveLength(1); + expect(tree[0].children[0].name).toBe("b"); + // "b" has one child: "c" + expect(tree[0].children[0].children).toHaveLength(1); + expect(tree[0].children[0].children[0].name).toBe("c"); + // "c" has the file + expect(tree[0].children[0].children[0].children[0].name).toBe("deep.txt"); + expect(tree[0].children[0].children[0].children[0].size).toBe(32); + }); + + it("adds multiple files to the same directory", () => { + const files: FileEntry[] = [ + { path: "configs", size: 0, dir: true }, + { path: "configs/a.yaml", size: 10, dir: false }, + { path: "configs/b.yaml", size: 20, dir: false }, + ]; + const tree = buildTree(files); + expect(tree).toHaveLength(1); + expect(tree[0].children.map((n) => n.name).sort()).toEqual(["a.yaml", "b.yaml"]); + }); + + it("does not duplicate a directory already created as intermediate", () => { + const files: FileEntry[] = [ + { path: "a/b.txt", size: 5, dir: false }, + { path: "a", size: 0, dir: true }, + ]; + const tree = buildTree(files); + // "a" should appear only once + expect(tree).toHaveLength(1); + expect(tree[0].name).toBe("a"); + // The dir "a" should still contain "b.txt" + expect(tree[0].children).toHaveLength(1); + expect(tree[0].children[0].name).toBe("b.txt"); + }); + + it("intermediate dirs have size 0", () => { + const files: FileEntry[] = [ + { path: "a/b/c/file.txt", size: 1, dir: false }, + ]; + const tree = buildTree(files); + expect(tree[0].size).toBe(0); + expect(tree[0].children[0].size).toBe(0); + }); + + it("handles deeply nested mixed dirs and files", () => { + const files: FileEntry[] = [ + { path: "a", size: 0, dir: true }, + { path: "a/b", size: 0, dir: true }, + { path: "a/b/c", size: 0, dir: true }, + { path: "a/b/c/d.txt", size: 1, dir: false }, + { path: "a/b/e.txt", size: 2, dir: false }, + { path: "a/f.txt", size: 3, dir: false }, + ]; + const tree = buildTree(files); + expect(tree).toHaveLength(1); // root: "a" + expect(tree[0].children.map((n) => n.name).sort()).toEqual(["b", "f.txt"]); + expect(tree[0].children.find((n) => n.name === "b")!.children.map((n) => n.name).sort()) + .toEqual(["c", "e.txt"]); + }); +});