/** * @file Data-safety tests for the Claude Code config page: the settings editor * unsets only keys from the snapshot it opened (never one written meanwhile), * a renamed key keeps its value, one failing endpoint blanks only its own tab, * the tab lives in `?tab=`, the Project scope is refused under the plugin * cache, and the pure helpers (change → keys, restore command) are right. * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, vi, afterEach } from "vitest"; import { render, screen, act, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm"; import { eventBus } from "../../lib/eventBus"; const ccConfigMock = vi.hoisted(() => { const r = (value: unknown) => vi.fn().mockResolvedValue(value); return { overview: r({ roots: { claudeHome: "/home/.claude", projectClaudeDir: "/repo/.claude", projectRoot: "/repo", }, counts: { skills: { user: 0, project: 0 }, agents: { user: 0, project: 0 }, commands: { user: 0, project: 0 }, outputStyles: { user: 0, project: 0 }, mcpServers: { user: 0, project: 0 }, hooks: {}, plugins: 0, marketplaces: 0, keybindings: 0, settingsFiles: 1, memory: 0, }, }), skills: r({ items: [] }), agents: r({ items: [] }), commands: r({ items: [] }), outputStyles: r({ items: [] }), memory: r({ items: [] }), plugins: r({ manifestPath: "", manifestExists: false, plugins: [] }), marketplaces: r({ items: [] }), mcp: r({ user: [], projectScoped: [] }), hooks: r({ items: [] }), settings: r({ items: [{ scope: "user", file: "/home/.claude/settings.json", exists: true, data: { a: 1 } }], }), keybindings: r({ groups: [] }), statusline: r({ config: null, scripts: [] }), hookScripts: r({ items: [] }), patchSettings: r({ ok: true }), pluginsAvailable: r({ installed: [], available: [] }), updateAllStatus: r({ batch: null }), }; }); vi.mock("../../lib/api", async (importOriginal) => { const actual = await importOriginal>(); return { ...actual, api: { ...(actual.api as Record), ccConfig: ccConfigMock }, }; }); import { CcConfig, isPluginCachePath, keysForChange, restoreCommand } from "../CcConfig"; async function settle() { await act(async () => { await new Promise((res) => setTimeout(res, 0)); await new Promise((res) => setTimeout(res, 0)); }); } afterEach(() => vi.clearAllMocks()); describe("CcConfig — settings editor", () => { it("unsets only keys from its own snapshot, never one written after Edit", async () => { const user = userEvent.setup(); render( ); await settle(); await user.click(screen.getByRole("button", { name: /^edit$/i })); // The live CLI writes key `b` while the editor is open. ccConfigMock.settings.mockResolvedValue({ items: [ { scope: "user", file: "/home/.claude/settings.json", exists: true, data: { a: 1, b: 2 } }, ], }); vi.useFakeTimers({ shouldAdvanceTime: true }); act(() => eventBus.publish({ type: "cc_config_changed", data: { source: "fs", paths: ["/home/.claude/settings.json"] }, } as never) ); await act(async () => { vi.advanceTimersByTime(300); }); vi.useRealTimers(); await settle(); // Remove `a` and save. await user.click(screen.getAllByRole("button", { name: /remove key/i })[0]!); let pending: PendingConfirm | null = null; const off = subscribeConfirm((p) => (pending = p)); await user.click(screen.getByRole("button", { name: /^save$/i })); await settle(); // The file changed underneath: it asks before writing. expect(pending).not.toBeNull(); await act(async () => settleConfirm(true)); await settle(); off(); expect(ccConfigMock.patchSettings).toHaveBeenCalledWith({ scope: "user", unset: ["a"] }); }); it("a renamed key is sent under its new name, not dropped", async () => { const user = userEvent.setup(); render( ); await settle(); await user.click(screen.getByRole("button", { name: /^edit$/i })); const keyBox = screen.getByDisplayValue("a"); await user.clear(keyBox); await user.type(keyBox, "z"); await user.click(screen.getByRole("button", { name: /^save$/i })); await settle(); expect(ccConfigMock.patchSettings).toHaveBeenCalledWith({ scope: "user", set: { z: 1 }, unset: ["a"], }); }); }); describe("CcConfig — redacted and read-only entries", () => { it("refuses a new settings key that names a redacted one (it would replace the secret)", async () => { ccConfigMock.settings.mockResolvedValue({ items: [ { scope: "user", file: "/home/.claude/settings.json", exists: true, data: { a: 1, env: { API_KEY: "" } }, }, ], }); const user = userEvent.setup(); render( ); await settle(); await user.click(screen.getByRole("button", { name: /^edit$/i })); await user.click(screen.getByRole("button", { name: /add key/i })); const keys = screen.getAllByRole("textbox", { name: /^key$/i }); await user.type(keys[keys.length - 1]!, "env"); await user.click(screen.getByRole("button", { name: /^save$/i })); await settle(); expect(ccConfigMock.patchSettings).not.toHaveBeenCalled(); expect(screen.getByText(/redacted/i, { selector: "span" })).toBeInTheDocument(); }); it("refuses a new hook event that names a read-only one (it would replace its entries)", async () => { ccConfigMock.hooks.mockResolvedValue({ items: [ { scope: "user", file: "/home/.claude/settings.json", exists: true, hooks: { Stop: [{ type: "prompt", prompt: "x" }] }, }, ], }); const user = userEvent.setup(); render( ); await settle(); await user.click(screen.getByRole("button", { name: /^edit$/i })); await user.click(screen.getByRole("button", { name: /add event/i })); await user.type(screen.getByRole("textbox", { name: /event/i }), "Stop"); await user.type(screen.getByRole("textbox", { name: /command/i }), "echo hi"); await user.click(screen.getByRole("button", { name: /^save$/i })); await settle(); expect(ccConfigMock.patchSettings).not.toHaveBeenCalled(); expect( screen.getByText(/can't be edited here|read-only/i, { selector: "span" }) ).toBeInTheDocument(); }); }); describe("CcConfig — page", () => { it("one failing endpoint shows its error on its own tab and leaves the rest working", async () => { ccConfigMock.mcp.mockRejectedValueOnce(new Error("mcp down")); const user = userEvent.setup(); render( ); await settle(); expect(screen.queryByText(/mcp down/)).toBeNull(); expect(screen.getByRole("tab", { name: /skills/i })).toHaveAttribute("aria-selected", "true"); await user.click(screen.getByRole("tab", { name: /mcp/i })); expect(await screen.findByText(/mcp down/)).toBeInTheDocument(); }); it("arrow keys move between tabs (roving tabindex)", async () => { const user = userEvent.setup(); render( ); await settle(); const list = screen.getByRole("tablist"); const overview = within(list).getByRole("tab", { name: /overview/i }); expect(overview).toHaveAttribute("tabindex", "0"); overview.focus(); await user.keyboard("{ArrowRight}"); expect(within(list).getByRole("tab", { name: /skills/i })).toHaveAttribute( "aria-selected", "true" ); expect(document.activeElement).toBe(within(list).getByRole("tab", { name: /skills/i })); }); it("refuses the Project scope when the project root is the plugin cache", async () => { ccConfigMock.overview.mockResolvedValueOnce({ roots: { claudeHome: "/home/.claude", projectClaudeDir: "/home/u/.claude/plugins/cache/agenthub/agenthub/1.8.13/.claude", projectRoot: "/home/u/.claude/plugins/cache/agenthub/agenthub/1.8.13", }, counts: (await ccConfigMock.overview()).counts, }); render( ); await settle(); expect(screen.getByRole("button", { name: /^project$/i })).toBeDisabled(); }); }); describe("CcConfig helpers", () => { it("maps a change to the keys it touched", () => { expect(keysForChange({ source: "fs", paths: ["/h/.claude/skills/x/SKILL.md"] }).sort()).toEqual( ["overview", "skills"].sort() ); expect(keysForChange({ source: "dashboard", type: "keybindings" }).sort()).toEqual( ["keybindings", "overview"].sort() ); // Something unplaceable reloads everything. expect(keysForChange({ source: "fs", paths: ["/h/.claude/weird.txt"] })).toContain("mcp"); }); it("knows the plugin cache", () => { expect(isPluginCachePath("/home/u/.claude/plugins/cache/a/b/1.0")).toBe(true); expect(isPluginCachePath("/home/u/projects/app")).toBe(false); expect(isPluginCachePath(null)).toBe(false); }); it("restores a skill directory by copying its contents, never mv into it", () => { expect(restoreCommand("/b/s.2026.bak", "/h/skills/s", true)).toBe( "mkdir -p /h/skills/s && cp -a /b/s.2026.bak/. /h/skills/s/" ); expect(restoreCommand("/b/a.md.2026.bak", "/h/agents/a.md", false)).toBe( "cp -a /b/a.md.2026.bak /h/agents/a.md" ); }); });