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