588 lines
17 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|