import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import { stripVTControlCharacters } from "node:util"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { StatusLineComponent } from "@oh-my-pi/pi-coding-agent/modes/components/status-line"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; async function flushMicrotasks(): Promise { await Promise.resolve(); await Promise.resolve(); } function makeSession(fetchUsageReports: (signal?: AbortSignal) => Promise): AgentSession { const messages: unknown[] = []; return { fetchUsageReports, messages, state: { messages, model: { contextWindow: 200_000 } }, model: { contextWindow: 200_000 }, isStreaming: false, sessionManager: { getUsageStatistics: () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, orchestrationInput: 0, orchestrationOutput: 0, orchestrationCacheRead: 0, premiumRequests: 0, cost: 0, }), getSessionName: () => "test", }, getAsyncJobSnapshot: () => ({ running: [] }), getContextUsage: () => undefined, contextUsageRevision: 0, } as unknown as AgentSession; } function usageReport(percent: number): unknown[] { return [ { provider: "anthropic", fetchedAt: Date.now(), limits: [ { id: "anthropic:5h", label: "Claude 5 Hour", scope: { provider: "anthropic", windowId: "5h" }, window: { id: "5h", label: "5h", resetsAt: Date.now() + 60_000 }, amount: { unit: "percent", usedFraction: percent / 100 }, }, ], }, ]; } function plain(text: string): string { return stripVTControlCharacters(text); } describe("StatusLineComponent usage refresh", () => { beforeEach(async () => { resetSettingsForTest(); await Settings.init({ inMemory: true }); await initTheme(); vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); resetSettingsForTest(); }); it("does not invoke usage fetching synchronously on the render path", async () => { let calls = 0; const component = new StatusLineComponent( makeSession(async () => { calls++; return []; }), ); component.refreshUsageInBackground(); expect(calls).toBe(0); vi.advanceTimersByTime(0); await flushMicrotasks(); expect(calls).toBe(1); }); it("passes a startup timeout signal to the background usage fetch", async () => { let signal: AbortSignal | undefined; const component = new StatusLineComponent( makeSession(async nextSignal => { signal = nextSignal; return []; }), ); component.refreshUsageInBackground(); vi.advanceTimersByTime(0); await flushMicrotasks(); expect(signal).toBeInstanceOf(AbortSignal); }); it("backs off after the startup timeout when usage fetching hangs", async () => { let calls = 0; const component = new StatusLineComponent( makeSession(() => { calls++; return Promise.withResolvers().promise; }), ); component.refreshUsageInBackground(); vi.advanceTimersByTime(0); await flushMicrotasks(); expect(calls).toBe(1); component.refreshUsageInBackground(); expect(calls).toBe(1); vi.advanceTimersByTime(2_000); await flushMicrotasks(); component.refreshUsageInBackground(); vi.advanceTimersByTime(0); await flushMicrotasks(); expect(calls).toBe(1); }); it("applies late usage reports that resolve after the startup timeout", async () => { const late = Promise.withResolvers(); const component = new StatusLineComponent(makeSession(() => late.promise)); component.updateSettings({ preset: "custom", leftSegments: ["usage"], rightSegments: [], separator: "powerline-thin", }); component.refreshUsageInBackground(); vi.advanceTimersByTime(0); await flushMicrotasks(); vi.advanceTimersByTime(2_000); await flushMicrotasks(); expect(plain(component.getTopBorder(80).content)).not.toContain("5h"); late.resolve(usageReport(42)); await flushMicrotasks(); expect(plain(component.getTopBorder(80).content)).toContain("5h 42%"); }); it("re-fetches usage immediately when the session rotates to another org under the same email", async () => { let calls = 0; let orgId = "org-team"; const base = makeSession(async () => { calls++; return usageReport(10); }) as unknown as Record; // Same provider + email + account throughout — only the org rotates. base.state = { messages: [], model: { contextWindow: 200_000, provider: "anthropic" }, }; base.modelRegistry = { authStorage: { getOAuthAccountIdentity: () => ({ email: "shared@example.com", accountId: "account-shared", orgId, }), }, }; const component = new StatusLineComponent(base as unknown as AgentSession); component.refreshUsageInBackground(); vi.advanceTimersByTime(0); await flushMicrotasks(); expect(calls).toBe(1); // Same org within the cache TTL: served from cache, no refetch. component.refreshUsageInBackground(); vi.advanceTimersByTime(0); await flushMicrotasks(); expect(calls).toBe(1); // Org rotation under the same email/account must invalidate the cache. orgId = "org-max"; component.refreshUsageInBackground(); vi.advanceTimersByTime(0); await flushMicrotasks(); expect(calls).toBe(2); }); });