Files
AgentHub/client/src/pages/__tests__/CcConfig.dataSafety.test.tsx
T
nntrivi2001 198fbb7980 fix: event-loop freezes, plugin-install boot crash, tmux attach/scrollbar, Codex transcripts
- GET /api/workflows: one LEAD() window pass in a read worker (read-pool.js,
  workflow-stats.js); idx_events_session restored, idx_events_tool added
- compat-sqlite gains function/pluck/readonly so a plugin install without a
  better-sqlite3 build boots again; antigravity-usage uses db.js's Database
- install-hooks adds no checkout hooks while the agenthub plugin is enabled
  (events were doubled)
- terminal attach runs the live tmux server's own binary; the pane scrollbar
  gets a visible 2-column style, and claude panes start with
  CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 so tmux keeps history to scroll
- Codex sessions' conversation in GET /api/sessions/:id/transcript
- Codex/Antigravity sessions claimed by their workspace without hooks
- UI-audit follow-ups, docs and OpenAPI
2026-10-05 17:01:05 +07:00

320 lines
11 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, useLocation, useNavigate } 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 tab change from the URL (a search result) asks before dropping an unsaved edit", async () => {
const user = userEvent.setup();
function Jump() {
const navigate = useNavigate();
const loc = useLocation();
return (
<>
<button onClick={() => navigate("/settings/claude?tab=skills")}>jump</button>
<span data-testid="url">{loc.search}</span>
</>
);
}
render(
<MemoryRouter initialEntries={["/settings/claude?tab=settings"]}>
<CcConfig />
<Jump />
</MemoryRouter>
);
await settle();
await user.click(screen.getByRole("button", { name: /^edit$/i }));
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: "jump" }));
await settle();
expect(pending).not.toBeNull();
// Keep editing: the draft stays and the URL goes back to its tab.
await act(async () => settleConfirm(false));
await settle();
off();
expect(screen.getByRole("button", { name: /^save$/i })).toBeInTheDocument();
expect(screen.getByTestId("url")).toHaveTextContent("?tab=settings");
});
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"
);
});
});