feat(settings): data-safe editors, deep-linked search, Data/About/Cost pages

- 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.
This commit is contained in:
2026-10-05 13:22:08 +07:00
parent 03f1673fce
commit 6715fbd971
68 changed files with 4817 additions and 1374 deletions
@@ -7,10 +7,11 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AiRouterConfig } from "../AiRouterConfig";
import { api } from "../../lib/api";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
vi.mock("../../lib/api", () => ({
api: {
@@ -158,8 +159,18 @@ describe("AiRouterConfig", () => {
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "x");
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
expect(await screen.findByText(/must be http/)).toBeInTheDocument();
// Inside the dialog, not behind its scrim.
const dialog = screen.getByRole("dialog");
expect(await within(dialog).findByText(/must be http/)).toBeInTheDocument();
});
it("closes the form on Escape", async () => {
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
await user.keyboard("{Escape}");
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
});
@@ -236,7 +247,8 @@ describe("AiRouterConfig — testing an endpoint before saving it", () => {
it("opens a profile's Edit and Remove from a right-click; the built-in row has no menu", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
const confirm = vi.spyOn(window, "confirm").mockReturnValueOnce(false);
let pending: PendingConfirm | null = null;
const off = subscribeConfirm((p) => (pending = p));
const user = userEvent.setup();
render(<AiRouterConfig />);
const row = (await screen.findByText("9Router")).closest("tr")!;
@@ -246,12 +258,14 @@ describe("AiRouterConfig — testing an endpoint before saving it", () => {
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "aiRouter.remove" }));
expect(confirm).toHaveBeenCalledWith("aiRouter.removeConfirm:9Router");
await waitFor(() => expect(pending?.title).toBe("aiRouter.removeConfirm:9Router"));
settleConfirm(false);
await waitFor(() => expect(pending).toBeNull());
expect(api.aiRouters.remove).not.toHaveBeenCalled();
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "aiRouter.edit" }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
confirm.mockRestore();
off();
});
});
@@ -14,6 +14,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { api, type AiTool } from "../../lib/api";
import { __resetAiTools } from "../../hooks/useAiTools";
import { AiToolConfig } from "../AiToolConfig";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
import { subscribeToasts, type ToastItem } from "../../lib/toast";
vi.mock("../../lib/api", () => ({
api: {
@@ -138,4 +140,54 @@ describe("AiToolConfig", () => {
expect(await screen.findByText(/not valid JSON/)).toBeInTheDocument();
expect(api.aiTools.saveConfig).toHaveBeenCalledWith("gemini", 0, "{nope", "s1");
});
it("toasts a saved default model", async () => {
let seen: ToastItem[] = [];
const off = subscribeToasts((i) => (seen = i));
renderTab();
fireEvent.click(await screen.findByText("Inherit from settings"));
fireEvent.click(await screen.findByText("gemini-3.1-pro"));
fireEvent.click(screen.getByRole("button", { name: /save default model/i }));
await waitFor(() => expect(seen.some((x) => x.kind === "success")).toBe(true));
off();
});
it("offers a reload on ESTALE and keeps the user's text until they take it", async () => {
vi.mocked(api.aiTools.saveConfig).mockRejectedValueOnce(
Object.assign(new Error("changed on disk"), { code: "ESTALE", status: 409 })
);
renderTab();
fireEvent.click(await screen.findByRole("button", { name: "/h/.gemini/settings.json" }));
const box = await screen.findByRole("textbox", { name: "/h/.gemini/settings.json" });
fireEvent.change(box, { target: { value: "{mine}" } });
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
const reload = await screen.findByRole("button", { name: /reload/i });
expect(box).toHaveValue("{mine}");
vi.mocked(api.aiTools.readConfig).mockResolvedValueOnce({
path: "/h/.gemini/settings.json",
format: "json",
text: "{disk}",
sha: "s2",
});
fireEvent.click(reload);
await waitFor(() => expect(box).toHaveValue("{disk}"));
});
it("asks before Close discards an edited file", async () => {
let pending: PendingConfirm | null = null;
const off = subscribeConfirm((p) => (pending = p));
renderTab();
fireEvent.click(await screen.findByRole("button", { name: "/h/.gemini/settings.json" }));
const box = await screen.findByRole("textbox", { name: "/h/.gemini/settings.json" });
fireEvent.change(box, { target: { value: "{edited}" } });
fireEvent.click(screen.getByRole("button", { name: /^close$/i }));
await waitFor(() => expect(pending).not.toBeNull());
expect(box).toBeInTheDocument();
settleConfirm(false);
await new Promise((r) => setTimeout(r, 0));
expect(screen.getByRole("textbox", { name: "/h/.gemini/settings.json" })).toHaveValue(
"{edited}"
);
off();
});
});
@@ -9,6 +9,8 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { settleConfirm } from "../../lib/confirm";
const ccConfigMock = vi.hoisted(() => {
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
@@ -99,7 +101,11 @@ afterEach(() => vi.clearAllMocks());
describe("CcConfig — right-click menus", () => {
it("uninstalls a plugin from its menu only on the second click, like the button", async () => {
const user = userEvent.setup();
render(<CcConfig />);
render(
<MemoryRouter>
<CcConfig />
</MemoryRouter>
);
await settle();
await user.click(screen.getByRole("tab", { name: /plugins/i }));
await settle();
@@ -121,7 +127,11 @@ describe("CcConfig — right-click menus", () => {
it("deletes a saved profile from its menu through the same API as the trash button", async () => {
const user = userEvent.setup();
render(<CcConfig />);
render(
<MemoryRouter>
<CcConfig />
</MemoryRouter>
);
await settle();
await user.click(screen.getByRole("button", { name: /profile/i }));
await settle();
@@ -129,12 +139,20 @@ describe("CcConfig — right-click menus", () => {
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("profile-row-spare") });
await user.click(screen.getByTestId("profile-menu-delete"));
await settle();
// A saved login is never deleted on one click: it asks first.
expect(ccConfigMock.deleteProfile).not.toHaveBeenCalled();
await act(async () => settleConfirm(true));
await settle();
expect(ccConfigMock.deleteProfile).toHaveBeenCalledWith("spare");
});
it("offers no delete for the only saved copy of the live account", async () => {
const user = userEvent.setup();
render(<CcConfig />);
render(
<MemoryRouter>
<CcConfig />
</MemoryRouter>
);
await settle();
await user.click(screen.getByRole("button", { name: /profile/i }));
await settle();
@@ -0,0 +1,285 @@
/**
* @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"
);
});
});
@@ -12,6 +12,7 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
const ccConfigMock = vi.hoisted(() => {
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
@@ -109,7 +110,11 @@ async function settle() {
}
async function openProfiles() {
render(<CcConfig />);
render(
<MemoryRouter>
<CcConfig />
</MemoryRouter>
);
await settle();
await userEvent.click(screen.getByRole("button", { name: /profile/i }));
await settle();
@@ -13,6 +13,8 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, act, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { Toaster } from "../../components/Toaster";
const ccConfigMock = vi.hoisted(() => {
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
@@ -100,7 +102,13 @@ async function settle() {
/** Open the plugins tab, where the Update all card lives. */
async function openPlugins() {
render(<CcConfig />);
// Toasts go through the app-wide queue, so the test mounts its renderer.
render(
<MemoryRouter>
<CcConfig />
<Toaster />
</MemoryRouter>
);
await settle();
await userEvent.click(screen.getByRole("tab", { name: /plugins/i }));
await settle();
@@ -10,6 +10,7 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
// vi.mock factories are hoisted above the rest of the module, so the mock
// object they reference must be created inside vi.hoisted() too.
@@ -131,7 +132,11 @@ describe("CodexConfig", () => {
codex_home: "/work/.codex",
env_owned: false,
});
render(<CodexConfig />);
render(
<MemoryRouter>
<CodexConfig />
</MemoryRouter>
);
await settle();
const overviewCalls = codexConfigMock.overview.mock.calls.length;
@@ -151,7 +156,11 @@ describe("CodexConfig", () => {
it("offers create on the authored surfaces and withholds it on the CLI-owned ones", async () => {
stubCodexApi({ skills: [], mcp: { servers: [] } });
render(<CodexConfig />);
render(
<MemoryRouter>
<CodexConfig />
</MemoryRouter>
);
await settle();
await userEvent.click(screen.getByRole("tab", { name: /skills/i }));
@@ -167,7 +176,11 @@ describe("CodexConfig", () => {
rules: [{ scope: "user", name: "default", file: "/r/default.rules", size: 3 }],
write,
});
render(<CodexConfig />);
render(
<MemoryRouter>
<CodexConfig />
</MemoryRouter>
);
await settle();
await userEvent.click(screen.getByRole("tab", { name: /rules/i }));
@@ -181,7 +194,11 @@ describe("CodexConfig", () => {
it("keeps the file-backed tabs working when the codex CLI read rejects", async () => {
stubCodexApi({ skills: [{ scope: "user", name: "my-skill", file: "/s/my-skill", size: 3 }] });
codexConfigMock.mcp.mockRejectedValueOnce(new Error("the codex CLI was not found on PATH"));
render(<CodexConfig />);
render(
<MemoryRouter>
<CodexConfig />
</MemoryRouter>
);
await settle();
// No page-wide error banner - the CLI-backed rejection is isolated.
@@ -199,7 +216,11 @@ describe("CodexConfig", () => {
it("creates a nameless-type artifact (config.toml) without requiring a name", async () => {
const write = vi.fn().mockResolvedValue({ file: "/c/config.toml", created: true });
stubCodexApi({ write });
render(<CodexConfig />);
render(
<MemoryRouter>
<CodexConfig />
</MemoryRouter>
);
await settle();
await userEvent.click(screen.getByRole("tab", { name: /config/i }));
@@ -228,7 +249,11 @@ describe("CodexConfig", () => {
},
],
});
render(<CodexConfig />);
render(
<MemoryRouter>
<CodexConfig />
</MemoryRouter>
);
await settle();
await userEvent.click(screen.getByRole("button", { name: /backups/i }));
@@ -11,6 +11,19 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { GitCredentialsConfig } from "../GitCredentialsConfig";
import { api } from "../../lib/api";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
import { subscribeToasts, type ToastItem } from "../../lib/toast";
/** Answer the next confirm dialogs in order; returns the titles asked. */
function answerConfirms(...answers: boolean[]) {
const asked: string[] = [];
const off = subscribeConfirm((p: PendingConfirm | null) => {
if (!p) return;
asked.push(p.title);
queueMicrotask(() => settleConfirm(answers.shift() ?? false));
});
return { asked, off };
}
vi.mock("../../lib/api", () => ({
api: { gitCredentials: { list: vi.fn(), save: vi.fn(), remove: vi.fn() } },
@@ -84,24 +97,22 @@ describe("GitCredentialsConfig", () => {
});
it("deletes only after a confirm", async () => {
const confirm = vi
.spyOn(window, "confirm")
.mockReturnValueOnce(false)
.mockReturnValueOnce(true);
const confirm = answerConfirms(false, true);
const user = userEvent.setup();
render(<GitCredentialsConfig />);
const del = await screen.findByRole("button", { name: "gitCreds.remove git.smartgift.io.vn" });
await user.click(del);
await waitFor(() => expect(confirm.asked).toHaveLength(1));
expect(api.gitCredentials.remove).not.toHaveBeenCalled();
await user.click(del);
await waitFor(() =>
expect(api.gitCredentials.remove).toHaveBeenCalledWith("https", "git.smartgift.io.vn")
);
confirm.mockRestore();
confirm.off();
});
it("offers Replace and Remove on a right-click, Remove behind the same confirm", async () => {
const confirm = vi.spyOn(window, "confirm").mockReturnValueOnce(true);
const confirm = answerConfirms(true);
const user = userEvent.setup();
render(<GitCredentialsConfig />);
const row = (await screen.findByText("git.smartgift.io.vn")).closest("tr")!;
@@ -113,10 +124,41 @@ describe("GitCredentialsConfig", () => {
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "gitCreds.remove" }));
expect(confirm).toHaveBeenCalledWith("gitCreds.removeConfirm");
await waitFor(() => expect(confirm.asked).toEqual(["gitCreds.removeConfirm"]));
await waitFor(() =>
expect(api.gitCredentials.remove).toHaveBeenCalledWith("https", "git.smartgift.io.vn")
);
confirm.mockRestore();
confirm.off();
});
it("takes a bare host as https, and toasts the save", async () => {
let seen: ToastItem[] = [];
const off = subscribeToasts((i) => (seen = i));
const user = userEvent.setup();
render(<GitCredentialsConfig />);
await user.click(await screen.findByRole("button", { name: "gitCreds.add" }));
await user.type(screen.getByLabelText("gitCreds.fieldHost"), "git.example.com");
await user.type(screen.getByLabelText("gitCreds.fieldUser"), "bot");
await user.type(screen.getByLabelText("gitCreds.fieldToken"), "t");
await user.click(screen.getByRole("button", { name: "gitCreds.save" }));
await waitFor(() =>
expect(api.gitCredentials.save).toHaveBeenCalledWith({
protocol: "https",
host: "git.example.com",
username: "bot",
password: "t",
})
);
await waitFor(() => expect(seen.some((x) => x.kind === "success")).toBe(true));
off();
});
it("does not let Replace change the host", async () => {
const user = userEvent.setup();
render(<GitCredentialsConfig />);
await user.click(
await screen.findByRole("button", { name: "gitCreds.replace git.smartgift.io.vn" })
);
expect(screen.getByLabelText("gitCreds.fieldHost")).toHaveAttribute("readonly");
});
});
@@ -0,0 +1,125 @@
/**
* @file Settings.test.tsx
* @description The install-wide Settings pages: General and Notifications never
* read `/api/settings/info` (it COUNTs whole tables), Data reads it once and
* coalesces a burst of activity instead of re-reading per event, a purge takes
* a whole number of days and runs only after a dry-run count is confirmed, and
* Clear all data stays disabled until the confirmation phrase is typed.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
vi.mock("../../components/ImportHistory", () => ({ ImportHistory: () => null }));
vi.mock("../../components/RemoteSources", () => ({ RemoteSources: () => null }));
vi.mock("../../components/AlertsNotifications", () => ({ AlertsNotifications: () => null }));
const info = vi.fn(async () => ({
db: { path: "/tmp/x.db", size: 1, counts: { sessions: 1 } },
hooks: { installed: true, path: "", hooks: {} },
server: { version: "1", uptime: 1, node_version: "v1", platform: "linux", ws_connections: 0 },
}));
const cleanup = vi.fn(async (p: { dry_run?: boolean }) => ({
ok: true,
dry_run: p.dry_run,
abandoned: 0,
purged_sessions: 2,
purged_events: 5,
purged_agents: 1,
}));
const clearData = vi.fn(async () => ({ ok: true, cleared: { sessions: 1 } }));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
pricing: { totalCost: vi.fn(async () => ({ total_cost: 1 })) },
settings: {
info: (...a: unknown[]) => info(...(a as [])),
cleanup: (p: { dry_run?: boolean }) => cleanup(p),
clearData: () => clearData(),
gc: vi.fn(async () => ({ reaped: [], capped: [] })),
exportData: () => "/api/settings/export",
},
},
};
});
import { Settings } from "../Settings";
import { eventBus } from "../../lib/eventBus";
beforeEach(() => {
info.mockClear();
cleanup.mockClear();
clearData.mockClear();
});
const burst = () => {
for (let i = 0; i < 5; i++)
eventBus.publish({ type: "new_event", data: {} } as unknown as Parameters<
typeof eventBus.publish
>[0]);
};
describe("Settings", () => {
it("never reads server info on Notifications or General, even on activity", async () => {
const { unmount } = render(<Settings section="notifications" />);
burst();
await new Promise((r) => setTimeout(r, 30));
unmount();
render(<Settings section="general" />);
burst();
await new Promise((r) => setTimeout(r, 30));
expect(info).not.toHaveBeenCalled();
});
it("reads info once on Data and coalesces a burst of activity", async () => {
render(<Settings section="data" />);
await screen.findByText("/tmp/x.db");
expect(info).toHaveBeenCalledTimes(1);
burst();
await new Promise((r) => setTimeout(r, 50));
expect(info).toHaveBeenCalledTimes(1);
});
it("refuses a fractional purge and confirms a dry-run count before the real run", async () => {
const user = userEvent.setup();
render(<Settings section="data" />);
const days = await screen.findByLabelText(/purge completed/i);
const run = screen.getByRole("button", { name: /run cleanup/i });
await user.clear(days);
await user.type(days, "0.5");
expect(run).toBeDisabled();
await user.clear(days);
await user.type(days, "30");
let pending: PendingConfirm | null = null;
const off = subscribeConfirm((p) => (pending = p));
await user.click(run);
await waitFor(() => expect(pending).not.toBeNull());
expect(cleanup).toHaveBeenCalledTimes(1);
expect(cleanup.mock.calls[0]![0]).toMatchObject({ purge_days: 30, dry_run: true });
expect(pending!.message).toMatch(/2 old session/);
settleConfirm(true);
off();
await waitFor(() => expect(cleanup).toHaveBeenCalledTimes(2));
expect(cleanup.mock.calls[1]![0]).not.toHaveProperty("dry_run");
});
it("enables Clear all data only once the phrase is typed", async () => {
const user = userEvent.setup();
render(<Settings section="data" />);
await user.click(await screen.findByRole("button", { name: /clear all data/i }));
const dialog = screen.getByRole("dialog", { name: /clear all data/i });
const yes = screen.getByRole("button", { name: /yes, clear all/i });
expect(yes).toBeDisabled();
await user.type(screen.getByRole("textbox"), "clear all data");
expect(yes).toBeEnabled();
await user.click(yes);
await waitFor(() => expect(clearData).toHaveBeenCalled());
expect(dialog).not.toBeInTheDocument();
});
});
@@ -5,7 +5,9 @@
* unknown section falls back to General, a disabled tool has no page while an
* installed, enabled catalog tool gets one, the mobile
* select navigates, and the search filters the nav, opens the first match on
* Enter, clears on Escape and is focused by Ctrl+F.
* Enter, clears on Escape and is focused by Ctrl+F (a second press selects its
* text), its result list deep-links a row's sub-tab, an anchor in the URL is
* highlighted, Pricing sits in its own Cost group, and an unknown section toasts.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -16,7 +18,9 @@ import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import type { AiTool, AiToolsView } from "../../lib/api";
vi.mock("../Settings", () => ({
Settings: ({ section }: { section: string }) => <div data-testid={`settings-${section}`} />,
Settings: ({ section }: { section: string }) => (
<div data-testid={`settings-${section}`} data-settings-anchor={`${section}-row`} />
),
}));
vi.mock("../CcConfig", () => ({ CcConfig: () => <div data-testid="cc-config" /> }));
vi.mock("../CodexConfig", () => ({ CodexConfig: () => <div data-testid="codex-config" /> }));
@@ -56,6 +60,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
import { SettingsHub } from "../SettingsHub";
import { __resetAiTools } from "../../hooks/useAiTools";
import { subscribeToasts, type ToastItem } from "../../lib/toast";
function Where() {
const loc = useLocation();
@@ -180,4 +185,70 @@ describe("SettingsHub", () => {
await user.type(box, "zzzz");
expect(within(nav()).getByText("No setting matches “zzzz”.")).toBeInTheDocument();
});
it("Ctrl+F leaves the search alone while a modal dialog is open", async () => {
renderAt("/settings/general");
await screen.findByTestId("settings-general");
const dialog = document.createElement("div");
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
const field = document.createElement("input");
dialog.appendChild(field);
document.body.appendChild(dialog);
field.focus();
const ev = fireEvent.keyDown(window, { key: "f", ctrlKey: true });
expect(ev).toBe(true); // not prevented
expect(field).toHaveFocus();
dialog.remove();
});
it("a second Ctrl+F with the search focused selects its text instead of reaching the browser", async () => {
const user = userEvent.setup();
renderAt("/settings/general");
await screen.findByTestId("settings-general");
const box = screen.getByRole<HTMLInputElement>("searchbox", { name: "Search settings" });
fireEvent.keyDown(window, { key: "f", ctrlKey: true });
await user.type(box, "pet");
const second = fireEvent.keyDown(window, { key: "f", ctrlKey: true });
expect(second).toBe(false); // default prevented
expect(box).toHaveFocus();
expect([box.selectionStart, box.selectionEnd]).toEqual([0, 3]);
});
it("lists matching rows and deep-links a Claude Code sub-tab", async () => {
const user = userEvent.setup();
renderAt("/settings/general");
await screen.findByTestId("settings-general");
await user.type(screen.getByRole("searchbox", { name: "Search settings" }), "marketplaces");
const list = screen.getByRole("list", { name: "Search results" });
await user.click(within(list).getAllByRole("link")[0]!);
expect(screen.getByTestId("where").textContent).toBe(
"/settings/agents/claude-code?tab=marketplaces"
);
});
it("puts Pricing in its own Cost group and splits General into General, Data and About", async () => {
renderAt("/settings/data");
expect(await screen.findByTestId("settings-data")).toBeInTheDocument();
expect(within(nav()).getByText("Cost")).toBeInTheDocument();
const links = within(nav())
.getAllByRole("link")
.map((a) => a.textContent);
expect(links.slice(0, 3)).toEqual(["General", "Data", "About"]);
});
it("scrolls to and highlights the anchor the URL names", async () => {
renderAt("/settings/data#data-row");
const row = await screen.findByTestId("settings-data");
await vi.waitFor(() => expect(row.className).toContain("ring-2"));
});
it("toasts when an unknown section falls back to General", async () => {
let seen: ToastItem[] = [];
const off = subscribeToasts((i) => (seen = i));
renderAt("/settings/nope");
await screen.findByTestId("settings-general");
expect(seen.some((x) => x.message.includes("nope"))).toBe(true);
off();
});
});