Files
AgentHub/client/src/pages/__tests__/Administration.test.tsx
T
nntrivi2001 685eb748e8 feat: account pool per tool with groups and a Cursor pool; update runs in the background
- The update dialog can be closed while an update applies: it keeps running,
  the page still reloads itself when the new server is up, and a failure
  re-opens the dialog with its error.
- Account pool card: one tab per AI tool (Claude Code, Codex, Cursor CLI,
  Oh My Pi, and every other installed tool, which says it has no pool),
  accounts as cards with 5-hour and weekly quota shown as what is LEFT plus
  their reset times, and the saved profiles of Settings → AI tools →
  Profiles as cards with "Share to pool".
- Cursor account pool (server/lib/cursor-pool.js): Cursor API keys handed to
  a cursor-agent pane only through tmux -e CURSOR_API_KEY;
  `agenthub accounts add <name> --tool cursor`.
- Pool groups (server/lib/pool-groups.js, /api/accounts/groups): a group's
  members run only on its accounts of each tool, a user in no group on the
  accounts no group lists, a pin still wins. account_pins loses its tool
  CHECK (rebuilt, pins kept); three new tables.
- Oh My Pi login sharing can be switched from its pool tab too.
2026-10-22 17:02:08 +07:00

180 lines
5.5 KiB
TypeScript

/**
* @file Administration.test.tsx
* @description The Administration page (`/admin`): local mode and a developer
* are sent home; staff land on the Users tab; the Server tab shows the
* machine's hardware, every disk, and each user's CPU/memory/home share from
* `GET /api/host`, with a user's busiest processes on click.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import type { AuthState, HostStats } from "../../lib/api";
let authState: AuthState = { mode: "local" };
vi.mock("../../components/AuthGate", () => ({
useAuth: () => ({ state: authState, logout: async () => {} }),
}));
const GB = 1024 ** 3;
const stats: HostStats = {
at: "2026-10-15T00:00:00Z",
hostname: "box",
os: { platform: "linux", release: "7.0.0", distro: "Ubuntu 26.04", arch: "x64" },
uptime: 90000,
cpu: { model: "i5-7500", cores: 4, load: [1, 0.5, 0.25] },
memory: { total: 16 * GB, available: 8 * GB, used: 8 * GB, swapTotal: 4 * GB, swapUsed: 0 },
disks: [
{
device: "/dev/sdb2",
mount: "/",
type: "ext4",
kind: "SSD",
total: 200 * GB,
used: 50 * GB,
free: 150 * GB,
},
{
device: "/dev/sda1",
mount: "/data",
type: "ext4",
kind: "HDD",
total: 500 * GB,
used: 0,
free: 500 * GB,
},
],
homesMount: "/",
gpus: [],
usage: {
cpu: 40,
sampleMs: 1000,
groups: [
{
key: "user:2",
kind: "user",
userId: 2,
username: "mia",
cpu: 25,
memory: 2 * GB,
memoryPercent: 12.5,
processes: 3,
top: [{ pid: 77, name: "claude", cpu: 20, memory: GB }],
},
{ key: "host", kind: "host", cpu: 5, memory: GB, memoryPercent: 6.3, processes: 90, top: [] },
],
},
};
const api = vi.hoisted(() => ({
host: { stats: vi.fn() },
users: {
list: vi.fn(),
audit: vi.fn(),
storage: vi.fn(),
tmux: vi.fn(async () => ({ available: false, users: [] })),
avatarUrl: () => null,
},
}));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
...api,
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
accountPool: {
list: vi.fn(async () => ({ accounts: [], codex: [], blockPercent: 90 })),
profiles: vi.fn(async () => ({ profiles: [] })),
},
aiTools: {
list: vi.fn(async () => ({ tools: [], default: null })),
ompSharing: vi.fn(async () => ({ available: false })),
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
},
},
};
});
import { Administration } from "../Administration";
const renderAt = (url: string) =>
render(
<MemoryRouter initialEntries={[url]}>
<Routes>
<Route path="/admin" element={<Administration />} />
<Route path="/" element={<div data-testid="home" />} />
</Routes>
</MemoryRouter>
);
beforeEach(() => {
api.host.stats.mockResolvedValue(stats);
api.users.list.mockResolvedValue({
users: [
{
id: 1,
username: "boss",
role: "admin",
disabled: false,
workspaces: 0,
sessions: 0,
last7d: { cost: 0, tokens: 0 },
last30d: { cost: 0, tokens: 0 },
},
{
id: 2,
username: "mia",
role: "developer",
displayName: "Mia",
disabled: false,
workspaces: 1,
sessions: 2,
last7d: { cost: 0, tokens: 0 },
last30d: { cost: 0, tokens: 0 },
},
],
});
api.users.audit.mockResolvedValue({ entries: [] });
api.users.storage.mockResolvedValue({
storage: { 2: { bytes: 20 * GB, files: 10, partial: false, measuredAt: "x" } },
measuring: false,
});
});
describe("Administration", () => {
it("sends local mode and a developer home", async () => {
authState = { mode: "local" };
renderAt("/admin");
expect(await screen.findByTestId("home")).toBeTruthy();
authState = { mode: "server", user: { id: 2, username: "mia", role: "developer" } };
renderAt("/admin");
expect((await screen.findAllByTestId("home")).length).toBeGreaterThan(0);
});
it("opens on the Users tab", async () => {
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
renderAt("/admin");
expect(await screen.findByTestId("users-table")).toBeTruthy();
expect(screen.getByRole("tab", { name: /users/i }).getAttribute("aria-selected")).toBe("true");
});
it("shows hardware and each user's share on the Server tab", async () => {
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
renderAt("/admin?tab=server");
const table = await screen.findByTestId("server-usage-table");
expect(await screen.findByText("box")).toBeTruthy();
expect(screen.getByTestId("server-disks-table").textContent).toContain("HDD");
const mia = await within(table).findByText("Mia");
const row = mia.closest("tr")!;
expect(row.textContent).toContain("25%");
expect(row.textContent).toContain("(13%)");
// 20 GB home on the 200 GB homes disk.
expect(row.textContent).toContain("10%");
expect(within(table).queryByText("claude")).toBeNull();
fireEvent.click(mia.closest("button")!);
expect(within(table).getByText("claude")).toBeTruthy();
});
});