agent-ecosystem/test/features/codex-account/renderer/useCodexAccountSnapshot.test.ts

588 lines
17 KiB
TypeScript

import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useCodexAccountSnapshot } from '../../../../src/features/codex-account/renderer/hooks/useCodexAccountSnapshot';
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
const apiMocks = vi.hoisted(() => ({
getCodexAccountSnapshot: vi.fn(),
refreshCodexAccountSnapshot: vi.fn(),
startCodexChatgptLogin: vi.fn(),
cancelCodexChatgptLogin: vi.fn(),
logoutCodexAccount: vi.fn(),
onCodexAccountSnapshotChanged: vi.fn<
(callback: (event: unknown, snapshot: CodexAccountSnapshotDto) => void) => () => void
>(() => () => undefined),
}));
type IdleCallbackForTest = (deadline: {
didTimeout: boolean;
timeRemaining: () => number;
}) => void;
vi.mock('@renderer/api', () => ({
api: apiMocks,
isElectronMode: () => true,
}));
function createSnapshot(): CodexAccountSnapshotDto {
return {
preferredAuthMode: 'chatgpt',
effectiveAuthMode: 'chatgpt',
launchAllowed: true,
launchIssueMessage: null,
launchReadinessState: 'ready_chatgpt',
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: {
type: 'chatgpt',
email: 'belief@example.com',
planType: 'pro',
},
apiKey: {
available: false,
source: null,
sourceLabel: null,
},
requiresOpenaiAuth: false,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: {
limitId: 'codex',
limitName: null,
primary: {
usedPercent: 77,
windowDurationMins: 300,
resetsAt: 1_776_678_034,
},
secondary: null,
credits: {
hasCredits: false,
unlimited: false,
balance: '0',
},
planType: 'pro',
},
updatedAt: new Date().toISOString(),
};
}
function withSnapshotOverrides(
snapshot: CodexAccountSnapshotDto,
overrides: Partial<CodexAccountSnapshotDto>
): CodexAccountSnapshotDto {
return {
...snapshot,
...overrides,
};
}
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((nextResolve, nextReject) => {
resolve = nextResolve;
reject = nextReject;
});
return { promise, resolve, reject };
}
describe('useCodexAccountSnapshot', () => {
beforeEach(() => {
vi.clearAllMocks();
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
vi.useRealTimers();
});
afterEach(() => {
document.body.innerHTML = '';
Reflect.deleteProperty(window, 'requestIdleCallback');
Reflect.deleteProperty(window, 'cancelIdleCallback');
});
it('loads the initial Codex snapshot through refresh when rate limits are requested', async () => {
const snapshot = createSnapshot();
const refreshDeferred = createDeferred<CodexAccountSnapshotDto>();
apiMocks.refreshCodexAccountSnapshot.mockReturnValue(refreshDeferred.promise);
apiMocks.getCodexAccountSnapshot.mockResolvedValue(snapshot);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
function Harness(): React.ReactElement {
const state = useCodexAccountSnapshot({
enabled: true,
includeRateLimits: true,
});
return React.createElement('div', null, state.snapshot?.managedAccount?.email ?? 'empty');
}
await act(async () => {
root.render(React.createElement(Harness));
refreshDeferred.resolve(snapshot);
await refreshDeferred.promise;
await Promise.resolve();
});
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledWith({
includeRateLimits: true,
});
expect(apiMocks.getCodexAccountSnapshot).not.toHaveBeenCalled();
expect(host.textContent).toContain('belief@example.com');
act(() => {
root.unmount();
});
});
it('ignores older pushed Codex snapshots after a fresher snapshot was applied', async () => {
let snapshotListener:
| ((event: unknown, snapshot: CodexAccountSnapshotDto) => void)
| null = null;
const staleSnapshot = withSnapshotOverrides(createSnapshot(), {
updatedAt: '2026-01-01T00:00:00.000Z',
managedAccount: {
type: 'chatgpt',
email: 'stale@example.com',
planType: 'pro',
},
});
const freshSnapshot = withSnapshotOverrides(createSnapshot(), {
updatedAt: '2026-01-01T00:00:01.000Z',
managedAccount: {
type: 'chatgpt',
email: 'fresh@example.com',
planType: 'pro',
},
});
apiMocks.getCodexAccountSnapshot.mockResolvedValue(freshSnapshot);
apiMocks.onCodexAccountSnapshotChanged.mockImplementation(
(callback: (event: unknown, snapshot: CodexAccountSnapshotDto) => void) => {
snapshotListener = callback;
return () => undefined;
}
);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
function Harness(): React.ReactElement {
const state = useCodexAccountSnapshot({
enabled: true,
});
return React.createElement('div', null, state.snapshot?.managedAccount?.email ?? 'empty');
}
await act(async () => {
root.render(React.createElement(Harness));
await Promise.resolve();
});
expect(host.textContent).toContain('fresh@example.com');
await act(async () => {
snapshotListener?.({}, staleSnapshot);
await Promise.resolve();
});
expect(host.textContent).toContain('fresh@example.com');
act(() => {
root.unmount();
});
});
it('can defer the initial Codex snapshot without starting interval refreshes first', async () => {
vi.useFakeTimers();
const snapshot = createSnapshot();
apiMocks.refreshCodexAccountSnapshot.mockResolvedValue(snapshot);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
function Harness(): React.ReactElement {
const state = useCodexAccountSnapshot({
enabled: true,
includeRateLimits: true,
initialRefreshDelayMs: 30_000,
});
return React.createElement(
'div',
{ 'data-loading': state.loading ? 'true' : 'false' },
state.snapshot?.managedAccount?.email ?? 'empty'
);
}
await act(async () => {
root.render(React.createElement(Harness));
await Promise.resolve();
});
expect(apiMocks.refreshCodexAccountSnapshot).not.toHaveBeenCalled();
expect(host.firstElementChild?.getAttribute('data-loading')).toBe('true');
await act(async () => {
vi.advanceTimersByTime(20_000);
await Promise.resolve();
});
expect(apiMocks.refreshCodexAccountSnapshot).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(10_000);
await Promise.resolve();
});
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(1);
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledWith({
includeRateLimits: true,
});
expect(host.textContent).toContain('belief@example.com');
act(() => {
root.unmount();
});
});
it('uses idle scheduling for deferred initial Codex snapshots when a max delay is provided', async () => {
vi.useFakeTimers();
const snapshot = createSnapshot();
apiMocks.refreshCodexAccountSnapshot.mockResolvedValue(snapshot);
let idleCallback: IdleCallbackForTest = () => undefined;
const requestIdleCallback = vi.fn((callback, options?: { timeout?: number }) => {
idleCallback = callback;
expect(options).toEqual({ timeout: 28_000 });
return 7;
});
const cancelIdleCallback = vi.fn();
Object.defineProperty(window, 'requestIdleCallback', {
configurable: true,
value: requestIdleCallback,
});
Object.defineProperty(window, 'cancelIdleCallback', {
configurable: true,
value: cancelIdleCallback,
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
function Harness(): React.ReactElement {
const state = useCodexAccountSnapshot({
enabled: true,
includeRateLimits: true,
initialRefreshDelayMs: 2_000,
initialRefreshMaxDelayMs: 30_000,
});
return React.createElement('div', null, state.snapshot?.managedAccount?.email ?? 'empty');
}
await act(async () => {
root.render(React.createElement(Harness));
await Promise.resolve();
});
await act(async () => {
vi.advanceTimersByTime(1_999);
await Promise.resolve();
});
expect(apiMocks.refreshCodexAccountSnapshot).not.toHaveBeenCalled();
expect(requestIdleCallback).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(1);
await Promise.resolve();
});
expect(requestIdleCallback).toHaveBeenCalledTimes(1);
expect(apiMocks.refreshCodexAccountSnapshot).not.toHaveBeenCalled();
await act(async () => {
idleCallback({ didTimeout: false, timeRemaining: () => 10 });
await Promise.resolve();
await Promise.resolve();
});
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(1);
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledWith({
includeRateLimits: true,
});
expect(host.textContent).toContain('belief@example.com');
act(() => {
root.unmount();
});
expect(cancelIdleCallback).not.toHaveBeenCalled();
});
it('clears a deferred initial Codex snapshot timer on unmount', async () => {
vi.useFakeTimers();
apiMocks.refreshCodexAccountSnapshot.mockResolvedValue(createSnapshot());
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
function Harness(): React.ReactElement {
useCodexAccountSnapshot({
enabled: true,
includeRateLimits: true,
initialRefreshDelayMs: 30_000,
});
return React.createElement('div', null, 'mounted');
}
await act(async () => {
root.render(React.createElement(Harness));
await Promise.resolve();
});
act(() => {
root.unmount();
});
await act(async () => {
vi.advanceTimersByTime(30_000);
await Promise.resolve();
});
expect(apiMocks.refreshCodexAccountSnapshot).not.toHaveBeenCalled();
});
it('keeps retrying after a deferred initial Codex snapshot fails transiently', async () => {
vi.useFakeTimers();
const snapshot = createSnapshot();
apiMocks.refreshCodexAccountSnapshot
.mockRejectedValueOnce(new Error('temporary Codex outage'))
.mockResolvedValueOnce(snapshot);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
function Harness(): React.ReactElement {
const state = useCodexAccountSnapshot({
enabled: true,
includeRateLimits: true,
initialRefreshDelayMs: 30_000,
});
return React.createElement(
'div',
null,
state.error ?? state.snapshot?.managedAccount?.email ?? 'empty'
);
}
await act(async () => {
root.render(React.createElement(Harness));
await Promise.resolve();
});
await act(async () => {
vi.advanceTimersByTime(30_000);
await Promise.resolve();
await Promise.resolve();
});
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(1);
expect(host.textContent).toContain('temporary Codex outage');
await act(async () => {
vi.advanceTimersByTime(10_000);
await Promise.resolve();
await Promise.resolve();
});
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(2);
expect(host.textContent).toContain('belief@example.com');
act(() => {
root.unmount();
});
});
it('does not run the deferred initial snapshot after a manual refresh already loaded one', async () => {
vi.useFakeTimers();
Object.defineProperty(document, 'visibilityState', {
configurable: true,
get: () => 'hidden' satisfies DocumentVisibilityState,
});
const snapshot = createSnapshot();
apiMocks.refreshCodexAccountSnapshot.mockResolvedValue(snapshot);
let refreshNow!: () => Promise<void>;
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
function Harness(): React.ReactElement {
const state = useCodexAccountSnapshot({
enabled: true,
includeRateLimits: true,
initialRefreshDelayMs: 30_000,
});
refreshNow = () => state.refresh({ includeRateLimits: true });
return React.createElement('div', null, state.snapshot?.managedAccount?.email ?? 'empty');
}
await act(async () => {
root.render(React.createElement(Harness));
await Promise.resolve();
});
await act(async () => {
await refreshNow();
});
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(1);
await act(async () => {
vi.advanceTimersByTime(30_000);
await Promise.resolve();
});
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(1);
act(() => {
root.unmount();
});
});
it('refreshes rate-limit snapshots more often while visible without flipping loading state during background polls', async () => {
vi.useFakeTimers();
const visibilityState: DocumentVisibilityState = 'visible';
Object.defineProperty(document, 'visibilityState', {
configurable: true,
get: () => visibilityState,
});
const snapshot = createSnapshot();
apiMocks.refreshCodexAccountSnapshot.mockResolvedValue(snapshot);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
function Harness(): React.ReactElement {
const state = useCodexAccountSnapshot({
enabled: true,
includeRateLimits: true,
});
return React.createElement(
'div',
{ 'data-loading': state.loading ? 'true' : 'false' },
state.snapshot?.managedAccount?.email ?? 'empty'
);
}
await act(async () => {
root.render(React.createElement(Harness));
await Promise.resolve();
});
expect(host.firstElementChild?.getAttribute('data-loading')).toBe('false');
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(1);
apiMocks.refreshCodexAccountSnapshot.mockClear();
await act(async () => {
vi.advanceTimersByTime(10_000);
await Promise.resolve();
});
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(1);
expect(host.firstElementChild?.getAttribute('data-loading')).toBe('false');
act(() => {
root.unmount();
});
});
it('slows background refreshes while hidden and refreshes immediately when the tab becomes visible again after staleness', async () => {
vi.useFakeTimers();
let visibilityState: DocumentVisibilityState = 'visible';
Object.defineProperty(document, 'visibilityState', {
configurable: true,
get: () => visibilityState,
});
const snapshot = createSnapshot();
apiMocks.refreshCodexAccountSnapshot.mockResolvedValue(snapshot);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
function Harness(): React.ReactElement {
useCodexAccountSnapshot({
enabled: true,
includeRateLimits: true,
});
return React.createElement('div', null, 'hook-mounted');
}
await act(async () => {
root.render(React.createElement(Harness));
await Promise.resolve();
});
apiMocks.refreshCodexAccountSnapshot.mockClear();
await act(async () => {
visibilityState = 'hidden';
document.dispatchEvent(new Event('visibilitychange'));
await Promise.resolve();
});
await act(async () => {
vi.advanceTimersByTime(10_000);
await Promise.resolve();
});
expect(apiMocks.refreshCodexAccountSnapshot).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(50_000);
await Promise.resolve();
});
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(1);
apiMocks.refreshCodexAccountSnapshot.mockClear();
await act(async () => {
vi.advanceTimersByTime(10_000);
visibilityState = 'visible';
document.dispatchEvent(new Event('visibilitychange'));
await Promise.resolve();
});
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(1);
act(() => {
root.unmount();
});
});
});