6715fbd971
- EditorModal loads once per path (a parent re-render no longer reloads the file over the user's typing), refuses Save after a failed load, opens a truncated (>256 KB) file read-only, and asks before overwriting a file that changed on disk or could not be re-read. - Settings/Hooks editors unset only keys from the snapshot taken at Edit, warn when the file changed underneath, keep a renamed key's value, and refuse a new key named like a redacted one (env) or a read-only hook event, which used to replace the secret/hooks. - Project scope shows its resolved folder and is disabled inside the plugin cache. Account-profile, pricing-row and pet deletes confirm; two-click actions disarm after 3 s; unsaved drafts ask before closing. - Search indexes every Claude Code / Codex sub-tab, opens it via ?tab= and scrolls to + highlights the row; the active config tab is in the URL. - General splits into General / Data / About; Pricing moves to a Cost group with one shared load; config tabs group as Content / Extensions / Config with arrow-key roving; Promise.allSettled per tab. - Data: browser-preferences export/import (allowlisted keys), per-section reset, housekeeping dry-run first, clear-all behind a typed phrase. Server: purge days must be a whole number >= 1 (INVALID_PURGE_DAYS) and dry_run returns exactly what the real run would delete. - Alerts can be edited; webhooks/alerts/remote-source failures toast; copies go through copyText; modals get dialog roles and useDialog. - Sagi: Ctrl/Cmd+B only when shown and not typing, default spot clear of row controls, arrow keys move it, reduced motion honoured; the dead /run launch link goes to the workspace.
286 lines
10 KiB
TypeScript
286 lines
10 KiB
TypeScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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<Record<string, unknown>>();
|
|
return {
|
|
...actual,
|
|
api: { ...(actual.api as Record<string, unknown>), 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(
|
|
<MemoryRouter initialEntries={["/settings/claude?tab=settings"]}>
|
|
<CcConfig />
|
|
</MemoryRouter>
|
|
);
|
|
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(
|
|
<MemoryRouter initialEntries={["/?tab=settings"]}>
|
|
<CcConfig />
|
|
</MemoryRouter>
|
|
);
|
|
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: "<redacted>" } },
|
|
},
|
|
],
|
|
});
|
|
const user = userEvent.setup();
|
|
render(
|
|
<MemoryRouter initialEntries={["/?tab=settings"]}>
|
|
<CcConfig />
|
|
</MemoryRouter>
|
|
);
|
|
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(
|
|
<MemoryRouter initialEntries={["/?tab=hooks"]}>
|
|
<CcConfig />
|
|
</MemoryRouter>
|
|
);
|
|
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(
|
|
<MemoryRouter initialEntries={["/?tab=skills"]}>
|
|
<CcConfig />
|
|
</MemoryRouter>
|
|
);
|
|
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(
|
|
<MemoryRouter>
|
|
<CcConfig />
|
|
</MemoryRouter>
|
|
);
|
|
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(
|
|
<MemoryRouter initialEntries={["/?tab=skills"]}>
|
|
<CcConfig />
|
|
</MemoryRouter>
|
|
);
|
|
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"
|
|
);
|
|
});
|
|
});
|