Files
stack/apps/web/src/app/(authenticated)/usage/page.test.tsx
Jason Woltje a5ee974765 feat(#375): frontend token usage and cost dashboard
- Install recharts for data visualization
- Add Usage nav item to sidebar navigation
- Create telemetry API service with data fetching functions
- Build dashboard page with summary cards, charts, and time range selector
- Token usage line chart, cost breakdown bar chart, task outcome pie chart
- Loading and empty states handled
- Responsive layout with PDA-friendly design
- Add unit tests (14 tests passing)

Refs #375

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 02:10:22 -06:00

289 lines
10 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import type { ReactNode } from "react";
import UsagePage from "./page";
// ─── Component Prop Types ────────────────────────────────────────────
interface ChildrenProps {
children: ReactNode;
}
interface StyledChildrenProps extends ChildrenProps {
className?: string;
}
// ─── Mocks ───────────────────────────────────────────────────────────
// Mock @/components/ui/card — @mosaic/ui can't be resolved in vitest
vi.mock("@/components/ui/card", () => ({
Card: ({ children, className }: StyledChildrenProps): React.JSX.Element => (
<div className={className}>{children}</div>
),
CardHeader: ({ children }: ChildrenProps): React.JSX.Element => <div>{children}</div>,
CardContent: ({ children, className }: StyledChildrenProps): React.JSX.Element => (
<div className={className}>{children}</div>
),
CardFooter: ({ children }: ChildrenProps): React.JSX.Element => <div>{children}</div>,
CardTitle: ({ children, className }: StyledChildrenProps): React.JSX.Element => (
<h3 className={className}>{children}</h3>
),
CardDescription: ({ children, className }: StyledChildrenProps): React.JSX.Element => (
<p className={className}>{children}</p>
),
}));
// Mock recharts — jsdom has no SVG layout engine, so we render stubs
vi.mock("recharts", () => ({
LineChart: ({ children }: ChildrenProps): React.JSX.Element => (
<div data-testid="recharts-line-chart">{children}</div>
),
Line: (): React.JSX.Element => <div />,
BarChart: ({ children }: ChildrenProps): React.JSX.Element => (
<div data-testid="recharts-bar-chart">{children}</div>
),
Bar: (): React.JSX.Element => <div />,
PieChart: ({ children }: ChildrenProps): React.JSX.Element => (
<div data-testid="recharts-pie-chart">{children}</div>
),
Pie: (): React.JSX.Element => <div />,
Cell: (): React.JSX.Element => <div />,
XAxis: (): React.JSX.Element => <div />,
YAxis: (): React.JSX.Element => <div />,
CartesianGrid: (): React.JSX.Element => <div />,
Tooltip: (): React.JSX.Element => <div />,
ResponsiveContainer: ({ children }: ChildrenProps): React.JSX.Element => <div>{children}</div>,
Legend: (): React.JSX.Element => <div />,
}));
// Mock the telemetry API module
vi.mock("@/lib/api/telemetry", () => ({
fetchUsageSummary: vi.fn(),
fetchTokenUsage: vi.fn(),
fetchCostBreakdown: vi.fn(),
fetchTaskOutcomes: vi.fn(),
}));
// Import mocked modules after vi.mock
import {
fetchUsageSummary,
fetchTokenUsage,
fetchCostBreakdown,
fetchTaskOutcomes,
} from "@/lib/api/telemetry";
// ─── Test Data ───────────────────────────────────────────────────────
const mockSummary = {
totalTokens: 245800,
totalCost: 3.42,
taskCount: 47,
avgQualityGatePassRate: 0.87,
};
const mockTokenUsage = [
{ date: "2026-02-08", inputTokens: 10000, outputTokens: 5000, totalTokens: 15000 },
{ date: "2026-02-09", inputTokens: 12000, outputTokens: 6000, totalTokens: 18000 },
];
const mockCostBreakdown = [
{ model: "claude-sonnet-4-5", provider: "anthropic", cost: 18.5, taskCount: 124 },
{ model: "gpt-4o", provider: "openai", cost: 12.3, taskCount: 89 },
];
const mockTaskOutcomes = [
{ outcome: "Success", count: 312, color: "#6EBF8B" },
{ outcome: "Partial", count: 48, color: "#F5C862" },
];
function setupMocks(overrides?: { empty?: boolean; error?: boolean }): void {
if (overrides?.error) {
vi.mocked(fetchUsageSummary).mockRejectedValue(new Error("Network error"));
vi.mocked(fetchTokenUsage).mockRejectedValue(new Error("Network error"));
vi.mocked(fetchCostBreakdown).mockRejectedValue(new Error("Network error"));
vi.mocked(fetchTaskOutcomes).mockRejectedValue(new Error("Network error"));
return;
}
const summary = overrides?.empty ? { ...mockSummary, taskCount: 0 } : mockSummary;
vi.mocked(fetchUsageSummary).mockResolvedValue(summary);
vi.mocked(fetchTokenUsage).mockResolvedValue(mockTokenUsage);
vi.mocked(fetchCostBreakdown).mockResolvedValue(mockCostBreakdown);
vi.mocked(fetchTaskOutcomes).mockResolvedValue(mockTaskOutcomes);
}
// ─── Tests ───────────────────────────────────────────────────────────
describe("UsagePage", (): void => {
beforeEach((): void => {
vi.clearAllMocks();
});
it("should render the page title and subtitle", (): void => {
setupMocks();
render(<UsagePage />);
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Usage");
expect(screen.getByText("Token usage and cost overview")).toBeInTheDocument();
});
it("should have proper layout structure", (): void => {
setupMocks();
const { container } = render(<UsagePage />);
const main = container.querySelector("main");
expect(main).toBeInTheDocument();
});
it("should show loading skeleton initially", (): void => {
setupMocks();
render(<UsagePage />);
expect(screen.getByTestId("loading-skeleton")).toBeInTheDocument();
});
it("should render summary cards after loading", async (): Promise<void> => {
setupMocks();
render(<UsagePage />);
await waitFor((): void => {
expect(screen.getByTestId("summary-cards")).toBeInTheDocument();
});
// Check summary card values
expect(screen.getByText("Total Tokens")).toBeInTheDocument();
expect(screen.getByText("245.8K")).toBeInTheDocument();
expect(screen.getByText("Estimated Cost")).toBeInTheDocument();
expect(screen.getByText("$3.42")).toBeInTheDocument();
expect(screen.getByText("Task Count")).toBeInTheDocument();
expect(screen.getByText("47")).toBeInTheDocument();
expect(screen.getByText("Quality Gate Pass Rate")).toBeInTheDocument();
expect(screen.getByText("87.0%")).toBeInTheDocument();
});
it("should render all chart sections after loading", async (): Promise<void> => {
setupMocks();
render(<UsagePage />);
await waitFor((): void => {
expect(screen.getByTestId("token-usage-chart")).toBeInTheDocument();
expect(screen.getByTestId("cost-breakdown-chart")).toBeInTheDocument();
expect(screen.getByTestId("task-outcomes-chart")).toBeInTheDocument();
});
});
it("should render the time range selector with three options", (): void => {
setupMocks();
render(<UsagePage />);
expect(screen.getByText("7 Days")).toBeInTheDocument();
expect(screen.getByText("30 Days")).toBeInTheDocument();
expect(screen.getByText("90 Days")).toBeInTheDocument();
});
it("should have 30 Days selected by default", (): void => {
setupMocks();
render(<UsagePage />);
const button30d = screen.getByText("30 Days");
expect(button30d).toHaveAttribute("aria-pressed", "true");
});
it("should change time range when a different option is clicked", async (): Promise<void> => {
setupMocks();
render(<UsagePage />);
// Wait for initial load
await waitFor((): void => {
expect(screen.getByTestId("summary-cards")).toBeInTheDocument();
});
// Click 7 Days
const button7d = screen.getByText("7 Days");
fireEvent.click(button7d);
expect(button7d).toHaveAttribute("aria-pressed", "true");
expect(screen.getByText("30 Days")).toHaveAttribute("aria-pressed", "false");
});
it("should refetch data when time range changes", async (): Promise<void> => {
setupMocks();
render(<UsagePage />);
// Wait for initial load (30d default)
await waitFor((): void => {
expect(screen.getByTestId("summary-cards")).toBeInTheDocument();
});
// Initial call was with "30d"
expect(fetchUsageSummary).toHaveBeenCalledWith("30d");
// Change to 7d
fireEvent.click(screen.getByText("7 Days"));
await waitFor((): void => {
expect(fetchUsageSummary).toHaveBeenCalledWith("7d");
});
});
it("should show empty state when no tasks exist", async (): Promise<void> => {
setupMocks({ empty: true });
render(<UsagePage />);
await waitFor((): void => {
expect(screen.getByTestId("empty-state")).toBeInTheDocument();
});
expect(screen.getByText("No usage data yet")).toBeInTheDocument();
});
it("should show error state on fetch failure", async (): Promise<void> => {
setupMocks({ error: true });
render(<UsagePage />);
await waitFor((): void => {
expect(screen.getByText("Network error")).toBeInTheDocument();
});
expect(screen.getByText("Try again")).toBeInTheDocument();
});
it("should retry loading when Try again button is clicked after error", async (): Promise<void> => {
setupMocks({ error: true });
render(<UsagePage />);
await waitFor((): void => {
expect(screen.getByText("Try again")).toBeInTheDocument();
});
// Now set up success mocks and click retry
setupMocks();
fireEvent.click(screen.getByText("Try again"));
await waitFor((): void => {
expect(screen.getByTestId("summary-cards")).toBeInTheDocument();
});
});
it("should display chart section titles", async (): Promise<void> => {
setupMocks();
render(<UsagePage />);
await waitFor((): void => {
expect(screen.getByText("Token Usage Over Time")).toBeInTheDocument();
expect(screen.getByText("Cost by Model")).toBeInTheDocument();
expect(screen.getByText("Task Outcomes")).toBeInTheDocument();
});
});
it("should render recharts components within chart containers", async (): Promise<void> => {
setupMocks();
render(<UsagePage />);
await waitFor((): void => {
expect(screen.getByTestId("recharts-line-chart")).toBeInTheDocument();
expect(screen.getByTestId("recharts-bar-chart")).toBeInTheDocument();
expect(screen.getByTestId("recharts-pie-chart")).toBeInTheDocument();
});
});
});