a9c71cd905
Usage panel (status bar): - every account shows its plan (Claude subscriptionType, Codex chatgpt_plan_type, Cursor membershipType, Grok subscriptionTier) - new quota rows: Cursor CLI (cursor.com usage-summary), Antigravity (`agy --print /usage`, two model pools; AGENTHUB_AGY_USAGE=0 never runs agy), Grok Build, Kimi Code, OpenCode Go — each login read from the tool's own file, sent only to its own host while valid, never refreshed - in-flight quota reads are shared between tabs AI Tools catalog: Grok Build (`grok`) and Kimi Code (`kimi`) added; Gemini CLI, Hermes and OpenClaw removed. Also: Quit AgentHub (#65, `agenthub stop`), status-area icon, Add to Chat on an editor selection, Settings in three groups, JetBrains-style Changes panel (ticked files/hunks, ignore, blame, file history), terminal font. --no-verify: :3000 is taken by another dev server (lane-detect tests); both suites run by hand — server 2636/2639 (the 3 are :3000), client 1131/1131.
729 lines
23 KiB
TypeScript
729 lines
23 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: {
|
|
aiProxies: { list: r({ items: [] }) },
|
|
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),
|
|
},
|
|
budgets: { status: r({ budgets: [] }) },
|
|
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: [] }),
|
|
pushToken: {
|
|
get: r({ enabled: false, env_owned: false }),
|
|
rotate: r({ token: "t", enabled: true, env_owned: false }),
|
|
revoke: r({ enabled: false, env_owned: false }),
|
|
},
|
|
autostart: {
|
|
get: r({ available: true, kind: "systemd", enabled: false, foreign: false }),
|
|
set: r({ available: true, kind: "systemd", enabled: true, foreign: false }),
|
|
},
|
|
tray: {
|
|
get: r({ available: true, enabled: true, running: true }),
|
|
set: r({ available: true, enabled: false, running: false }),
|
|
},
|
|
privacy: {
|
|
get: r({
|
|
redact_secrets: false,
|
|
ignore_cwd: [],
|
|
env_owned: { redact_secrets: false, ignore_cwd: false },
|
|
}),
|
|
save: r({ ok: true }),
|
|
},
|
|
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: [] }),
|
|
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 { api } from "../../lib/api";
|
|
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("Dashboard deep link opens the Workflow sub-tab with the workflow analytics", async () => {
|
|
localStorage.removeItem("dashboard_tab");
|
|
const { container } = render(
|
|
<MemoryRouter initialEntries={["/?tab=analytics&sub=workflow"]}>
|
|
<Dashboard />
|
|
</MemoryRouter>
|
|
);
|
|
await settle();
|
|
const headings = [...container.querySelectorAll("h2")].map((h) => h.textContent);
|
|
expect(headings).toContain(i18n.t("workflows:title"));
|
|
});
|
|
it("Dashboard Analytics reads its date range from ?range=", async () => {
|
|
localStorage.removeItem("dashboard_tab");
|
|
render(
|
|
<MemoryRouter initialEntries={["/?tab=analytics&range=7d"]}>
|
|
<Dashboard />
|
|
</MemoryRouter>
|
|
);
|
|
await settle();
|
|
const range = { from: "2026-06-04", to: "2026-06-10" };
|
|
expect(api.analytics.get).toHaveBeenCalledWith({ tool: "", range });
|
|
expect(api.pricing.totalCost).toHaveBeenCalledWith({ tool: "", range });
|
|
expect(screen.getByRole("button", { name: /Date range/ })).toHaveTextContent("Last 7 days");
|
|
});
|
|
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 — notifications", async () => {
|
|
await snapshot(<Settings section="notifications" />, "/settings/notifications");
|
|
});
|
|
it("Settings — data", async () => {
|
|
await snapshot(<Settings section="data" />, "/settings/data");
|
|
});
|
|
it("Settings — about", async () => {
|
|
await snapshot(<Settings section="about" />, "/settings/about");
|
|
});
|
|
it("Not found", async () => {
|
|
await snapshot(<NotFound />, "/nope");
|
|
});
|
|
});
|
|
|
|
// Hook status and CLAUDE_HOME belong to Claude Code (#40 part B): they sit
|
|
// under the Claude Code tab's Overview, not on Dashboard or on the other
|
|
// sub-tabs. Its prices live on the Pricing page; the Overview only links there.
|
|
describe("Claude Code's AgentHub settings", () => {
|
|
it("shows hooks and CLAUDE_HOME on the Overview only", async () => {
|
|
const dashboard = render(
|
|
<MemoryRouter initialEntries={["/settings"]}>
|
|
<Settings />
|
|
</MemoryRouter>
|
|
);
|
|
await settle();
|
|
for (const id of ["pricing", "hooks", "claude-home"]) {
|
|
expect(dashboard.container.querySelector(`section#${id}`)).toBeNull();
|
|
}
|
|
dashboard.unmount();
|
|
|
|
const { container } = render(
|
|
<MemoryRouter initialEntries={["/cc-config"]}>
|
|
<CcConfig />
|
|
</MemoryRouter>
|
|
);
|
|
await settle();
|
|
expect(screen.queryByTestId("cc-tab-agenthub")).toBeNull();
|
|
for (const id of ["hooks", "claude-home"]) {
|
|
expect(container.querySelector(`section#${id}`)).not.toBeNull();
|
|
}
|
|
expect(container.querySelector("section#pricing")).toBeNull();
|
|
expect(container.querySelector('a[href="/settings/pricing#pricing-claude"]')).not.toBeNull();
|
|
fireEvent.click(screen.getByTestId("cc-tab-skills"));
|
|
await settle();
|
|
expect(container.querySelector("section#hooks")).toBeNull();
|
|
});
|
|
});
|
|
|
|
// 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();
|
|
});
|
|
});
|