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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user