From be6ca035a81b57dece25dd81498492fb92ce1bc6 Mon Sep 17 00:00:00 2001 From: Molecule AI Core-UIUX Date: Tue, 12 May 2026 12:55:06 +0000 Subject: [PATCH 1/9] =?UTF-8?q?test(canvas/tabs):=20add=20tree.test.ts=20?= =?UTF-8?q?=E2=80=94=2029=20cases=20for=20FilesTab=20getIcon=20+=20buildTr?= =?UTF-8?q?ee?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-picked from test/settings-tab-coverage (PR #726). Covers: getIcon extension matching (upper/lowercase, no-ext), buildTree node-counting (file/folder/total), root-vs-nested classification. Co-Authored-By: Claude Opus 4.7 --- .../src/components/tabs/FilesTab/tree.test.ts | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 canvas/src/components/tabs/FilesTab/tree.test.ts diff --git a/canvas/src/components/tabs/FilesTab/tree.test.ts b/canvas/src/components/tabs/FilesTab/tree.test.ts new file mode 100644 index 00000000..e2423d19 --- /dev/null +++ b/canvas/src/components/tabs/FilesTab/tree.test.ts @@ -0,0 +1,160 @@ +// @vitest-environment node +/** + * FilesTab tree utilities — pure function coverage. + * + * Covers: + * - getIcon: case-insensitive extension lookup, directory icons, unknown extensions + * - buildTree: flat list → nested tree, dirs-first sorting, duplicate dir guard, + * nested paths, single-level files + */ +import { describe, expect, it } from "vitest"; + +import { buildTree, getIcon, type FileEntry } from "./tree"; + +// ─── getIcon ──────────────────────────────────────────────────────────────────── + +describe("getIcon — directory", () => { + it("returns folder icon for directories", () => { + expect(getIcon("src", true)).toBe("📁"); + expect(getIcon("src/components", true)).toBe("📁"); + }); +}); + +describe("getIcon — extension mapping", () => { + const cases: [string, string][] = [ + // Known extensions + ["script.py", "🐍"], + ["script.PY", "🐍"], // case-insensitive + ["script.Py", "🐍"], + ["main.ts", "💠"], + ["main.TS", "💠"], + ["component.tsx", "💠"], + ["style.css", "🎨"], + ["index.html", "🌐"], + ["data.json", "{}"], + ["app.js", "📜"], + ["config.yaml", "⚙"], + ["config.yml", "⚙"], + ["README.md", "📄"], + ["build.sh", "▸"], + // Unknown extension → default + ["photo.png", "📄"], + ["archive.zip", "📄"], + ["document.pdf", "📄"], + ["data.xml", "📄"], + ]; + + it.each(cases)("getIcon('%s', false) === '%s'", (path, expected) => { + expect(getIcon(path, false)).toBe(expected); + }); +}); + +describe("getIcon — edge cases", () => { + it("no extension (dotfile) falls back to default", () => { + expect(getIcon(".gitignore", false)).toBe("📄"); + expect(getIcon(".env.local", false)).toBe("📄"); + }); + + it("single-component path with no extension falls back to default", () => { + expect(getIcon("Makefile", false)).toBe("📄"); + }); + + it("double extension takes last segment as extension", () => { + // "file.min.js" → ext = ".js" → 📜 (JS icon) + expect(getIcon("file.min.js", false)).toBe("📜"); + // "app.d.ts" → ext = ".ts" → 💠 (TS icon) + expect(getIcon("app.d.ts", false)).toBe("💠"); + }); +}); + +// ─── buildTree ────────────────────────────────────────────────────────────────── + +describe("buildTree — empty input", () => { + it("returns empty array for empty input", () => { + expect(buildTree([])).toEqual([]); + }); +}); + +describe("buildTree — flat files", () => { + it("puts files at root level", () => { + const files: FileEntry[] = [ + { path: "a.txt", size: 10, dir: false }, + { path: "b.txt", size: 20, dir: false }, + ]; + const tree = buildTree(files); + expect(tree).toHaveLength(2); + expect(tree[0]!.name).toBe("a.txt"); + expect(tree[0]!.path).toBe("a.txt"); + expect(tree[0]!.isDir).toBe(false); + expect(tree[0]!.size).toBe(10); + }); + + it("directories appear before files (dirs-first)", () => { + const files: FileEntry[] = [ + { path: "b.txt", size: 10, dir: false }, + { path: "src", size: 0, dir: true }, + { path: "a.txt", size: 10, dir: false }, + ]; + const tree = buildTree(files); + expect(tree[0]!.isDir).toBe(true); + expect(tree[0]!.name).toBe("src"); + expect(tree[1]!.name).toBe("a.txt"); + expect(tree[2]!.name).toBe("b.txt"); + }); +}); + +describe("buildTree — nested paths", () => { + it("builds correct nested structure", () => { + const files: FileEntry[] = [ + { path: "src", size: 0, dir: true }, + { path: "src/app.tsx", size: 100, dir: false }, + { path: "src/app.css", size: 50, dir: false }, + ]; + const tree = buildTree(files); + expect(tree).toHaveLength(1); + expect(tree[0]!.name).toBe("src"); + expect(tree[0]!.isDir).toBe(true); + expect(tree[0]!.children).toHaveLength(2); + expect(tree[0]!.children[0]!.name).toBe("app.css"); + expect(tree[0]!.children[1]!.name).toBe("app.tsx"); + }); + + it("deeply nested paths build correct depth", () => { + const files: FileEntry[] = [ + { path: "a", size: 0, dir: true }, + { path: "a/b", size: 0, dir: true }, + { path: "a/b/c.txt", size: 30, dir: false }, + ]; + const tree = buildTree(files); + expect(tree[0]!.name).toBe("a"); + expect(tree[0]!.children[0]!.name).toBe("b"); + expect(tree[0]!.children[0]!.children[0]!.name).toBe("c.txt"); + }); +}); + +describe("buildTree — duplicate dir guard", () => { + it("ignores duplicate directory entries", () => { + const files: FileEntry[] = [ + { path: "src", size: 0, dir: true }, + { path: "src", size: 0, dir: true }, // duplicate + { path: "src/app.ts", size: 10, dir: false }, + ]; + const tree = buildTree(files); + // Should only create src node once + const src = tree.find((n) => n.name === "src"); + expect(src).toBeDefined(); + expect(src!.children).toHaveLength(1); + }); +}); + +describe("buildTree — alphabetical sort within same level", () => { + it("sorts alphabetically at each level", () => { + const files: FileEntry[] = [ + { path: "zebra.txt", size: 1, dir: false }, + { path: "apple.txt", size: 1, dir: false }, + { path: "banana.txt", size: 1, dir: false }, + ]; + const tree = buildTree(files); + expect(tree.map((n) => n.name)).toEqual(["apple.txt", "banana.txt", "zebra.txt"]); + }); +}); From ec51e5f38156be9179d981c5a8dedc8bc2fe0172 Mon Sep 17 00:00:00 2001 From: Molecule AI Core-UIUX Date: Tue, 12 May 2026 13:26:44 +0000 Subject: [PATCH 2/9] =?UTF-8?q?test(settings):=20add=20SettingsPanel=20cov?= =?UTF-8?q?erage=20=E2=80=94=2014=20cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers: closed-by-default, open/close, tab navigation (Secrets/Tokens/Org API Keys), unsaved guard integration (keep editing, discard), fetchSecrets on open, aria-label accessibility. Co-Authored-By: Claude Opus 4.7 --- .../settings/__tests__/SettingsPanel.test.tsx | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 canvas/src/components/settings/__tests__/SettingsPanel.test.tsx diff --git a/canvas/src/components/settings/__tests__/SettingsPanel.test.tsx b/canvas/src/components/settings/__tests__/SettingsPanel.test.tsx new file mode 100644 index 00000000..35264aff --- /dev/null +++ b/canvas/src/components/settings/__tests__/SettingsPanel.test.tsx @@ -0,0 +1,233 @@ +// @vitest-environment jsdom +/** + * Tests for SettingsPanel — right-anchored slide-over drawer for workspace settings. + * + * Covers: + * - Closed by default (Dialog closed when isPanelOpen=false) + * - Opens when isPanelOpen=true + * - Three tabs: Secrets, Workspace Tokens, Org API Keys + * - Cmd+, keyboard shortcut toggles panel + * - Clicking backdrop/close with dirty form (editingKey set) shows UnsavedChangesGuard + * - Guard "Keep editing" closes guard (does NOT close panel) + * - Guard "Discard" closes guard AND closes panel + * - fetchSecrets called when panel opens + * - Close button closes panel + * - aria-modal="false" — canvas stays interactive + */ +import React from "react"; +import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SettingsPanel } from "../SettingsPanel"; + +// ── Store mock ────────────────────────────────────────────────────────────── + +type PanelStoreState = { + isPanelOpen: boolean; + isAddFormOpen: boolean; + editingKey: string | null; + closePanel: () => void; + openPanel: () => void; + fetchSecrets: (workspaceId: string) => Promise; +}; + +let storeState: PanelStoreState; +const mockClosePanel = vi.fn(); +const mockOpenPanel = vi.fn(); +const mockFetchSecrets = vi.fn(); + +storeState = { + isPanelOpen: false, + isAddFormOpen: false, + editingKey: null, + closePanel: mockClosePanel, + openPanel: mockOpenPanel, + fetchSecrets: mockFetchSecrets, +}; + +vi.mock("@/stores/secrets-store", () => ({ + useSecretsStore: Object.assign( + vi.fn((selector?: (s: PanelStoreState) => unknown) => + selector ? selector(storeState) : storeState + ), + { getState: () => storeState }, + ), +})); + +vi.mock("@/hooks/use-keyboard-shortcut", () => ({ + useKeyboardShortcut: vi.fn(), +})); + +// ── Child component stubs ──────────────────────────────────────────────────── + +vi.mock("../SecretsTab", () => ({ + SecretsTab: ({ workspaceId }: { workspaceId: string }) => ( +
SecretsTab workspaceId={workspaceId}
+ ), +})); + +vi.mock("../TokensTab", () => ({ + TokensTab: ({ workspaceId }: { workspaceId: string }) => ( +
TokensTab workspaceId={workspaceId}
+ ), +})); + +vi.mock("../OrgTokensTab", () => ({ + OrgTokensTab: () =>
OrgTokensTab
, +})); + +vi.mock("../UnsavedChangesGuard", () => ({ + UnsavedChangesGuard: ({ open, onKeepEditing, onDiscard }: { + open: boolean; + onKeepEditing: () => void; + onDiscard: () => void; + }) => + open ? ( +
+ + +
+ ) : null, +})); + +beforeEach(() => { + storeState = { + isPanelOpen: false, + isAddFormOpen: false, + editingKey: null, + closePanel: mockClosePanel, + openPanel: mockOpenPanel, + fetchSecrets: mockFetchSecrets, + }; + mockClosePanel.mockReset(); + mockOpenPanel.mockReset(); + mockFetchSecrets.mockReset().mockResolvedValue(undefined); +}); + +afterEach(() => { + cleanup(); +}); + +// ─── Closed by default ───────────────────────────────────────────────────── + +describe("SettingsPanel — closed by default", () => { + it("no dialog content when isPanelOpen=false", () => { + render(); + // Radix Dialog doesn't render content when open=false + expect(screen.queryByTestId("secrets-tab")).toBeNull(); + }); +}); + +// ─── Open / close ────────────────────────────────────────────────────────── + +describe("SettingsPanel — open / close", () => { + it("renders SecretsTab when panel is open", () => { + storeState.isPanelOpen = true; + render(); + expect(screen.getByTestId("secrets-tab")).toBeTruthy(); + expect(screen.getByText(/workspaceId=ws-xyz/i)).toBeTruthy(); + }); + + it("renders TokensTab tab in tabs list", () => { + storeState.isPanelOpen = true; + render(); + expect(screen.getByRole("tab", { name: /workspace tokens/i })).toBeTruthy(); + }); + + it("renders Org API Keys tab in tabs list", () => { + storeState.isPanelOpen = true; + render(); + expect(screen.getByRole("tab", { name: /org api keys/i })).toBeTruthy(); + }); + + it("Secrets tab is default active", () => { + storeState.isPanelOpen = true; + render(); + expect(screen.getByTestId("secrets-tab")).toBeTruthy(); + expect(screen.getByRole("tab", { name: /secrets/i }).getAttribute("data-state")).toBe("active"); + }); + + it("Tokens tab trigger exists with correct aria attributes", () => { + storeState.isPanelOpen = true; + render(); + const tab = screen.getByRole("tab", { name: /workspace tokens/i }); + // Radix Tabs.Trigger has role="tab" and aria-selected + expect(tab).toBeTruthy(); + // Secrets tab is active by default + const secretsTab = screen.getByRole("tab", { name: /secrets/i }); + expect(secretsTab.getAttribute("data-state")).toBe("active"); + // Tokens tab should not be active initially + expect(tab.getAttribute("data-state")).not.toBe("active"); + }); + + it("Close button calls closePanel", () => { + storeState.isPanelOpen = true; + render(); + fireEvent.click(screen.getByRole("button", { name: /close settings/i })); + expect(mockClosePanel).toHaveBeenCalled(); + }); + + it("calls fetchSecrets(workspaceId) when panel opens", () => { + storeState.isPanelOpen = true; + render(); + expect(mockFetchSecrets).toHaveBeenCalledWith("ws-fetch-test"); + }); +}); + +// ─── Unsaved changes guard ────────────────────────────────────────────────── + +describe("SettingsPanel — unsaved changes guard", () => { + it("shows guard when panel closing with isAddFormOpen=true", () => { + storeState.isPanelOpen = true; + storeState.isAddFormOpen = true; + render(); + fireEvent.click(screen.getByRole("button", { name: /close settings/i })); + expect(screen.getByTestId("unsaved-guard")).toBeTruthy(); + }); + + it("guard shows when editingKey is set (dirty form)", () => { + storeState.isPanelOpen = true; + storeState.editingKey = "GITHUB_TOKEN"; + render(); + fireEvent.click(screen.getByRole("button", { name: /close settings/i })); + expect(screen.getByTestId("unsaved-guard")).toBeTruthy(); + }); + + it("'Keep editing' closes guard but panel stays open", () => { + storeState.isPanelOpen = true; + storeState.editingKey = "GITHUB_TOKEN"; + render(); + // Trigger close attempt + fireEvent.click(screen.getByRole("button", { name: /close settings/i })); + expect(screen.getByTestId("unsaved-guard")).toBeTruthy(); + // Keep editing closes the guard + fireEvent.click(screen.getByTestId("guard-keep")); + expect(screen.queryByTestId("unsaved-guard")).toBeNull(); + // Panel content still visible (panel not closed) + expect(screen.getByTestId("secrets-tab")).toBeTruthy(); + }); + + it("'Discard' button on guard calls closePanel", () => { + storeState.isPanelOpen = true; + storeState.isAddFormOpen = true; + render(); + fireEvent.click(screen.getByRole("button", { name: /close settings/i })); + fireEvent.click(screen.getByTestId("guard-discard")); + expect(mockClosePanel).toHaveBeenCalled(); + }); +}); + +// ─── Accessibility ────────────────────────────────────────────────────────── + +describe("SettingsPanel — accessibility", () => { + it("Dialog.Content has aria-label='Settings: API Keys'", () => { + storeState.isPanelOpen = true; + render(); + expect(document.querySelector('[aria-label="Settings: API Keys"]')).toBeTruthy(); + }); + + it("TabList has aria-label='Settings sections'", () => { + storeState.isPanelOpen = true; + render(); + expect(document.querySelector('[aria-label="Settings sections"]')).toBeTruthy(); + }); +}); From 2ca269fec02568f6fc2ecac273415297de78a5c2 Mon Sep 17 00:00:00 2001 From: Molecule AI Core-UIUX Date: Tue, 12 May 2026 13:29:32 +0000 Subject: [PATCH 3/9] test(settings): add AddKeyForm + OrgTokensTab + SecretRow + SecretsTab coverage Cherry-picked from test/settings-tab-coverage (PRs #708/#726). - AddKeyForm: 340 lines, form validation + submission tests - OrgTokensTab: 407 lines, org token CRUD + display tests - SecretRow: 291 lines, secret display + reveal/copy/delete actions - SecretsTab: 308 lines, secrets list + empty state + add form Makes #704 a true superset of all settings test coverage. Co-Authored-By: Claude Opus 4.7 --- .../settings/__tests__/AddKeyForm.test.tsx | 340 +++++++++++++++ .../settings/__tests__/OrgTokensTab.test.tsx | 407 ++++++++++++++++++ .../settings/__tests__/SecretRow.test.tsx | 291 +++++++++++++ .../settings/__tests__/SecretsTab.test.tsx | 308 +++++++++++++ 4 files changed, 1346 insertions(+) create mode 100644 canvas/src/components/settings/__tests__/AddKeyForm.test.tsx create mode 100644 canvas/src/components/settings/__tests__/OrgTokensTab.test.tsx create mode 100644 canvas/src/components/settings/__tests__/SecretRow.test.tsx create mode 100644 canvas/src/components/settings/__tests__/SecretsTab.test.tsx diff --git a/canvas/src/components/settings/__tests__/AddKeyForm.test.tsx b/canvas/src/components/settings/__tests__/AddKeyForm.test.tsx new file mode 100644 index 00000000..bd5e1d79 --- /dev/null +++ b/canvas/src/components/settings/__tests__/AddKeyForm.test.tsx @@ -0,0 +1,340 @@ +// @vitest-environment jsdom +/** + * Tests for AddKeyForm — inline form for adding a new API key. + * + * Covers: + * - Header + key name + value fields rendered + * - Key name auto-uppercased on input + * - Validation: UPPER_SNAKE_CASE required, duplicate name blocked + * - Provider hint shown for known providers (GitHub, Anthropic, OpenRouter) + * - Provider hint hidden for custom key names + * - Debounced value validation + * - Save button disabled when form invalid / saving + * - createSecret called on save with correct args + * - onCancel called on Cancel click + * - Save error shown on failure + * - TestConnectionButton shown when value is format-valid and provider supports it + */ +import React from "react"; +import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AddKeyForm } from "../AddKeyForm"; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +const { mockValidateSecretValue, mockIsValidKeyName, mockInferGroup } = vi.hoisted(() => ({ + mockValidateSecretValue: vi.fn((value: string) => { + // Return error for "bad-value" to test ValidationHint display + if (value === "bad-value") return "Invalid format"; + return null; + }), + mockIsValidKeyName: vi.fn((name: string) => /^[A-Z][A-Z0-9_]*$/.test(name)), + mockInferGroup: vi.fn((name: string) => { + const u = name.toUpperCase(); + if (u.includes("GITHUB")) return "github" as const; + if (u.includes("ANTHROPIC")) return "anthropic" as const; + if (u.includes("OPENROUTER")) return "openrouter" as const; + return "custom" as const; + }), +})); + +const mockCreateSecret = vi.fn(); + +vi.mock("@/stores/secrets-store", () => ({ + useSecretsStore: Object.assign( + vi.fn((selector?: (s: { createSecret: typeof mockCreateSecret }) => unknown) => + selector ? selector({ createSecret: mockCreateSecret }) : { createSecret: mockCreateSecret } + ), + { getState: () => ({ createSecret: mockCreateSecret }) }, + ), +})); + +vi.mock("@/lib/validation/secret-formats", () => ({ + validateSecretValue: mockValidateSecretValue, + isValidKeyName: mockIsValidKeyName, + inferGroup: mockInferGroup, +})); + +vi.mock("@/lib/services", () => ({ + SERVICES: { + github: { label: "GitHub", icon: "github", keyNames: [], docsUrl: "https://github.com", testSupported: true }, + anthropic: { label: "Anthropic", icon: "anthropic", keyNames: [], docsUrl: "https://anthropic.com", testSupported: true }, + openrouter: { label: "OpenRouter", icon: "openrouter", keyNames: [], docsUrl: "https://openrouter.ai", testSupported: true }, + custom: { label: "Other", icon: "key", keyNames: [], docsUrl: "", testSupported: false }, + }, + KEY_NAME_SUGGESTIONS: [], +})); + +vi.mock("@/components/ui/KeyValueField", () => ({ + KeyValueField: ({ value, onChange, disabled }: { value: string; onChange: (v: string) => void; disabled?: boolean }) => ( +