/** * @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ĩ */ import { describe, it, expect, vi, afterEach } from "vitest"; import { render, screen, act } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; // 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: [] }), }; }); vi.mock("../../lib/api", async (importOriginal) => { const actual = await importOriginal>(); return { ...actual, api: { ...(actual.api as Record), codexConfig: codexConfigMock, }, }; }); 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; }) { 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("offers create on the authored surfaces and withholds it on the CLI-owned ones", async () => { stubCodexApi({ skills: [], mcp: { servers: [] } }); render(); 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(); 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(); 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(); 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(); await settle(); await userEvent.click(screen.getByRole("button", { name: /backups/i })); await settle(); expect(screen.getByText("default.2026-09-08.bak")).toBeInTheDocument(); }); });