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.
283 lines
11 KiB
TypeScript
283 lines
11 KiB
TypeScript
/**
|
|
* @file StatusBar.test.tsx
|
|
* @description Tests for the shell's bottom status bar: the live/disconnected
|
|
* state with the version (click opens the connection details), the running and
|
|
* needs-you SESSION counts, today's spend (`date=` on the pricing cost call)
|
|
* with its tokens and a per-model hover list, and on the Workspace page the
|
|
* open workspace's and the shown session's totals; the right-end quota chips
|
|
* (fullest window per tool) and their hover panel.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import { act, render, screen, waitFor } from "@testing-library/react";
|
|
import { MemoryRouter } from "react-router-dom";
|
|
import userEvent from "@testing-library/user-event";
|
|
|
|
const M = 1_000_000;
|
|
|
|
function today() {
|
|
const d = new Date();
|
|
const pad = (n: number) => String(n).padStart(2, "0");
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
}
|
|
|
|
vi.mock("../../lib/api", () => ({
|
|
api: {
|
|
aiTools: { iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
|
|
sessions: {
|
|
// 3 active sessions, 1 of them waiting on the user.
|
|
list: vi.fn().mockImplementation(async (p: { awaiting?: boolean }) => ({
|
|
sessions: [],
|
|
total: p.awaiting ? 1 : 3,
|
|
})),
|
|
},
|
|
pricing: {
|
|
totalCost: vi.fn().mockImplementation(async (opts?: { lane?: number; date?: string }) => ({
|
|
total_cost: opts?.lane ? 123.456 : opts?.date === today() ? 1.234 : 10,
|
|
breakdown: opts?.lane
|
|
? []
|
|
: [
|
|
// Two buckets of one model fold into one line.
|
|
{
|
|
model: "claude-opus-4-8",
|
|
input_tokens: 1 * M,
|
|
output_tokens: 0,
|
|
cache_read_tokens: 10 * M,
|
|
cache_write_tokens: 0,
|
|
cost: 1,
|
|
matched_rule: null,
|
|
},
|
|
{
|
|
model: "claude-opus-4-8",
|
|
speed: "fast",
|
|
input_tokens: 0,
|
|
output_tokens: 1 * M,
|
|
cache_read_tokens: 0,
|
|
cache_write_tokens: 0,
|
|
cost: 0.2,
|
|
matched_rule: null,
|
|
},
|
|
{
|
|
model: "claude-haiku-4-5",
|
|
input_tokens: 300_000,
|
|
output_tokens: 0,
|
|
cache_read_tokens: 0,
|
|
cache_write_tokens: 0,
|
|
cost: 0.034,
|
|
matched_rule: null,
|
|
},
|
|
],
|
|
daily_costs: [],
|
|
})),
|
|
quota: vi.fn().mockResolvedValue({
|
|
tools: [
|
|
{
|
|
id: "claude",
|
|
active: true,
|
|
label: "Work",
|
|
account: "me@example.com",
|
|
plan: "max",
|
|
expiresAt: new Date(Date.now() + 3 * 3600_000 + 30_000).toISOString(),
|
|
windows: [
|
|
{
|
|
label: "5h",
|
|
usedPercent: 95,
|
|
resetsAt: new Date(Date.now() + 41 * 60_000 + 30_000).toISOString(),
|
|
},
|
|
{ label: "wk", usedPercent: 32, resetsAt: null },
|
|
],
|
|
},
|
|
{
|
|
id: "claude",
|
|
active: false,
|
|
label: "Personal",
|
|
account: "home@example.com",
|
|
expiresAt: "2020-01-01T00:00:00.000Z",
|
|
windows: [],
|
|
error: "EPROFILEEXPIRED",
|
|
},
|
|
{
|
|
id: "codex",
|
|
account: "dev@example.com",
|
|
expiresAt: "2020-01-01T00:00:00.000Z",
|
|
windows: [],
|
|
error: "EUPSTREAM",
|
|
},
|
|
{
|
|
id: "antigravity",
|
|
active: true,
|
|
windows: [
|
|
{ group: "Gemini Models", label: "5h", usedPercent: 0, resetsAt: null },
|
|
{ group: "Gemini Models", label: "wk", usedPercent: 25, resetsAt: null },
|
|
{ group: "Claude and GPT models", label: "5h", usedPercent: 50, resetsAt: null },
|
|
],
|
|
},
|
|
{
|
|
id: "cursor",
|
|
active: true,
|
|
account: "c@example.com",
|
|
plan: "pro",
|
|
windows: [{ label: "mo", usedPercent: 40, resetsAt: null }],
|
|
},
|
|
],
|
|
checkedAt: "",
|
|
}),
|
|
sessionCost: vi.fn().mockImplementation(async (id: string) => ({
|
|
total_cost: id === "s-live" ? 4.5 : 7,
|
|
breakdown: [],
|
|
daily_costs: [],
|
|
})),
|
|
},
|
|
budgets: {
|
|
status: vi.fn().mockResolvedValue({
|
|
budgets: [
|
|
{ id: "b1", name: "Quiet", config: { limit_usd: 100 }, spend: 10, pct: 10, crossed: [] },
|
|
{
|
|
id: "b2",
|
|
name: "Monthly",
|
|
config: { limit_usd: 100 },
|
|
spend: 85,
|
|
pct: 85,
|
|
crossed: [80],
|
|
},
|
|
],
|
|
}),
|
|
},
|
|
},
|
|
}));
|
|
|
|
import { api } from "../../lib/api";
|
|
import { StatusBar } from "../StatusBar";
|
|
import { LIVE_RUNS_EVENT } from "../WorkspaceTree";
|
|
|
|
const at = (url: string, ui: JSX.Element) =>
|
|
render(<MemoryRouter initialEntries={[url]}>{ui}</MemoryRouter>);
|
|
|
|
describe("StatusBar", () => {
|
|
it("shows the connection with the version, the counts and today's spend", async () => {
|
|
const user = userEvent.setup();
|
|
const onConnectionClick = vi.fn();
|
|
at("/", <StatusBar wsConnected left="15rem" onConnectionClick={onConnectionClick} />);
|
|
const conn = screen.getByRole("button", { name: /Live/ });
|
|
expect(conn).toHaveTextContent(`v${__APP_VERSION__}`);
|
|
const cost = await screen.findByText("Today $1.23 (12.3M Tokens)");
|
|
expect(api.pricing.totalCost).toHaveBeenCalledWith({ date: today() });
|
|
expect(cost).toHaveAttribute(
|
|
"title",
|
|
"Claude Opus 4.8: 12.0M · $1.20\nClaude Haiku 4.5: 300.0K · $0.03"
|
|
);
|
|
await screen.findByText("1 need you");
|
|
expect(screen.getByTestId("status-bar-counts").textContent).toBe("2 running · 1 need you");
|
|
await user.click(conn);
|
|
expect(onConnectionClick).toHaveBeenCalled();
|
|
// Polite live region: the connection and who is waiting, nothing that ticks.
|
|
expect(screen.getByRole("status")).toHaveTextContent("Live · 1 need you");
|
|
});
|
|
|
|
it("flags the budget furthest past a threshold, linking to the cost analytics", async () => {
|
|
at("/", <StatusBar wsConnected left="0" onConnectionClick={() => {}} />);
|
|
const chip = await screen.findByTestId("status-bar-budget");
|
|
expect(chip.textContent).toBe("Budget 85%");
|
|
expect(chip.getAttribute("href")).toBe("/?tab=analytics&sub=cost");
|
|
expect(chip.getAttribute("title")).toBe("Monthly: $85.00 of $100.00 this period");
|
|
});
|
|
|
|
it("says Disconnected when the socket is down", () => {
|
|
at("/", <StatusBar wsConnected={false} left="0" onConnectionClick={() => {}} />);
|
|
expect(screen.getByRole("button", { name: /Disconnected/ })).toBeInTheDocument();
|
|
expect(screen.getByRole("status")).toHaveTextContent("Disconnected");
|
|
});
|
|
|
|
it("off the Workspace page shows no workspace or session cost", async () => {
|
|
at("/sessions", <StatusBar wsConnected left="0" onConnectionClick={() => {}} />);
|
|
await screen.findByText(/^Today \$1\.23/);
|
|
expect(screen.queryByTestId("status-bar-workspace-cost")).toBeNull();
|
|
expect(screen.queryByTestId("status-bar-session-cost")).toBeNull();
|
|
});
|
|
|
|
it("shows the open workspace's total and the replayed session's", async () => {
|
|
at(
|
|
"/workspace?lane=3&session=s-old",
|
|
<StatusBar wsConnected left="0" onConnectionClick={() => {}} />
|
|
);
|
|
expect(await screen.findByText("Workspace $123.46 (0 Tokens)")).toBeInTheDocument();
|
|
expect(await screen.findByText(/^Session \$7\.00/)).toBeInTheDocument();
|
|
expect(api.pricing.totalCost).toHaveBeenCalledWith({ lane: 3 });
|
|
});
|
|
|
|
it("takes a focused pane's session from the page's run poll", async () => {
|
|
at(
|
|
"/workspace?lane=3&run=agenthub-lane-3-1",
|
|
<StatusBar wsConnected left="0" onConnectionClick={() => {}} />
|
|
);
|
|
await screen.findByText(/^Workspace \$123\.46/);
|
|
expect(screen.queryByTestId("status-bar-session-cost")).toBeNull();
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new CustomEvent(LIVE_RUNS_EVENT, {
|
|
detail: [{ id: "agenthub-lane-3-1", laneId: 3, status: "running", sessionId: "s-live" }],
|
|
})
|
|
);
|
|
});
|
|
expect(await screen.findByText(/^Session \$4\.50/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows each tool's fullest quota window and the details on hover", async () => {
|
|
const user = userEvent.setup();
|
|
at("/", <StatusBar wsConnected left="0" onConnectionClick={() => {}} />);
|
|
const chips = await screen.findByTestId("status-bar-quota");
|
|
expect(chips).toHaveTextContent("Claude95% used");
|
|
expect(chips.textContent?.match(/Claude/g)).toHaveLength(1);
|
|
expect(screen.getByText("95% used")).toHaveClass("text-status-danger");
|
|
await user.hover(chips);
|
|
const panel = screen.getByTestId("status-bar-quota-panel");
|
|
const [work, personal, codex, agy, cursor] = screen.getAllByTestId("status-bar-quota-account");
|
|
expect(work).toHaveTextContent("Workmaxin use");
|
|
expect(work).toHaveTextContent("me@example.com · Sign-in expires in 3h 0m");
|
|
expect(work).toHaveTextContent("5h95%Resets in 41m");
|
|
expect(work).toHaveTextContent("Week32%");
|
|
expect(personal).toHaveTextContent("home@example.com · Sign-in expired");
|
|
expect(personal).toHaveTextContent("Check quota in Claude Code → Profiles");
|
|
expect(personal).not.toHaveTextContent("in use");
|
|
expect(codex).toHaveTextContent("dev@example.com");
|
|
expect(codex).toHaveTextContent("Sign-in expired");
|
|
expect(panel).toHaveTextContent("Could not read the quota");
|
|
expect(agy).toHaveTextContent("Gemini Models5h0%Week25%Claude and GPT models5h50%");
|
|
expect(chips).toHaveTextContent("Antigravity50% used");
|
|
expect(cursor).toHaveTextContent("c@example.compro");
|
|
expect(cursor).toHaveTextContent("Month40%");
|
|
expect(chips).toHaveTextContent("Cursor40% used");
|
|
// Moving onto the panel keeps it open, so it can be read; leaving it closes it.
|
|
await user.unhover(chips);
|
|
await user.hover(panel);
|
|
await new Promise((r) => setTimeout(r, 300));
|
|
expect(screen.getByTestId("status-bar-quota-panel")).toBeInTheDocument();
|
|
// Only the footer button refreshes: it spins and says so until the read lands.
|
|
const same = await api.pricing.quota(false);
|
|
let answer!: (v: unknown) => void;
|
|
vi.mocked(api.pricing.quota)
|
|
.mockClear()
|
|
.mockReturnValueOnce(new Promise((r) => (answer = r)) as never);
|
|
const refresh = screen.getByTestId("status-bar-quota-refresh");
|
|
await user.click(refresh);
|
|
expect(api.pricing.quota).toHaveBeenLastCalledWith(true);
|
|
expect(refresh).toHaveTextContent("Refreshing…");
|
|
expect(refresh).toHaveAttribute("aria-busy", "true");
|
|
await user.click(refresh);
|
|
expect(api.pricing.quota).toHaveBeenCalledTimes(1);
|
|
await act(async () => answer(same));
|
|
// The spin outlasts an instant answer (a throttled refresh is served from
|
|
// cache), so the label returns after the minimum spin.
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId("status-bar-quota-refresh")).toHaveTextContent(/^Refresh$/)
|
|
);
|
|
expect(screen.getByTestId("status-bar-quota-panel")).toBeInTheDocument();
|
|
await user.unhover(panel);
|
|
await waitFor(() => expect(screen.queryByTestId("status-bar-quota-panel")).toBeNull());
|
|
vi.mocked(api.pricing.quota).mockClear();
|
|
await user.click(chips);
|
|
expect(api.pricing.quota).toHaveBeenLastCalledWith(true);
|
|
});
|
|
});
|