Files
AgentHub/client/src/pages/__tests__/screens.snapshot.test.tsx
T
nntrivi2001 2942ced3ec style(config): give Codex the same tab bar as Claude Code, drop the Cursor tab
The Codex tab strip wrapped onto a second row while Claude Code's stayed
one scrollable row, so two panels that are meant to be twins read as
different components. Both now render the same ConfigTabBar — the
scroller lifted out of CcConfig, with the fade edges and chevrons that
only appear when there is somewhere to scroll. The deferral note in
CodexConfig that predicted this is gone with it.

The bar also carries role="tablist"/"tab" and aria-selected now: Codex
had them, Claude Code did not, and one of the two had to win.

Cursor's placeholder tab goes away with the machinery that served only
it — the optional Panel, the "soon" badge, the coming-soon card, and
their four i18n keys.

Element.scrollBy and friends move to test-setup: jsdom has none of them,
and the tab bar now calls them from both pages.
2026-09-09 14:44:25 +07:00

642 lines
19 KiB
TypeScript

/**
* @file screens.snapshot.test.tsx
* @description Render snapshot tests for every routed screen. Each page is
* rendered inside a MemoryRouter with the API layer mocked to a deterministic
* loaded-empty state (empty collections + zeroed scalars) so snapshots capture
* real structure / layout / i18n without noisy chart DOM or live data. The
* system clock and timezone are pinned so any relative/absolute timestamps are
* stable across machines and CI.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
// Pin timezone before anything reads it, so date formatting is machine-stable
// (referenced via globalThis so the browser-targeted tsconfig doesn't need node types).
const nodeProcess = (
globalThis as unknown as { process?: { env: Record<string, string | undefined> } }
).process;
if (nodeProcess) nodeProcess.env.TZ = "UTC";
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from "vitest";
import type { ReactNode } from "react";
import { render, act, screen, fireEvent } from "@testing-library/react";
import { MemoryRouter, Routes, Route } from "react-router-dom";
import i18n from "i18next";
// ── Mock the API layer with deterministic, crash-safe empty fixtures ──────────
// Keep the module's other named exports (RUN_EFFORT_CHOICES, etc.) real and
// override only `api`.
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
// One installed plugin that actually contributes something, so the Claude Code
// tab renders PluginCard's contribution chips instead of an empty list.
const demoPlugin = {
key: "agenthub@sg",
name: "agenthub",
marketplace: "sg",
scope: "user",
version: "1.4.6",
installPath: "/home/test/.claude/plugins/agenthub",
installedAt: "2026-05-01T12:00:00.000Z",
lastUpdated: "2026-06-01T09:30:00.000Z",
gitCommitSha: "a1b2c3d",
installPathExists: true,
enabled: true,
contributes: {
skills: 2,
agents: 1,
commands: 1,
outputStyles: 0,
hooks: 3,
items: {
skills: [
{
name: "ship-feature",
description: "Implement a feature safely end-to-end.",
file: "/home/test/.claude/plugins/agenthub/skills/ship-feature/SKILL.md",
},
{
name: "file-headers",
description: "Authorship header policy.",
file: "/home/test/.claude/plugins/agenthub/skills/file-headers/SKILL.md",
},
],
agents: [
{
name: "qc-local",
description: "Local browser QC.",
file: "/home/test/.claude/plugins/agenthub/agents/qc-local.md",
},
],
commands: [
{
name: "agenthub-doctor",
description: null,
file: "/home/test/.claude/plugins/agenthub/plugins/agenthub/commands/agenthub-doctor.md",
},
],
outputStyles: [],
},
pluginJson: { name: "agenthub", description: "The dashboard as a plugin.", version: "1.4.6" },
},
};
const items = { items: [] };
const cost = {
total_cost: 0,
breakdown: [],
daily_costs: [],
feature_costs: {},
unpriced_models: [],
};
const emptyWorkflow = {
stats: {
totalSessions: 0,
totalAgents: 0,
totalSubagents: 0,
avgSubagents: 0,
successRate: 0,
avgDepth: 0,
avgDurationSec: 0,
totalCompactions: 0,
avgCompactions: 0,
topFlow: null,
},
orchestration: {
sessionCount: 0,
mainCount: 0,
subagentTypes: [],
edges: [],
outcomes: [],
compactions: { total: 0, sessions: 0 },
},
toolFlow: { transitions: [], toolCounts: [] },
effectiveness: [],
patterns: { patterns: [], soloSessionCount: 0, soloPercentage: 0 },
modelDelegation: { mainModels: [], subagentModels: [], tokensByModel: [] },
errorPropagation: {
byDepth: [],
byType: [],
eventErrors: [],
sessionsWithErrors: 0,
totalSessions: 0,
errorRate: 0,
},
concurrency: { aggregateLanes: [] },
complexity: [],
compaction: {
totalCompactions: 0,
tokensRecovered: 0,
perSession: [],
sessionsWithCompactions: 0,
totalSessions: 0,
},
cooccurrence: [],
};
const analytics = {
tokens: { total_input: 0, total_output: 0, total_cache_read: 0, total_cache_write: 0 },
tool_usage: [],
daily_events: [],
daily_sessions: [],
agent_types: [],
event_types: [],
avg_events_per_session: 0,
total_subagents: 0,
overview: {
total_sessions: 0,
active_sessions: 0,
active_agents: 0,
total_agents: 0,
total_events: 0,
},
agents_by_status: {},
sessions_by_status: {},
};
const settingsInfo = {
db: {
path: "/tmp/test.db",
size: 0,
counts: {},
pragmas: {
journal_mode: "wal",
synchronous: 1,
auto_vacuum: 0,
encoding: "UTF-8",
foreign_keys: 1,
busy_timeout: 5000,
},
load_stats: { m5: 0, m15: 0, h1: 0 },
},
hooks: { installed: true, path: "/tmp/settings.json", hooks: {} },
server: {
uptime: 0,
node_version: "v22.0.0",
platform: "linux",
ws_connections: 0,
memory: { rss: 0, heapTotal: 0, heapUsed: 0, external: 0 },
cpu_load: [0, 0, 0],
arch: "x64",
total_mem: 0,
free_mem: 0,
cpus: 1,
},
transcript_cache: { size: 0, maxSize: 100, hits: 0, misses: 0, keys: [] },
};
const session = {
id: "sess-1",
name: "Test Session",
status: "active",
cwd: "/test",
model: "claude-opus-4-6",
started_at: "2026-06-10T12:00:00.000Z",
ended_at: null,
metadata: null,
};
const stats = {
total_sessions: 0,
active_sessions: 0,
active_agents: 0,
total_agents: 0,
total_events: 0,
events_today: 0,
ws_connections: 0,
agents_by_status: {},
sessions_by_status: {},
};
const sampleWorkflowRun = {
run_id: "wf_sample1",
session_id: "sess-1",
task_id: "task-1",
name: "review-changes",
status: "completed",
default_model: "claude-opus-4-6",
started_at: "2026-06-10T12:30:00.000Z",
ended_at: "2026-06-10T12:35:00.000Z",
duration_ms: 300000,
agent_count: 2,
total_tokens: 48000,
total_tool_calls: 9,
phases: [{ title: "Review" }, { title: "Verify" }],
progress: [
{ type: "workflow_phase", index: 1, title: "Review" },
{ type: "workflow_phase", index: 2, title: "Verify" },
{
type: "workflow_agent",
agentId: "a1",
agentType: "reviewer",
state: "done",
label: "review:bugs",
phaseTitle: "Review",
tokens: 22000,
toolCalls: 5,
durationMs: 120000,
lastToolName: "Read",
resultPreview: "Found 3 issues",
},
{
type: "workflow_agent",
agentId: "a2",
agentType: "verifier",
state: "done",
label: "verify:bugs",
phaseTitle: "Verify",
tokens: 26000,
toolCalls: 4,
durationMs: 180000,
lastToolName: "Bash",
resultPreview: "All confirmed",
},
],
script_path: null,
journal_path: "/x/wf_sample1.json",
source: "journal",
created_at: "2026-06-10T12:30:00.000Z",
updated_at: "2026-06-10T12:35:00.000Z",
};
return {
...actual,
api: {
stats: { get: r(stats), facets: r({ cwds: [] }) },
sessions: {
list: r({ sessions: [], total: 0, limit: 50, offset: 0 }),
facets: r({ cwds: [], sources: ["local"] }),
get: r({ session, agents: [], events: [], workflows: [sampleWorkflowRun] }),
stats: r({
session_id: "sess-1",
total_events: 0,
events_by_type: [],
tools_used: [],
error_count: 0,
first_event_at: null,
last_event_at: null,
agents: { total: 0, main: 0, subagent: 0, compaction: 0, by_status: {} },
subagent_types: [],
tokens: {
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
},
}),
transcripts: r({ transcripts: [] }),
transcript: r({ messages: [], session_id: "sess-1" }),
},
agents: { list: r({ agents: [] }) },
remoteSources: {
list: r({ sources: [] }),
create: r({ source: {} }),
update: r({ source: {} }),
remove: r({ ok: true, purged: 0 }),
test: r({ ok: true, message: "" }),
sync: r({ ok: true }),
},
events: {
list: r({ events: [], total: 0, limit: 50, offset: 0 }),
facets: r({ event_types: [], tool_names: [] }),
},
analytics: { get: r(analytics) },
workflows: {
get: r(emptyWorkflow),
session: r({}),
skillUsage: r({ skills: [], plugins: [] }),
runs: r({
runs: [sampleWorkflowRun],
total: 1,
counts: { completed: 1 },
limit: 200,
offset: 0,
}),
run: r({ workflow: sampleWorkflowRun, agents: [], events: [] }),
},
pricing: {
list: r({ pricing: [] }),
upsert: r({ pricing: {} }),
delete: r({ ok: true }),
totalCost: r(cost),
sessionCost: r(cost),
},
settings: {
info: r(settingsInfo),
claudeHome: {
get: r({ claude_home: "/home/test/.claude" }),
set: r({ ok: true, claude_home: "/home/test/.claude" }),
},
clearData: r({ ok: true, cleared: {} }),
reimport: r({ ok: true, imported: 0, skipped: 0, errors: 0 }),
reinstallHooks: r({ ok: true, hooks: { installed: true, hooks: {} } }),
resetPricing: r({ ok: true, pricing: [] }),
exportData: () => "/api/settings/export",
cleanup: r({
ok: true,
abandoned: 0,
purged_sessions: 0,
purged_events: 0,
purged_agents: 0,
}),
},
import: {
guide: r({
platform: "linux",
default_projects_dir: "/home/test/.claude/projects",
default_projects_dir_display: "~/.claude/projects",
default_projects_dir_exists: true,
default_projects_dir_stats: { projects: 0, jsonl_files: 0 },
archive_command: "tar",
supported_extensions: [".jsonl"],
max_upload_bytes: 1000000,
max_upload_files: 10,
steps: [],
}),
rescan: r({}),
scanPath: r({}),
},
ccConfig: {
overview: r({
roots: {
claudeHome: "/home/test/.claude",
projectClaudeDir: "/test/.claude",
projectRoot: "/test",
claudeJson: "/home/test/.claude.json",
},
counts: {
skills: { user: 0, project: 0 },
agents: { user: 0, project: 0 },
commands: { user: 0, project: 0 },
outputStyles: { user: 0, project: 0 },
plugins: 0,
pluginsEnabled: 0,
pluginsDisabled: 0,
marketplaces: 0,
keybindings: 0,
mcpServers: { user: 0, project: 0 },
hooks: {},
memory: 0,
settingsFiles: 0,
},
}),
skills: r(items),
agents: r(items),
commands: r(items),
outputStyles: r(items),
plugins: r({ manifestPath: "", manifestExists: false, plugins: [demoPlugin] }),
mcp: r({ servers: [], items: [] }),
hooks: r(items),
settings: r(items),
memory: r(items),
file: r({
scope: "user",
name: "x",
path: "/x",
size: 0,
mtime: 0,
truncated: false,
frontmatter: {},
preview: "",
content: "",
}),
write: r({ ok: true }),
delete: r({ ok: true }),
marketplaces: r({ marketplaces: [], items: [] }),
keybindings: r({ items: [], bindings: [] }),
statusline: r({ configured: false }),
hookScripts: r({ items: [], scripts: [] }),
backups: r({ items: [] }),
},
run: {
list: r({ runs: [], items: [] }),
history: r({ items: [] }),
binary: r({ found: true, path: "/usr/bin/claude" }),
cwds: r({ items: [] }),
files: r({ items: [] }),
start: r({ id: "run-1", status: "running" }),
get: r({ id: "run-1", status: "running", messages: [], envelopes: [] }),
send: r({ messageId: "m-1" }),
kill: r({ ok: true }),
},
alerts: {
list: r({ alerts: [], total: 0, unacked: 0, limit: 50, offset: 0 }),
ack: r({ alert: {} }),
ackAll: r({ ok: true, acknowledged: 0 }),
rules: {
list: r({ rules: [] }),
create: r({ rule: {} }),
update: r({ rule: {} }),
remove: r({ ok: true }),
},
},
webhooks: {
list: r({ targets: [] }),
providers: r({ providers: [] }),
create: r({ target: {} }),
update: r({ target: {} }),
remove: r({ ok: true }),
test: r({ ok: true, status: 200, attempts: 1, error: null }),
deliveries: r({ deliveries: [], limit: 20, offset: 0 }),
},
lanes: {
list: r({ lanes: [], counts: { total: 0, running: 0, needs_you: 0, dead: 0 } }),
get: r({ lane: {} }),
create: r({ lane: {} }),
update: r({ lane: {} }),
stage: r({ lane: {} }),
pipelines: r({ pipelines: [] }),
pipelineStats: r({ pipelines: [], lanes: [] }),
// A realistic shape: `{}` would crash blockingReason on `blocked` if any
// screen ever opened the destructive modal.
preflight: r({
action: "reset",
lane: 1,
kind: "managed",
branch: "feat/demo",
head: "abc1234",
dirty: 0,
untracked: 0,
unpushed: 0,
blocked: [],
warnings: [],
}),
action: r({ ok: true }),
},
updates: { check: r({ behind: 0, ahead: 0, current: "", upstream: "" }), status: r({}) },
},
};
});
// eventBus: no-op pub/sub so pages mount without a live socket. onConnection +
// connected back the useSyncExternalStore(eventBus.onConnection, …) reads.
vi.mock("../../lib/eventBus", () => ({
eventBus: {
subscribe: () => () => {},
publish: () => {},
onConnection: () => () => {},
connected: true,
setConnected: () => {},
},
}));
// push notifications: avoid real service-worker / Notification calls.
vi.mock("../../lib/push", () => ({
subscribeToPush: vi.fn().mockResolvedValue(undefined),
unsubscribeFromPush: vi.fn().mockResolvedValue(undefined),
}));
// Page components (imported after the mocks above; vi.mock is hoisted).
import { Dashboard } from "../Dashboard";
import { Sessions } from "../Sessions";
import { SessionDetail } from "../SessionDetail";
import { ActivityFeed } from "../ActivityFeed";
import { Workflows } from "../Workflows";
import { CcConfig } from "../CcConfig";
import { Workspace } from "../Workspace";
import { Settings } from "../Settings";
import { NotFound } from "../NotFound";
// jsdom lacks these browser APIs that chart / responsive components rely on.
class ObserverStub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver =
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
globalThis.IntersectionObserver =
globalThis.IntersectionObserver || (ObserverStub as unknown as typeof IntersectionObserver);
if (!window.matchMedia) {
window.matchMedia = (query: string) =>
({
matches: false,
media: query,
onchange: null,
addListener() {},
removeListener() {},
addEventListener() {},
removeEventListener() {},
dispatchEvent() {
return false;
},
}) as unknown as MediaQueryList;
}
// Flush pending promises (mocked API resolves) + effects so the loaded state
// is rendered before snapshotting.
async function settle() {
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
}
async function snapshot(ui: ReactNode, route = "/") {
const { container } = render(<MemoryRouter initialEntries={[route]}>{ui}</MemoryRouter>);
await settle();
expect(container).toMatchSnapshot();
}
beforeAll(() => {
// Fake only Date so relative/absolute times are deterministic; leave timers
// real so setTimeout-based flushing in settle() still works.
vi.useFakeTimers({ now: new Date("2026-06-10T13:00:00.000Z"), toFake: ["Date"] });
});
afterAll(() => {
vi.useRealTimers();
});
beforeEach(() => {
i18n.changeLanguage("en");
});
afterEach(() => {
vi.clearAllMocks();
});
describe("screen snapshots", () => {
it("Dashboard", async () => {
await snapshot(<Dashboard />, "/");
});
it("Sessions", async () => {
await snapshot(<Sessions />, "/sessions");
});
it("Session detail", async () => {
await snapshot(
<Routes>
<Route path="/sessions/:id" element={<SessionDetail />} />
</Routes>,
"/sessions/sess-1"
);
});
it("Activity feed", async () => {
await snapshot(<ActivityFeed />, "/activity");
});
it("Workflows", async () => {
await snapshot(<Workflows />, "/workflows");
});
it("Claude Config", async () => {
await snapshot(<CcConfig />, "/cc-config");
});
it("Run", async () => {
await snapshot(<Workspace />, "/run");
});
it("Settings", async () => {
await snapshot(<Settings />, "/settings");
});
it("Not found", async () => {
await snapshot(<NotFound />, "/nope");
});
});
// The page opens on Overview; the plugin cards live behind the Plugins tab.
async function openPluginsTab() {
render(
<MemoryRouter initialEntries={["/cc-config"]}>
<CcConfig />
</MemoryRouter>
);
await settle();
fireEvent.click(screen.getByTestId("cc-tab-plugins"));
await settle();
}
// The chips are the only way into a plugin's contents, so the toggle is the
// feature: a count that cannot be opened tells you nothing about WHICH skills
// a plugin ships.
describe("plugin contribution chips", () => {
it("expands a plugin's skills by name on click, and collapses again", async () => {
await openPluginsTab();
const chip = screen.getByTestId("plugin-contrib-agenthub@sg-skills");
expect(chip).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByText("ship-feature")).toBeNull();
fireEvent.click(chip);
expect(chip).toHaveAttribute("aria-expanded", "true");
expect(screen.getByText("ship-feature")).toBeInTheDocument();
expect(screen.getByText("file-headers")).toBeInTheDocument();
fireEvent.click(chip);
expect(chip).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByText("ship-feature")).toBeNull();
});
it("shows only one surface at a time, and prefixes a command with its slash", async () => {
await openPluginsTab();
fireEvent.click(screen.getByTestId("plugin-contrib-agenthub@sg-skills"));
fireEvent.click(screen.getByTestId("plugin-contrib-agenthub@sg-commands"));
expect(screen.queryByText("ship-feature")).toBeNull();
expect(screen.getByText("/agenthub-doctor")).toBeInTheDocument();
});
it("leaves hooks as a plain chip - they have no per-item list to open", async () => {
await openPluginsTab();
expect(screen.queryByTestId("plugin-contrib-agenthub@sg-hooks")).toBeNull();
});
});