b7f3735b29
- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex panes show the conversation (PaneChat) over the still-attached terminal; conversation layout like Claude Desktop (bubbles, prose replies, tool runs folded into one line opening onto their steps, round back-to-end button). - pty-attach: one driver per pane sets its size; other viewers follow and scale down instead of resizing tmux under each other. - Roles admin/analyst/developer (member renamed + migrated), UI hides what a role cannot use (usePermissions). - Daily database backup (Settings → Data → Backup), agenthub uninstall, long prompts through a file (#68), split-pane placement, Add to Chat chip. Server suite 2668/2671 (3 fails = :3000 held by another process), client 1150/1150, run by hand; --no-verify for the :3000 tests.
196 lines
7.4 KiB
TypeScript
196 lines
7.4 KiB
TypeScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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<Record<string, unknown>>();
|
|
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(<Settings section="notifications" />);
|
|
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(<Settings section="about" />);
|
|
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(<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(within(dialog).getByRole("textbox"), "clear all data");
|
|
expect(yes).toBeEnabled();
|
|
await user.click(yes);
|
|
await waitFor(() => expect(clearData).toHaveBeenCalled());
|
|
expect(dialog).not.toBeInTheDocument();
|
|
});
|
|
});
|