1import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
2import { mergeUsage } from "@t3tools/shared/usageMerge";
3import { act } from "react";
4import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
5import { create, type ReactTestRenderer } from "react-test-renderer";
6import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
7
8const state = vi.hoisted(() => ({
9 presentations: new Map(),
10 refreshProviders: vi.fn(async () => undefined),
11 metric: "cost",
12 refresh: vi.fn(async (_window?: unknown) => undefined),
13}));
14vi.mock("@effect/atom-react", () => ({
15 useAtomValue: (atom: unknown) =>
16 atom === "keybindings" ? DEFAULT_RESOLVED_KEYBINDINGS : state.presentations,
17}));
18vi.mock("@tanstack/react-router", () => ({
19 useNavigate: () => vi.fn(),
20 useCanGoBack: () => false,
21}));
22vi.mock("../../state/presentation", () => ({
23 environmentPresentations: { presentationsAtom: null },
24}));
25vi.mock("../../state/server", () => ({
26 serverEnvironment: { refreshProviders: null },
27 primaryServerKeybindingsAtom: "keybindings",
28}));
29vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.refreshProviders }));
30vi.mock("../../env", () => ({ isElectron: false }));
31vi.mock("../../hooks/useSettings", () => ({ usePrimarySettings: () => "24h" }));
32vi.mock("../../state/usage", () => ({
33 useUsage: () => ({
34 merged: mergeUsage([], USAGE_CONTRACT_VERSION),
35 environments: [
36 {
37 environmentId: EnvironmentId.make("test"),
38 label: "Test",
39 isPending: false,
40 error: null,
41 summary: null,
42 canReadDiagnostics: true,
43 },
44 ],
45 selectedEnvironments: [
46 {
47 environmentId: EnvironmentId.make("test"),
48 label: "Test",
49 isPending: false,
50 error: null,
51 summary: null,
52 canReadDiagnostics: true,
53 },
54 ],
55 isPending: false,
56 shown: null,
57 isPartial: false,
58 refresh: state.refresh,
59 }),
60}));
61vi.mock("./usagePagePreferences", () => ({
62 readUsagePagePreferences: () => ({ metric: state.metric, windowDays: 30 }),
63 saveUsagePagePreferences: vi.fn(),
64}));
65vi.mock("../ui/button", () => ({ Button: "button", InlineButton: "button" }));
66vi.mock("../ui/scroll-area", () => ({ ScrollArea: "div" }));
67vi.mock("../ui/select", () => ({
68 Select: "select",
69 SelectItem: "option",
70 SelectPopup: "div",
71 SelectTrigger: "div",
72 SelectValue: "span",
73}));
74vi.mock("../ui/sidebar", () => ({ SidebarInset: "div" }));
75vi.mock("../ui/toggle-group", () => ({ Toggle: "button", ToggleGroup: "div" }));
76vi.mock("../ui/tooltip", () => ({ Tooltip: "div", TooltipPopup: "div", TooltipTrigger: "div" }));
77vi.mock("../ui/popover", () => ({ Popover: "div", PopoverPopup: "div", PopoverTrigger: "div" }));
78vi.mock("../ui/menu", () => ({
79 Menu: "div",
80 MenuCheckboxItem: "div",
81 MenuItem: "div",
82 MenuPopup: "div",
83 MenuSeparator: "hr",
84 MenuTrigger: "div",
85}));
86vi.mock("../WorkspaceBreadcrumb", () => ({
87 WorkspaceBreadcrumb: "div",
88 WorkspaceBreadcrumbItem: "div",
89 WorkspaceBreadcrumbSeparator: "span",
90 WorkspaceBreadcrumbText: "span",
91}));
92vi.mock("../WorkspacePageContainer", () => ({ WorkspacePageContainer: "main" }));
93vi.mock("../WorkspacePageHeader", () => ({ WorkspacePageHeader: "header" }));
94vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" }));
95vi.mock("./UsagePriceOverrides", () => ({ UsagePriceOverrides: () => null }));
96vi.mock("../chat/ProviderInstanceIcon", () => ({ ProviderInstanceIcon: () => null }));
97vi.mock("../settings/RedactedSensitiveText", () => ({ RedactedSensitiveText: "span" }));
98vi.mock("../settings/providerDriverMeta", () => ({
99 providerClients: { get: () => ({ label: "Codex" }) },
100}));
101
102import { UsagePage } from "./UsagePage";
103
104let renderer: ReactTestRenderer;
105beforeEach(() => {
106 vi.stubGlobal("window", new EventTarget());
107 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
108 vi.useFakeTimers({ toFake: ["Date"] });
109 vi.setSystemTime(new Date("2026-09-11T12:00:00Z"));
110 state.metric = "cost";
111 state.refresh.mockReset().mockResolvedValue(undefined);
112 state.refreshProviders.mockClear();
113 state.presentations = new Map();
114});
115afterEach(async () => {
116 await act(() => renderer?.unmount());
117 vi.useRealTimers();
118 vi.restoreAllMocks();
119 vi.unstubAllGlobals();
120});
121
122it.each([0, 1])("refreshes history from toolbar button %i", async (buttonIndex) => {
123 await act(() => {
124 renderer = create(<UsagePage />);
125 });
126 expect(state.refresh).not.toHaveBeenCalled();
127 expect(state.refreshProviders).not.toHaveBeenCalled();
128
129 vi.setSystemTime(new Date("2026-09-12T12:00:00Z"));
130 await act(async () => {
131 renderer.root
132 .findAll((node) => node.type === "button" && node.props["aria-label"] === "Refresh usage")
133 .at(buttonIndex)!
134 .props.onClick();
135 });
136 expect(state.refresh).toHaveBeenCalledOnce();
137 expect(state.refresh).toHaveBeenCalledWith(expect.objectContaining({ untilDay: "2026-09-12" }));
138 expect(state.refreshProviders).not.toHaveBeenCalled();
139});
140
141it("keeps refresh busy and suppresses repeated requests until history arrives", async () => {
142 let finishRefresh!: () => void;
143 const pending = new Promise<undefined>((resolve) => {
144 finishRefresh = () => resolve(undefined);
145 });
146 state.refresh.mockImplementationOnce(() => pending);
147 await act(() => {
148 renderer = create(<UsagePage />);
149 });
150 const button = () =>
151 renderer.root.findAll(
152 (node) => node.type === "button" && node.props["aria-label"] === "Refresh usage",
153 )[0]!;
154 await act(() => {
155 button().props.onClick();
156 button().props.onClick();
157 });
158 expect(button().props["aria-busy"]).toBe(true);
159 expect(state.refresh).toHaveBeenCalledOnce();
160 await act(async () => {
161 finishRefresh();
162 await pending;
163 });
164 expect(button().props["aria-busy"]).toBe(false);
165});
166
167it("switches costs and tokens without checking subscription limits", async () => {
168 await act(() => {
169 renderer = create(<UsagePage />);
170 });
171 const metricControl = () =>
172 renderer.root.findAll(
173 (node) => node.type === "div" && node.props["aria-label"] === "Usage metric",
174 )[0]!;
175 await act(() => metricControl().props.onValueChange(["tokens"]));
176 expect(metricControl().props.value).toEqual(["tokens"]);
177 await act(() => metricControl().props.onValueChange(["limits"]));
178 expect(metricControl().props.value).toEqual(["tokens"]);
179 expect(state.refreshProviders).not.toHaveBeenCalled();
180});