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.
265 lines
9.0 KiB
TypeScript
265 lines
9.0 KiB
TypeScript
/**
|
|
* @file CodexConfig.test.tsx
|
|
* @description Covers the two load-bearing behaviors of the Codex config
|
|
* panel: create/edit/delete is offered on the authored surfaces (skills,
|
|
* agents, rules, memory, config) and withheld on the CLI-owned ones (mcp,
|
|
* plugins); and saves go through `api.codexConfig`, not the Claude client.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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.
|
|
const codexConfigMock = vi.hoisted(() => {
|
|
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
|
return {
|
|
overview: r({
|
|
roots: { codexHome: "/home/.codex", projectCodexDir: "/repo/.codex", projectRoot: "/repo" },
|
|
counts: {
|
|
skills: { user: 0, project: 0 },
|
|
agents: { user: 0, project: 0 },
|
|
rules: { user: 0, project: 0 },
|
|
memory: { user: 0, project: 0 },
|
|
config: { user: 0, project: 0 },
|
|
},
|
|
}),
|
|
skills: r({ skills: [] }),
|
|
agents: r({ agents: [] }),
|
|
rules: r({ rules: [] }),
|
|
memory: r({ memory: [] }),
|
|
config: r({ config: [] }),
|
|
file: r({ ok: true, file: "", size: 0, mtime: 0, truncated: false, text: "" }),
|
|
write: r({ file: "/f", created: true }),
|
|
remove: r({ file: "/f" }),
|
|
mcp: r({ mcp: [] }),
|
|
plugins: r({ plugins: [] }),
|
|
marketplaces: r({ marketplaces: [] }),
|
|
mcpAdd: r({ ok: true, stdout: "", stderr: "" }),
|
|
mcpRemove: r({ ok: true, stdout: "", stderr: "" }),
|
|
pluginAction: r({ ok: true, stdout: "", stderr: "" }),
|
|
marketplaceAdd: r({ ok: true, stdout: "", stderr: "" }),
|
|
marketplaceAction: r({ ok: true, stdout: "", stderr: "" }),
|
|
backups: r({ items: [] }),
|
|
};
|
|
});
|
|
|
|
const codexHomeMock = vi.hoisted(() => ({
|
|
get: vi.fn(),
|
|
set: vi.fn(),
|
|
}));
|
|
|
|
const codexHooksMock = vi.hoisted(() => ({
|
|
get: vi.fn(() =>
|
|
Promise.resolve({
|
|
supported: true,
|
|
readable: true,
|
|
path: "/home/.codex/hooks.json",
|
|
script: "/home/.codex/agenthub-hook.sh",
|
|
installed: false,
|
|
trusted: false,
|
|
events: {},
|
|
})
|
|
),
|
|
install: vi.fn(),
|
|
remove: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../../lib/api", async (importOriginal) => {
|
|
const actual = await importOriginal<Record<string, unknown>>();
|
|
return {
|
|
...actual,
|
|
api: {
|
|
...(actual.api as Record<string, unknown>),
|
|
codexConfig: codexConfigMock,
|
|
settings: {
|
|
...((actual.api as { settings: Record<string, unknown> }).settings ?? {}),
|
|
codexHome: codexHomeMock,
|
|
codexHooks: codexHooksMock,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock("../../lib/eventBus", () => ({
|
|
eventBus: {
|
|
subscribe: () => () => {},
|
|
publish: () => {},
|
|
onConnection: () => () => {},
|
|
connected: true,
|
|
setConnected: () => {},
|
|
},
|
|
}));
|
|
|
|
import { CodexConfig } from "../CodexConfig";
|
|
|
|
/** Reset the mock to its defaults, then apply the given overrides (each
|
|
* value is wrapped in a resolved-promise mock unless already a mock fn). */
|
|
function stubCodexApi(overrides: {
|
|
skills?: unknown[];
|
|
rules?: { scope: "user" | "project"; name: string; file: string; size: number }[];
|
|
mcp?: { servers?: unknown[] };
|
|
write?: ReturnType<typeof vi.fn>;
|
|
}) {
|
|
codexConfigMock.skills.mockResolvedValue({ skills: overrides.skills ?? [] });
|
|
codexConfigMock.rules.mockResolvedValue({ rules: overrides.rules ?? [] });
|
|
codexConfigMock.mcp.mockResolvedValue({ mcp: overrides.mcp?.servers ?? [] });
|
|
if (overrides.write) codexConfigMock.write = overrides.write as never;
|
|
}
|
|
|
|
async function settle() {
|
|
await act(async () => {
|
|
await new Promise((res) => setTimeout(res, 0));
|
|
await new Promise((res) => setTimeout(res, 0));
|
|
});
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
codexConfigMock.write = vi.fn().mockResolvedValue({ file: "/f", created: true }) as never;
|
|
codexConfigMock.remove = vi.fn().mockResolvedValue({ file: "/f" }) as never;
|
|
});
|
|
|
|
describe("CodexConfig", () => {
|
|
it("edits CODEX_HOME on the Overview", async () => {
|
|
stubCodexApi({});
|
|
codexHomeMock.get.mockResolvedValue({ codex_home: "/home/.codex", env_owned: false });
|
|
codexHomeMock.set.mockResolvedValue({
|
|
ok: true,
|
|
codex_home: "/work/.codex",
|
|
env_owned: false,
|
|
});
|
|
render(
|
|
<MemoryRouter>
|
|
<CodexConfig />
|
|
</MemoryRouter>
|
|
);
|
|
await settle();
|
|
const overviewCalls = codexConfigMock.overview.mock.calls.length;
|
|
|
|
// No AgentHub sub-tab: its cards sit under the Overview, the page's first tab.
|
|
expect(screen.queryByRole("tab", { name: /agenthub/i })).toBeNull();
|
|
const input = await screen.findByDisplayValue("/home/.codex");
|
|
expect(await screen.findByText("Codex hooks")).toBeInTheDocument();
|
|
expect(screen.queryByPlaceholderText(/search/i)).not.toBeInTheDocument();
|
|
await userEvent.clear(input);
|
|
await userEvent.type(input, "/work/.codex");
|
|
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
|
|
expect(codexHomeMock.set).toHaveBeenCalledWith("/work/.codex");
|
|
// Every list lives under CODEX_HOME, so a new one re-reads them.
|
|
expect(codexConfigMock.overview.mock.calls.length).toBeGreaterThan(overviewCalls);
|
|
});
|
|
|
|
it("offers create on the authored surfaces and withholds it on the CLI-owned ones", async () => {
|
|
stubCodexApi({ skills: [], mcp: { servers: [] } });
|
|
render(
|
|
<MemoryRouter>
|
|
<CodexConfig />
|
|
</MemoryRouter>
|
|
);
|
|
await settle();
|
|
|
|
await userEvent.click(screen.getByRole("tab", { name: /skills/i }));
|
|
expect(screen.getByRole("button", { name: /new/i })).toBeInTheDocument();
|
|
|
|
await userEvent.click(screen.getByRole("tab", { name: /mcp/i }));
|
|
expect(screen.queryByRole("button", { name: /new/i })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("writes an edited rule back through the codex API, not the Claude one", async () => {
|
|
const write = vi.fn().mockResolvedValue({ file: "/r/default.rules", created: false });
|
|
stubCodexApi({
|
|
rules: [{ scope: "user", name: "default", file: "/r/default.rules", size: 3 }],
|
|
write,
|
|
});
|
|
render(
|
|
<MemoryRouter>
|
|
<CodexConfig />
|
|
</MemoryRouter>
|
|
);
|
|
await settle();
|
|
|
|
await userEvent.click(screen.getByRole("tab", { name: /rules/i }));
|
|
await userEvent.click(screen.getByRole("button", { name: /edit/i }));
|
|
await settle();
|
|
await userEvent.click(screen.getByRole("button", { name: /save/i }));
|
|
|
|
expect(write).toHaveBeenCalledWith(expect.objectContaining({ type: "rules", name: "default" }));
|
|
});
|
|
|
|
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(
|
|
<MemoryRouter>
|
|
<CodexConfig />
|
|
</MemoryRouter>
|
|
);
|
|
await settle();
|
|
|
|
// No page-wide error banner - the CLI-backed rejection is isolated.
|
|
expect(screen.queryByText(/failed to load/i)).not.toBeInTheDocument();
|
|
|
|
// Pure file reads (skills) still rendered their data.
|
|
await userEvent.click(screen.getByRole("tab", { name: /skills/i }));
|
|
expect(screen.getByText("my-skill")).toBeInTheDocument();
|
|
|
|
// The CLI-owned tab degrades gracefully instead of spinning forever.
|
|
await userEvent.click(screen.getByRole("tab", { name: /mcp/i }));
|
|
expect(screen.getByText(/codex cli isn't available/i)).toBeInTheDocument();
|
|
});
|
|
|
|
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(
|
|
<MemoryRouter>
|
|
<CodexConfig />
|
|
</MemoryRouter>
|
|
);
|
|
await settle();
|
|
|
|
await userEvent.click(screen.getByRole("tab", { name: /config/i }));
|
|
const newButtons = screen.getAllByRole("button", { name: /new/i });
|
|
await userEvent.click(newButtons[0]!);
|
|
await settle();
|
|
|
|
// No name field, no client-side "Name" validation error blocking save.
|
|
expect(screen.queryByPlaceholderText(/name/i)).not.toBeInTheDocument();
|
|
await userEvent.click(screen.getByRole("button", { name: /save/i }));
|
|
|
|
expect(write).toHaveBeenCalledWith(
|
|
expect.objectContaining({ type: "config", name: undefined })
|
|
);
|
|
});
|
|
|
|
it("opens the backups modal and lists what the codex API returns", async () => {
|
|
codexConfigMock.backups.mockResolvedValueOnce({
|
|
items: [
|
|
{
|
|
type: "rules",
|
|
name: "default.2026-09-08.bak",
|
|
file: "/r/default.bak",
|
|
mtime: 0,
|
|
size: 3,
|
|
},
|
|
],
|
|
});
|
|
render(
|
|
<MemoryRouter>
|
|
<CodexConfig />
|
|
</MemoryRouter>
|
|
);
|
|
await settle();
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: /backups/i }));
|
|
await settle();
|
|
|
|
expect(screen.getByText("default.2026-09-08.bak")).toBeInTheDocument();
|
|
});
|
|
});
|