/** * @file Settings.test.tsx * @description The install-wide Settings pages: System shows the server's * runtime, install and database facts; 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ĩ */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { render, screen, waitFor, within } 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>(); return { ...actual, api: { pricing: { totalCost: vi.fn(async () => ({ total_cost: 1 })) }, updates: { status: vi.fn(async () => ({ git_repo: false, update_available: true, install_kind: "plugin", repo_root: "/home/u/.claude/plugins/cache/agenthub/9.9.8", local_version: "9.9.8", latest_release: "v9.9.9", })), }, settings: { info: (...a: unknown[]) => info(...(a as [])), cleanup: (p: { dry_run?: boolean }) => cleanup(p), autostart: { get: vi.fn(async () => ({ available: false })) }, tray: { get: vi.fn(async () => ({ available: false })) }, clearData: () => clearData(), gc: vi.fn(async () => ({ reaped: [], capped: [] })), exportData: () => "/api/settings/export", pushToken: { get: vi.fn(async () => ({ enabled: false, env_owned: false })) }, privacy: { get: vi.fn(async () => ({ redact_secrets: false, ignore_cwd: [], env_owned: { redact_secrets: false, ignore_cwd: false }, })), }, backup: { get: vi.fn(async () => ({ dir: "", hour: 2, keep: 7, running: false, last: null, files: [], dir_error: null, env_owned: { dir: false, hour: false, keep: false }, })), }, }, }, }; }); 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, even on activity", async () => { render(); burst(); await new Promise((r) => setTimeout(r, 30)); expect(info).not.toHaveBeenCalled(); }); it("shows the server's runtime, install and database facts on System", async () => { info.mockImplementationOnce( async () => ({ db: { path: "/data/dashboard.db", size: 5 * 1024 * 1024, counts: {}, pragmas: { journal_mode: "wal" }, load_stats: { m5: 3, m15: 9, h1: 40 }, }, hooks: { installed: true, path: "", hooks: {} }, server: { version: "1", uptime: 3700, node_version: "v26.0.0", platform: "linux", arch: "x64", cpus: 8, cpu_load: [0.5, 0.25, 0.125], total_mem: 16 * 1024 ** 3, free_mem: 4 * 1024 ** 3, memory: { rss: 200 * 1024 ** 2, heapUsed: 50 * 1024 ** 2, heapLimit: 4 * 1024 ** 3 }, ws_connections: 2, }, }) as never ); render(); expect(await screen.findByText("/data/dashboard.db")).toBeInTheDocument(); expect(screen.getByText("1h 1m")).toBeInTheDocument(); expect(screen.getByText(/linux · x64 · Node.js v26.0.0/)).toBeInTheDocument(); expect(screen.getByText("8 cores · load 0.50 / 0.25 / 0.13")).toBeInTheDocument(); expect(screen.getByText("200.0 MB RSS · heap 50.0 MB of 4.0 GB")).toBeInTheDocument(); expect(screen.getByText("12.0 GB used of 16.0 GB")).toBeInTheDocument(); expect(screen.getByText("wal")).toBeInTheDocument(); expect(screen.getByText("3 in 5 min · 9 in 15 min · 40 in 1 h")).toBeInTheDocument(); expect(await screen.findByText("Claude Code plugin")).toBeInTheDocument(); expect(screen.getByText("v9.9.9")).toBeInTheDocument(); expect(screen.getByText("Update available")).toBeInTheDocument(); }); it("reads info once on Data and coalesces a burst of activity", async () => { render(); 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(); 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(); 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(within(dialog).getByRole("textbox"), "clear all data"); expect(yes).toBeEnabled(); await user.click(yes); await waitFor(() => expect(clearData).toHaveBeenCalled()); expect(dialog).not.toBeInTheDocument(); }); });