fix(codex-account): keep account snapshots fresh
This commit is contained in:
parent
1cae11da34
commit
b15de780cb
7 changed files with 298 additions and 15 deletions
|
|
@ -263,7 +263,8 @@ async function resolveCodexBinaryForAccountSnapshot(): Promise<string | null> {
|
||||||
await resolveInteractiveShellEnvBestEffort({
|
await resolveInteractiveShellEnvBestEffort({
|
||||||
timeoutMs: CODEX_BINARY_COLD_RETRY_TIMEOUT_MS,
|
timeoutMs: CODEX_BINARY_COLD_RETRY_TIMEOUT_MS,
|
||||||
fallbackEnv: process.env,
|
fallbackEnv: process.env,
|
||||||
background: false,
|
background: true,
|
||||||
|
source: 'codex-account-binary-discovery',
|
||||||
});
|
});
|
||||||
CodexBinaryResolver.clearCache();
|
CodexBinaryResolver.clearCache();
|
||||||
return CodexBinaryResolver.resolve();
|
return CodexBinaryResolver.resolve();
|
||||||
|
|
@ -293,6 +294,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
||||||
|
|
||||||
private snapshotCache: CodexAccountSnapshotDto | null = null;
|
private snapshotCache: CodexAccountSnapshotDto | null = null;
|
||||||
private snapshotObservedAt = 0;
|
private snapshotObservedAt = 0;
|
||||||
|
private lastPublishedSnapshotUpdatedAtMs = 0;
|
||||||
private refreshPromise: Promise<CodexAccountSnapshotDto> | null = null;
|
private refreshPromise: Promise<CodexAccountSnapshotDto> | null = null;
|
||||||
private pendingRefreshOptions: CodexSnapshotRefreshOptions | null = null;
|
private pendingRefreshOptions: CodexSnapshotRefreshOptions | null = null;
|
||||||
private lastKnownAccount: CodexLastKnownAccount | null = null;
|
private lastKnownAccount: CodexLastKnownAccount | null = null;
|
||||||
|
|
@ -446,6 +448,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
||||||
this.lastKnownAccount = null;
|
this.lastKnownAccount = null;
|
||||||
this.lastKnownRateLimits = null;
|
this.lastKnownRateLimits = null;
|
||||||
this.lastKnownRuntimeContext = null;
|
this.lastKnownRuntimeContext = null;
|
||||||
|
this.lastPublishedSnapshotUpdatedAtMs = 0;
|
||||||
this.activeMutationCount = 0;
|
this.activeMutationCount = 0;
|
||||||
if (this.mutationQueueRelease) {
|
if (this.mutationQueueRelease) {
|
||||||
this.mutationQueueRelease();
|
this.mutationQueueRelease();
|
||||||
|
|
@ -519,7 +522,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
||||||
runtimeContext: freshRuntimeContext,
|
runtimeContext: freshRuntimeContext,
|
||||||
login,
|
login,
|
||||||
rateLimits: this.snapshotCache?.rateLimits ?? null,
|
rateLimits: this.snapshotCache?.rateLimits ?? null,
|
||||||
updatedAt: new Date(now).toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
@ -539,7 +542,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
||||||
localActiveChatgptAccountPresent,
|
localActiveChatgptAccountPresent,
|
||||||
login,
|
login,
|
||||||
rateLimits: null,
|
rateLimits: null,
|
||||||
updatedAt: new Date(now).toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
@ -699,20 +702,27 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
||||||
runtimeContext,
|
runtimeContext,
|
||||||
login,
|
login,
|
||||||
rateLimits,
|
rateLimits,
|
||||||
updatedAt: new Date(now).toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
private setSnapshot(nextSnapshot: CodexAccountSnapshotDto): CodexAccountSnapshotDto {
|
private setSnapshot(nextSnapshot: CodexAccountSnapshotDto): CodexAccountSnapshotDto {
|
||||||
|
const publishedAtMs = Math.max(Date.now(), this.lastPublishedSnapshotUpdatedAtMs + 1);
|
||||||
|
this.lastPublishedSnapshotUpdatedAtMs = publishedAtMs;
|
||||||
|
const publishedSnapshot = {
|
||||||
|
...nextSnapshot,
|
||||||
|
updatedAt: new Date(publishedAtMs).toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
if (this.disposed) {
|
if (this.disposed) {
|
||||||
return deepClone(nextSnapshot);
|
return deepClone(publishedSnapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.snapshotCache = deepClone(nextSnapshot);
|
this.snapshotCache = deepClone(publishedSnapshot);
|
||||||
this.snapshotObservedAt = Date.now();
|
this.snapshotObservedAt = Date.now();
|
||||||
const snapshot = deepClone(nextSnapshot);
|
const snapshot = deepClone(publishedSnapshot);
|
||||||
this.presenter.publish(snapshot);
|
this.presenter.publish(snapshot);
|
||||||
for (const listener of this.listeners) {
|
for (const listener of this.listeners) {
|
||||||
listener(snapshot);
|
listener(snapshot);
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,11 @@ function getRefreshIntervalMs(options: {
|
||||||
: CODEX_VISIBLE_STANDARD_REFRESH_MS;
|
: CODEX_VISIBLE_STANDARD_REFRESH_MS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSnapshotUpdatedAtMs(snapshot: CodexAccountSnapshotDto): number | null {
|
||||||
|
const updatedAtMs = Date.parse(snapshot.updatedAt);
|
||||||
|
return Number.isFinite(updatedAtMs) ? updatedAtMs : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function useCodexAccountSnapshot(options: {
|
export function useCodexAccountSnapshot(options: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
includeRateLimits?: boolean;
|
includeRateLimits?: boolean;
|
||||||
|
|
@ -68,6 +73,7 @@ export function useCodexAccountSnapshot(options: {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [visible, setVisible] = useState(() => isDocumentVisible());
|
const [visible, setVisible] = useState(() => isDocumentVisible());
|
||||||
const lastUpdatedAtRef = useRef<number | null>(null);
|
const lastUpdatedAtRef = useRef<number | null>(null);
|
||||||
|
const snapshotUpdatedAtRef = useRef<number | null>(null);
|
||||||
const initialRefreshDelayMs = options.initialRefreshDelayMs ?? 0;
|
const initialRefreshDelayMs = options.initialRefreshDelayMs ?? 0;
|
||||||
const initialRefreshMaxDelayMs = options.initialRefreshMaxDelayMs;
|
const initialRefreshMaxDelayMs = options.initialRefreshMaxDelayMs;
|
||||||
const [initialRefreshAttempted, setInitialRefreshAttempted] = useState(
|
const [initialRefreshAttempted, setInitialRefreshAttempted] = useState(
|
||||||
|
|
@ -75,6 +81,16 @@ export function useCodexAccountSnapshot(options: {
|
||||||
);
|
);
|
||||||
|
|
||||||
const applySnapshot = useCallback((nextSnapshot: CodexAccountSnapshotDto) => {
|
const applySnapshot = useCallback((nextSnapshot: CodexAccountSnapshotDto) => {
|
||||||
|
const nextUpdatedAtMs = getSnapshotUpdatedAtMs(nextSnapshot);
|
||||||
|
if (
|
||||||
|
nextUpdatedAtMs !== null &&
|
||||||
|
snapshotUpdatedAtRef.current !== null &&
|
||||||
|
nextUpdatedAtMs < snapshotUpdatedAtRef.current
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshotUpdatedAtRef.current = nextUpdatedAtMs ?? Date.now();
|
||||||
lastUpdatedAtRef.current = Date.now();
|
lastUpdatedAtRef.current = Date.now();
|
||||||
setSnapshot(nextSnapshot);
|
setSnapshot(nextSnapshot);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,21 @@ function mergeCodexNativeBackendOption(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeCodexCapabilitiesWithSnapshot(
|
||||||
|
provider: CliProviderStatus,
|
||||||
|
snapshot: CodexAccountSnapshotDto
|
||||||
|
): CliProviderStatus['capabilities'] {
|
||||||
|
if (!snapshot.launchAllowed) {
|
||||||
|
return provider.capabilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...provider.capabilities,
|
||||||
|
teamLaunch: true,
|
||||||
|
oneShot: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function mergeCodexProviderStatusWithSnapshot(
|
export function mergeCodexProviderStatusWithSnapshot(
|
||||||
provider: CliProviderStatus,
|
provider: CliProviderStatus,
|
||||||
snapshot: CodexAccountSnapshotDto | null
|
snapshot: CodexAccountSnapshotDto | null
|
||||||
|
|
@ -166,8 +181,10 @@ export function mergeCodexProviderStatusWithSnapshot(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...provider,
|
...provider,
|
||||||
supported: provider.supported || isCodexBootstrapPlaceholder(provider),
|
supported:
|
||||||
|
provider.supported || isCodexBootstrapPlaceholder(provider) || snapshot.launchAllowed,
|
||||||
authenticated: snapshot.launchAllowed,
|
authenticated: snapshot.launchAllowed,
|
||||||
|
capabilities: mergeCodexCapabilitiesWithSnapshot(provider, snapshot),
|
||||||
authMethod:
|
authMethod:
|
||||||
snapshot.effectiveAuthMode === 'chatgpt'
|
snapshot.effectiveAuthMode === 'chatgpt'
|
||||||
? 'chatgpt'
|
? 'chatgpt'
|
||||||
|
|
|
||||||
|
|
@ -370,9 +370,23 @@ describe('createCodexAccountFeature', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retries Codex binary discovery after cold shell env resolves before publishing runtime-missing', async () => {
|
it('retries Codex binary discovery after cold shell env resolves before publishing runtime-missing', async () => {
|
||||||
binaryResolveMock.mockResolvedValueOnce(null).mockResolvedValue('/usr/local/bin/codex');
|
getCachedShellEnvMock.mockReturnValue(null);
|
||||||
resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({
|
binaryResolveMock.mockImplementation(async () =>
|
||||||
PATH: '/usr/local/bin:/usr/bin:/bin',
|
getCachedShellEnvMock()?.PATH?.includes('/custom/bin') ? '/custom/bin/codex' : null
|
||||||
|
);
|
||||||
|
resolveInteractiveShellEnvBestEffortMock.mockImplementation(async (options?: {
|
||||||
|
background?: boolean;
|
||||||
|
fallbackEnv?: NodeJS.ProcessEnv;
|
||||||
|
}) => {
|
||||||
|
if (options?.background === false) {
|
||||||
|
return options.fallbackEnv ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const shellEnv = {
|
||||||
|
PATH: '/custom/bin:/usr/bin:/bin',
|
||||||
|
};
|
||||||
|
getCachedShellEnvMock.mockReturnValue(shellEnv);
|
||||||
|
return shellEnv;
|
||||||
});
|
});
|
||||||
readAccountMock.mockResolvedValue({
|
readAccountMock.mockResolvedValue({
|
||||||
account: createAccountResponse(),
|
account: createAccountResponse(),
|
||||||
|
|
@ -395,6 +409,8 @@ describe('createCodexAccountFeature', () => {
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
timeoutMs: 12_000,
|
timeoutMs: 12_000,
|
||||||
fallbackEnv: process.env,
|
fallbackEnv: process.env,
|
||||||
|
background: true,
|
||||||
|
source: 'codex-account-binary-discovery',
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(binaryClearCacheMock).toHaveBeenCalledTimes(1);
|
expect(binaryClearCacheMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
@ -407,6 +423,99 @@ describe('createCodexAccountFeature', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('timestamps snapshots at publication time after a slow account read', async () => {
|
||||||
|
vi.useFakeTimers({
|
||||||
|
toFake: ['Date'],
|
||||||
|
});
|
||||||
|
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
|
||||||
|
const accountReadDeferred = createDeferred<void>();
|
||||||
|
readAccountMock.mockImplementation(async () => {
|
||||||
|
await accountReadDeferred.promise;
|
||||||
|
return {
|
||||||
|
account: createAccountResponse(),
|
||||||
|
initialize: {
|
||||||
|
codexHome: '/Users/test/.codex',
|
||||||
|
platformFamily: 'unix',
|
||||||
|
platformOs: 'macos',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const feature = createCodexAccountFeature({
|
||||||
|
logger: createLoggerPort(),
|
||||||
|
configManager: createConfigManager('chatgpt'),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const snapshotPromise = feature.refreshSnapshot();
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(readAccountMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.setSystemTime(new Date('2026-01-01T00:00:05.000Z'));
|
||||||
|
accountReadDeferred.resolve();
|
||||||
|
|
||||||
|
const snapshot = await snapshotPromise;
|
||||||
|
|
||||||
|
expect(snapshot.updatedAt).toBe('2026-01-01T00:00:05.000Z');
|
||||||
|
expect(snapshot.appServerState).toBe('healthy');
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
await feature.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publishes strictly increasing snapshot timestamps within the same millisecond', async () => {
|
||||||
|
vi.useFakeTimers({
|
||||||
|
toFake: ['Date'],
|
||||||
|
});
|
||||||
|
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
|
||||||
|
readAccountMock.mockResolvedValue({
|
||||||
|
account: createAccountResponse(),
|
||||||
|
initialize: {
|
||||||
|
codexHome: '/Users/test/.codex',
|
||||||
|
platformFamily: 'unix',
|
||||||
|
platformOs: 'macos',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const feature = createCodexAccountFeature({
|
||||||
|
logger: createLoggerPort(),
|
||||||
|
configManager: createConfigManager('chatgpt'),
|
||||||
|
});
|
||||||
|
const publishedSnapshots: CodexAccountSnapshotDto[] = [];
|
||||||
|
const unsubscribe = feature.subscribe((snapshot) => {
|
||||||
|
publishedSnapshots.push(snapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await feature.refreshSnapshot();
|
||||||
|
emitLoginState({
|
||||||
|
status: 'pending',
|
||||||
|
error: null,
|
||||||
|
startedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
authUrl: 'https://chatgpt.com/auth',
|
||||||
|
});
|
||||||
|
emitLoginState({
|
||||||
|
status: 'cancelled',
|
||||||
|
error: null,
|
||||||
|
startedAt: null,
|
||||||
|
authUrl: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(publishedSnapshots.map((snapshot) => Date.parse(snapshot.updatedAt))).toEqual([
|
||||||
|
1_767_225_600_000,
|
||||||
|
1_767_225_600_001,
|
||||||
|
1_767_225_600_002,
|
||||||
|
]);
|
||||||
|
expect(publishedSnapshots.at(-1)?.login.status).toBe('cancelled');
|
||||||
|
} finally {
|
||||||
|
unsubscribe();
|
||||||
|
vi.useRealTimers();
|
||||||
|
await feature.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('still reports runtime-missing after the cold binary retry cannot find Codex', async () => {
|
it('still reports runtime-missing after the cold binary retry cannot find Codex', async () => {
|
||||||
binaryResolveMock.mockResolvedValue(null);
|
binaryResolveMock.mockResolvedValue(null);
|
||||||
resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({
|
resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,66 @@ describe('mergeCodexProviderStatusWithSnapshot', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clears a stale runtime-missing provider once the live snapshot is ready', () => {
|
||||||
|
const baseProvider = createBaseCodexProvider();
|
||||||
|
const baseConnection = baseProvider.connection!;
|
||||||
|
const merged = mergeCodexProviderStatusWithSnapshot(
|
||||||
|
{
|
||||||
|
...baseProvider,
|
||||||
|
supported: false,
|
||||||
|
authenticated: false,
|
||||||
|
verificationState: 'error',
|
||||||
|
statusMessage: 'Codex CLI not found. Install Codex to use native account management.',
|
||||||
|
capabilities: {
|
||||||
|
teamLaunch: false,
|
||||||
|
oneShot: false,
|
||||||
|
extensions: createDefaultCliExtensionCapabilities(),
|
||||||
|
},
|
||||||
|
availableBackends: [
|
||||||
|
{
|
||||||
|
id: 'codex-native',
|
||||||
|
label: 'Codex native',
|
||||||
|
description: 'Use codex exec JSON mode.',
|
||||||
|
selectable: false,
|
||||||
|
recommended: true,
|
||||||
|
available: false,
|
||||||
|
state: 'runtime-missing',
|
||||||
|
audience: 'general',
|
||||||
|
statusMessage: 'Codex CLI not found',
|
||||||
|
detailMessage: 'Codex CLI not found',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connection: {
|
||||||
|
...baseConnection,
|
||||||
|
codex: {
|
||||||
|
...baseConnection.codex!,
|
||||||
|
appServerState: 'runtime-missing',
|
||||||
|
appServerStatusMessage: 'Codex CLI not found',
|
||||||
|
launchAllowed: false,
|
||||||
|
launchIssueMessage: 'Codex CLI not found',
|
||||||
|
launchReadinessState: 'runtime_missing',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createReadyChatgptSnapshot()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(merged.supported).toBe(true);
|
||||||
|
expect(merged.authenticated).toBe(true);
|
||||||
|
expect(merged.verificationState).toBe('verified');
|
||||||
|
expect(merged.statusMessage).toBe('ChatGPT account ready');
|
||||||
|
expect(merged.capabilities.teamLaunch).toBe(true);
|
||||||
|
expect(merged.capabilities.oneShot).toBe(true);
|
||||||
|
expect(merged.connection?.codex?.appServerState).toBe('healthy');
|
||||||
|
expect(merged.connection?.codex?.launchReadinessState).toBe('ready_chatgpt');
|
||||||
|
expect(merged.availableBackends?.find((option) => option.id === 'codex-native')).toMatchObject({
|
||||||
|
available: true,
|
||||||
|
selectable: true,
|
||||||
|
state: 'ready',
|
||||||
|
statusMessage: 'Ready',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('hydrates codex connection truth even when the stale provider payload had no connection block', () => {
|
it('hydrates codex connection truth even when the stale provider payload had no connection block', () => {
|
||||||
const merged = mergeCodexProviderStatusWithSnapshot(
|
const merged = mergeCodexProviderStatusWithSnapshot(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,9 @@ const apiMocks = vi.hoisted(() => ({
|
||||||
startCodexChatgptLogin: vi.fn(),
|
startCodexChatgptLogin: vi.fn(),
|
||||||
cancelCodexChatgptLogin: vi.fn(),
|
cancelCodexChatgptLogin: vi.fn(),
|
||||||
logoutCodexAccount: vi.fn(),
|
logoutCodexAccount: vi.fn(),
|
||||||
onCodexAccountSnapshotChanged: vi.fn(() => () => undefined),
|
onCodexAccountSnapshotChanged: vi.fn<
|
||||||
|
(callback: (event: unknown, snapshot: CodexAccountSnapshotDto) => void) => () => void
|
||||||
|
>(() => () => undefined),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
type IdleCallbackForTest = (deadline: {
|
type IdleCallbackForTest = (deadline: {
|
||||||
|
|
@ -71,6 +73,16 @@ function createSnapshot(): CodexAccountSnapshotDto {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withSnapshotOverrides(
|
||||||
|
snapshot: CodexAccountSnapshotDto,
|
||||||
|
overrides: Partial<CodexAccountSnapshotDto>
|
||||||
|
): CodexAccountSnapshotDto {
|
||||||
|
return {
|
||||||
|
...snapshot,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function createDeferred<T>() {
|
function createDeferred<T>() {
|
||||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||||
let reject!: (reason?: unknown) => void;
|
let reject!: (reason?: unknown) => void;
|
||||||
|
|
@ -133,6 +145,65 @@ describe('useCodexAccountSnapshot', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 () => {
|
it('can defer the initial Codex snapshot without starting interval refreshes first', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const snapshot = createSnapshot();
|
const snapshot = createSnapshot();
|
||||||
|
|
|
||||||
|
|
@ -228,11 +228,11 @@ describe('useRuntimeProviderManagement', () => {
|
||||||
const root = createRoot(host);
|
const root = createRoot(host);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(React.createElement(ConfigurableHarness, { enabled: true }));
|
root.render(React.createElement(ConfigurableHarness, { enabled: true }));
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(state?.error).toContain('wrong runtime binary');
|
await vi.waitFor(() => {
|
||||||
|
expect(state?.error).toContain('wrong runtime binary');
|
||||||
|
});
|
||||||
expect(state?.errorDiagnostics?.binaryPath).toBe('/opt/homebrew/bin/opencode');
|
expect(state?.errorDiagnostics?.binaryPath).toBe('/opt/homebrew/bin/opencode');
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue