import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { createCodexAccountFeature } from '../../../../src/features/codex-account/main/composition/createCodexAccountFeature'; import type { CodexAccountLoginStatus, CodexAccountSnapshotDto, CodexLoginStateDto, } from '@features/codex-account/contracts'; const { apiKeyLookupMock, binaryResolveMock, detectLocalAccountStateMock, getCachedShellEnvMock, loginCancelMock, loginDisposeMock, loginSettledListeners, loginStartMock, loginStateContainer, loginStateListeners, logoutMock, readAccountMock, readAccountSnapshotMock, readRateLimitsMock, } = vi.hoisted(() => ({ binaryResolveMock: vi.fn(), apiKeyLookupMock: vi.fn(), detectLocalAccountStateMock: vi.fn(), getCachedShellEnvMock: vi.fn(), readAccountMock: vi.fn(), readAccountSnapshotMock: vi.fn(), readRateLimitsMock: vi.fn(), logoutMock: vi.fn(), loginStartMock: vi.fn(), loginCancelMock: vi.fn(), loginDisposeMock: vi.fn(), loginStateContainer: { current: { status: 'idle' as CodexAccountLoginStatus, error: null as string | null, startedAt: null as string | null, authUrl: null as string | null, } as CodexLoginStateDto, }, loginStateListeners: new Set<() => void>(), loginSettledListeners: new Set<() => void>(), })); const originalOpenAiApiKey = process.env.OPENAI_API_KEY; const originalCodexApiKey = process.env.CODEX_API_KEY; function emitLoginState(nextState: CodexLoginStateDto): void { loginStateContainer.current = structuredClone(nextState); for (const listener of loginStateListeners) { listener(); } } vi.mock('../../../../src/main/services/extensions', () => ({ ApiKeyService: class MockApiKeyService { lookupPreferred = apiKeyLookupMock; }, })); vi.mock('../../../../src/main/utils/shellEnv', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getCachedShellEnv: getCachedShellEnvMock, }; }); vi.mock('../../../../src/main/services/infrastructure/codexAppServer', () => ({ CodexBinaryResolver: { resolve: binaryResolveMock, }, CodexAppServerSessionFactory: class MockCodexAppServerSessionFactory {}, JsonRpcStdioClient: class MockJsonRpcStdioClient {}, })); vi.mock( '../../../../src/features/codex-account/main/infrastructure/detectCodexLocalAccountArtifacts', () => ({ detectCodexLocalAccountState: detectLocalAccountStateMock, detectCodexLocalAccountArtifacts: async () => (await detectLocalAccountStateMock()).hasArtifacts, ensureCodexLegacyAuthFromActiveAccount: vi.fn().mockResolvedValue(null), }) ); vi.mock( '../../../../src/features/codex-account/main/infrastructure/CodexAccountAppServerClient', () => ({ CodexAccountAppServerClient: class MockCodexAccountAppServerClient { readAccountSnapshot = readAccountSnapshotMock; readAccount = readAccountMock; readRateLimits = readRateLimitsMock; logout = logoutMock; }, }) ); vi.mock( '../../../../src/features/codex-account/main/infrastructure/CodexLoginSessionManager', () => ({ CodexLoginSessionManager: class MockCodexLoginSessionManager { subscribe(listener: () => void): () => void { loginStateListeners.add(listener); return (): void => { loginStateListeners.delete(listener); }; } onSettled(listener: () => void): () => void { loginSettledListeners.add(listener); return (): void => { loginSettledListeners.delete(listener); }; } getState(): CodexLoginStateDto { return structuredClone(loginStateContainer.current); } async start(): Promise { await loginStartMock(); } async cancel(): Promise { await loginCancelMock(); } async dispose(): Promise { await loginDisposeMock(); } }, }) ); function createLoggerPort() { return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), }; } function createConfigManager(preferredAuthMode: 'auto' | 'chatgpt' | 'api_key' = 'auto') { return { getConfig: () => ({ providerConnections: { codex: { preferredAuthMode, }, }, }), }; } function createAccountResponse(overrides?: Partial<{ requiresOpenaiAuth: boolean; account: { type: 'chatgpt'; email: string; planType: 'pro' | 'plus' } | null; }>) { return { account: overrides && 'account' in overrides ? overrides.account ?? null : { type: 'chatgpt' as const, email: 'user@example.com', planType: 'pro' as const, }, requiresOpenaiAuth: overrides?.requiresOpenaiAuth ?? true, }; } function createRateLimitsResponse() { return { 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' as const, }, rateLimitsByLimitId: null, }; } function createDeferred(): { promise: Promise; resolve: (value: T | PromiseLike) => void; reject: (error: unknown) => void; } { let resolve: ((value: T | PromiseLike) => void) | null = null; let reject: ((error: unknown) => void) | null = null; const promise = new Promise((fulfill, fail) => { resolve = fulfill; reject = fail; }); if (!resolve || !reject) { throw new Error('Failed to create deferred promise.'); } return { promise, resolve, reject, }; } describe('createCodexAccountFeature', () => { beforeEach(() => { vi.clearAllMocks(); delete process.env.OPENAI_API_KEY; delete process.env.CODEX_API_KEY; binaryResolveMock.mockResolvedValue('/usr/local/bin/codex'); apiKeyLookupMock.mockResolvedValue(null); detectLocalAccountStateMock.mockResolvedValue({ hasArtifacts: false, hasActiveChatgptAccount: false, }); getCachedShellEnvMock.mockReturnValue({}); readAccountSnapshotMock.mockReset(); readAccountMock.mockReset(); readRateLimitsMock.mockReset(); logoutMock.mockReset(); loginStartMock.mockReset(); loginCancelMock.mockReset(); loginDisposeMock.mockReset(); loginStateContainer.current = { status: 'idle', error: null, startedAt: null, authUrl: null, }; loginStateListeners.clear(); loginSettledListeners.clear(); readAccountSnapshotMock.mockImplementation( async (options: { binaryPath: string; env: NodeJS.ProcessEnv; refreshToken?: boolean; includeRateLimits?: boolean; }) => { const account = await readAccountMock(options); if (options.includeRateLimits !== true) { return { ...account, rateLimits: null, }; } try { return { ...account, rateLimits: { ok: true, payload: await readRateLimitsMock(options), }, }; } catch (error) { return { ...account, rateLimits: { ok: false, error, }, }; } } ); }); afterAll(() => { if (typeof originalOpenAiApiKey === 'string') { process.env.OPENAI_API_KEY = originalOpenAiApiKey; } else { delete process.env.OPENAI_API_KEY; } if (typeof originalCodexApiKey === 'string') { process.env.CODEX_API_KEY = originalCodexApiKey; } else { delete process.env.CODEX_API_KEY; } }); it('builds a healthy snapshot from app-server account truth, API-key availability, and rate limits', async () => { getCachedShellEnvMock.mockReturnValue({ OPENAI_API_KEY: 'env-openai-key', }); readAccountMock.mockResolvedValue({ account: createAccountResponse(), initialize: { codexHome: '/Users/test/.codex', platformFamily: 'unix', platformOs: 'macos', }, }); readRateLimitsMock.mockResolvedValue(createRateLimitsResponse()); const feature = createCodexAccountFeature({ logger: createLoggerPort(), configManager: createConfigManager('auto'), }); try { const snapshot = await feature.refreshSnapshot({ includeRateLimits: true }); expect(snapshot).toMatchObject>({ preferredAuthMode: 'auto', effectiveAuthMode: 'chatgpt', appServerState: 'healthy', managedAccount: { type: 'chatgpt', email: 'user@example.com', planType: 'pro', }, apiKey: { available: true, source: 'environment', sourceLabel: 'Detected from OPENAI_API_KEY', }, runtimeContext: { binaryPath: '/usr/local/bin/codex', codexHome: '/Users/test/.codex', }, launchAllowed: true, launchReadinessState: 'ready_both', }); expect(snapshot.rateLimits?.planType).toBe('pro'); expect(snapshot.rateLimits?.primary?.usedPercent).toBe(77); expect(readAccountMock).toHaveBeenCalledWith( expect.objectContaining({ binaryPath: '/usr/local/bin/codex', refreshToken: false, }) ); expect(readRateLimitsMock).toHaveBeenCalledTimes(1); } finally { await feature.dispose(); } }); it('reuses a fresh refresh snapshot when the request does not need stronger data', async () => { readAccountMock.mockResolvedValue({ account: createAccountResponse(), initialize: { codexHome: '/Users/test/.codex', platformFamily: 'unix', platformOs: 'macos', }, }); const feature = createCodexAccountFeature({ logger: createLoggerPort(), configManager: createConfigManager('chatgpt'), }); try { const firstSnapshot = await feature.refreshSnapshot(); const secondSnapshot = await feature.refreshSnapshot(); expect(firstSnapshot.managedAccount?.email).toBe('user@example.com'); expect(secondSnapshot.managedAccount?.email).toBe('user@example.com'); expect(readAccountMock).toHaveBeenCalledTimes(1); expect(readAccountSnapshotMock).toHaveBeenCalledTimes(1); } finally { await feature.dispose(); } }); it('does not reuse a snapshot without rate limits for an includeRateLimits refresh', async () => { readAccountMock.mockResolvedValue({ account: createAccountResponse(), initialize: { codexHome: '/Users/test/.codex', platformFamily: 'unix', platformOs: 'macos', }, }); readRateLimitsMock.mockResolvedValue(createRateLimitsResponse()); const feature = createCodexAccountFeature({ logger: createLoggerPort(), configManager: createConfigManager('chatgpt'), }); try { const firstSnapshot = await feature.refreshSnapshot(); const secondSnapshot = await feature.refreshSnapshot({ includeRateLimits: true }); expect(firstSnapshot.rateLimits).toBeNull(); expect(secondSnapshot.rateLimits?.primary?.usedPercent).toBe(77); expect(readAccountMock).toHaveBeenCalledTimes(2); expect(readRateLimitsMock).toHaveBeenCalledTimes(1); expect(readAccountSnapshotMock.mock.calls[1]?.[0]).toMatchObject({ includeRateLimits: true, }); } finally { await feature.dispose(); } }); it('force-refreshes account truth even when a fresh snapshot exists', async () => { readAccountMock.mockResolvedValue({ account: createAccountResponse(), initialize: { codexHome: '/Users/test/.codex', platformFamily: 'unix', platformOs: 'macos', }, }); const feature = createCodexAccountFeature({ logger: createLoggerPort(), configManager: createConfigManager('chatgpt'), }); try { await feature.refreshSnapshot(); await feature.refreshSnapshot({ forceRefreshToken: true }); expect(readAccountMock).toHaveBeenCalledTimes(2); expect(readAccountMock.mock.calls[1]?.[0]).toMatchObject({ refreshToken: true, }); } finally { await feature.dispose(); } }); it('does not serve stale cached auth state while logout mutation is active', async () => { const logoutDeferred = createDeferred(); readAccountMock .mockResolvedValueOnce({ account: createAccountResponse(), initialize: { codexHome: '/Users/test/.codex', platformFamily: 'unix', platformOs: 'macos', }, }) .mockResolvedValue({ account: createAccountResponse({ account: null, requiresOpenaiAuth: false }), initialize: { codexHome: '/Users/test/.codex', platformFamily: 'unix', platformOs: 'macos', }, }); logoutMock.mockReturnValue(logoutDeferred.promise); readRateLimitsMock.mockResolvedValue(createRateLimitsResponse()); const feature = createCodexAccountFeature({ logger: createLoggerPort(), configManager: createConfigManager('chatgpt'), }); try { const initialSnapshot = await feature.refreshSnapshot(); expect(initialSnapshot.managedAccount?.email).toBe('user@example.com'); const logoutPromise = feature.logout(); await vi.waitFor(() => { expect(logoutMock).toHaveBeenCalledTimes(1); }); let refreshSettled = false; const refreshDuringLogout = feature.refreshSnapshot().then((snapshot) => { refreshSettled = true; return snapshot; }); await Promise.resolve(); expect(refreshSettled).toBe(false); logoutDeferred.resolve(); const [duringLogoutSnapshot, afterLogoutSnapshot] = await Promise.all([ refreshDuringLogout, logoutPromise, ]); expect(duringLogoutSnapshot.managedAccount).toBeNull(); expect(afterLogoutSnapshot.managedAccount).toBeNull(); expect(afterLogoutSnapshot.requiresOpenaiAuth).toBe(false); expect(readAccountMock.mock.calls.at(-1)?.[0]).toMatchObject({ refreshToken: true, }); } finally { await feature.dispose(); } }); it('coalesces mixed snapshot callers and preserves auth truth across logout end-to-end', async () => { const firstRead = createDeferred<{ account: ReturnType; initialize: { codexHome: string; platformFamily: string; platformOs: string }; }>(); const healthyRead = { account: createAccountResponse(), initialize: { codexHome: '/Users/test/.codex', platformFamily: 'unix', platformOs: 'macos', }, }; readAccountMock .mockReturnValueOnce(firstRead.promise) .mockResolvedValueOnce(healthyRead) .mockResolvedValueOnce({ account: createAccountResponse({ account: null, requiresOpenaiAuth: false }), initialize: { codexHome: '/Users/test/.codex', platformFamily: 'unix', platformOs: 'macos', }, }); readRateLimitsMock.mockResolvedValue(createRateLimitsResponse()); logoutMock.mockResolvedValue({}); const feature = createCodexAccountFeature({ logger: createLoggerPort(), configManager: createConfigManager('chatgpt'), }); try { const passiveSnapshot = feature.getSnapshot(); const rateLimitedSnapshot = feature.refreshSnapshot({ includeRateLimits: true }); const launchReadiness = feature.getLaunchReadiness(); await vi.waitFor(() => { expect(readAccountMock).toHaveBeenCalledTimes(1); }); firstRead.resolve(healthyRead); const [passiveResult, rateLimitedResult, readinessResult] = await Promise.all([ passiveSnapshot, rateLimitedSnapshot, launchReadiness, ]); expect(passiveResult.managedAccount?.email).toBe('user@example.com'); expect(rateLimitedResult.rateLimits?.primary?.usedPercent).toBe(77); expect(readinessResult.launchAllowed).toBe(true); expect(readAccountMock).toHaveBeenCalledTimes(2); expect(readRateLimitsMock).toHaveBeenCalledTimes(1); const cachedRateLimitedResult = await feature.refreshSnapshot({ includeRateLimits: true }); expect(cachedRateLimitedResult.rateLimits?.primary?.usedPercent).toBe(77); expect(readAccountMock).toHaveBeenCalledTimes(2); expect(readRateLimitsMock).toHaveBeenCalledTimes(1); const logoutResult = await feature.logout(); expect(logoutResult.managedAccount).toBeNull(); expect(logoutResult.requiresOpenaiAuth).toBe(false); expect(logoutResult.launchAllowed).toBe(false); expect(readAccountMock).toHaveBeenCalledTimes(3); expect(readAccountMock.mock.calls.at(-1)?.[0]).toMatchObject({ refreshToken: true, }); const cachedLoggedOutResult = await feature.getSnapshot(); expect(cachedLoggedOutResult.managedAccount).toBeNull(); expect(readAccountMock).toHaveBeenCalledTimes(3); } finally { await feature.dispose(); } }); it('keeps account snapshot healthy when the optional rate limits read fails', async () => { readAccountMock.mockResolvedValue({ account: createAccountResponse(), initialize: { codexHome: '/Users/test/.codex', platformFamily: 'unix', platformOs: 'macos', }, }); readRateLimitsMock.mockRejectedValue(new Error('rate limit service unavailable')); const logger = createLoggerPort(); const feature = createCodexAccountFeature({ logger, configManager: createConfigManager('chatgpt'), }); try { const snapshot = await feature.refreshSnapshot({ includeRateLimits: true }); expect(snapshot.appServerState).toBe('healthy'); expect(snapshot.managedAccount?.email).toBe('user@example.com'); expect(snapshot.rateLimits).toBeNull(); expect(logger.warn).toHaveBeenCalledWith('codex account rate limits refresh failed', { error: 'rate limit service unavailable', }); } finally { await feature.dispose(); } }); it('keeps last known rate limits visible during a transient optional rate limit refresh failure', async () => { readAccountMock.mockResolvedValue({ account: createAccountResponse(), initialize: { codexHome: '/Users/test/.codex', platformFamily: 'unix', platformOs: 'macos', }, }); readRateLimitsMock .mockResolvedValueOnce(createRateLimitsResponse()) .mockRejectedValueOnce(new Error('codex account authentication required to read rate limits')); const logger = createLoggerPort(); const feature = createCodexAccountFeature({ logger, configManager: createConfigManager('chatgpt'), }); const dateNowSpy = vi.spyOn(Date, 'now'); try { dateNowSpy.mockReturnValue(1_776_000_000_000); const firstSnapshot = await feature.refreshSnapshot({ includeRateLimits: true }); dateNowSpy.mockReturnValue(1_776_000_060_000); const secondSnapshot = await feature.refreshSnapshot({ includeRateLimits: true }); expect(firstSnapshot.rateLimits?.primary?.usedPercent).toBe(77); expect(secondSnapshot.appServerState).toBe('healthy'); expect(secondSnapshot.managedAccount?.email).toBe('user@example.com'); expect(secondSnapshot.rateLimits?.primary?.usedPercent).toBe(77); expect(logger.warn).toHaveBeenCalledWith('codex account rate limits refresh failed', { error: 'codex account authentication required to read rate limits', }); } finally { dateNowSpy.mockRestore(); await feature.dispose(); } }); it('does not reuse stale rate limits after the active ChatGPT account changes', async () => { readAccountMock .mockResolvedValueOnce({ account: createAccountResponse({ account: { type: 'chatgpt', email: 'first@example.com', planType: 'pro', }, }), initialize: { codexHome: '/Users/test/.codex', platformFamily: 'unix', platformOs: 'macos', }, }) .mockResolvedValueOnce({ account: createAccountResponse({ account: { type: 'chatgpt', email: 'second@example.com', planType: 'pro', }, }), initialize: { codexHome: '/Users/test/.codex', platformFamily: 'unix', platformOs: 'macos', }, }); readRateLimitsMock .mockResolvedValueOnce(createRateLimitsResponse()) .mockRejectedValueOnce(new Error('rate limit service unavailable')); const feature = createCodexAccountFeature({ logger: createLoggerPort(), configManager: createConfigManager('chatgpt'), }); const dateNowSpy = vi.spyOn(Date, 'now'); try { dateNowSpy.mockReturnValue(1_776_000_000_000); const firstSnapshot = await feature.refreshSnapshot({ includeRateLimits: true }); dateNowSpy.mockReturnValue(1_776_000_060_000); const secondSnapshot = await feature.refreshSnapshot({ includeRateLimits: true }); expect(firstSnapshot.managedAccount?.email).toBe('first@example.com'); expect(firstSnapshot.rateLimits?.primary?.usedPercent).toBe(77); expect(secondSnapshot.managedAccount?.email).toBe('second@example.com'); expect(secondSnapshot.rateLimits).toBeNull(); } finally { dateNowSpy.mockRestore(); await feature.dispose(); } }); it('keeps the last known managed account during a transient degraded read', async () => { readAccountMock .mockResolvedValueOnce({ account: createAccountResponse(), initialize: { codexHome: '/Users/test/.codex', platformFamily: 'unix', platformOs: 'macos', }, }) .mockRejectedValueOnce(new Error('temporary app-server timeout')); const logger = createLoggerPort(); const feature = createCodexAccountFeature({ logger, configManager: createConfigManager('chatgpt'), }); try { const firstSnapshot = await feature.refreshSnapshot(); const degradedSnapshot = await feature.refreshSnapshot({ forceRefreshToken: true }); expect(firstSnapshot.managedAccount?.email).toBe('user@example.com'); expect(degradedSnapshot.appServerState).toBe('degraded'); expect(degradedSnapshot.appServerStatusMessage).toContain('temporary app-server timeout'); expect(degradedSnapshot.managedAccount).toMatchObject({ type: 'chatgpt', email: 'user@example.com', }); expect(degradedSnapshot.runtimeContext).toEqual({ binaryPath: '/usr/local/bin/codex', codexHome: '/Users/test/.codex', }); expect(degradedSnapshot.launchAllowed).toBe(true); expect(logger.warn).not.toHaveBeenCalledWith( expect.stringContaining('false logout'), expect.anything() ); } finally { await feature.dispose(); } }); it('keeps the last known ChatGPT managed account during a transient empty account read after HMR-style reconnect flicker', async () => { detectLocalAccountStateMock.mockResolvedValue({ hasArtifacts: true, hasActiveChatgptAccount: true, }); readAccountMock .mockResolvedValueOnce({ account: createAccountResponse(), initialize: { codexHome: '/Users/test/.codex', platformFamily: 'unix', platformOs: 'macos', }, }) .mockResolvedValueOnce({ account: createAccountResponse({ account: null, requiresOpenaiAuth: true }), initialize: { codexHome: '/Users/test/.codex', platformFamily: 'unix', platformOs: 'macos', }, }); const feature = createCodexAccountFeature({ logger: createLoggerPort(), configManager: createConfigManager('chatgpt'), }); const dateNowSpy = vi.spyOn(Date, 'now'); try { dateNowSpy.mockReturnValue(1_776_000_000_000); const firstSnapshot = await feature.refreshSnapshot(); dateNowSpy.mockReturnValue(1_776_000_006_000); const secondSnapshot = await feature.refreshSnapshot({ forceRefreshToken: true }); expect(firstSnapshot.managedAccount?.email).toBe('user@example.com'); expect(secondSnapshot.managedAccount).toMatchObject({ type: 'chatgpt', email: 'user@example.com', }); expect(secondSnapshot.launchAllowed).toBe(true); expect(secondSnapshot.launchReadinessState).toBe('ready_chatgpt'); expect(secondSnapshot.launchIssueMessage).toBeNull(); expect(readAccountMock).toHaveBeenCalledTimes(2); } finally { dateNowSpy.mockRestore(); await feature.dispose(); } }); it('classifies a locally selected ChatGPT account without a usable managed session as reconnect-needed', async () => { detectLocalAccountStateMock.mockResolvedValue({ hasArtifacts: true, hasActiveChatgptAccount: true, }); readAccountMock.mockResolvedValue({ account: createAccountResponse({ account: null, requiresOpenaiAuth: true }), initialize: { codexHome: '/Users/test/.codex', platformFamily: 'unix', platformOs: 'macos', }, }); const feature = createCodexAccountFeature({ logger: createLoggerPort(), configManager: createConfigManager('chatgpt'), }); try { const snapshot = await feature.refreshSnapshot(); expect(snapshot.localAccountArtifactsPresent).toBe(true); expect(snapshot.localActiveChatgptAccountPresent).toBe(true); expect(snapshot.launchAllowed).toBe(false); expect(snapshot.launchReadinessState).toBe('missing_auth'); expect(snapshot.launchIssueMessage).toContain('Reconnect ChatGPT'); } finally { await feature.dispose(); } }); it('runs a stronger queued refresh after a passive read is already in flight', async () => { let resolveFirstRead: ((value: unknown) => void) | null = null; readAccountMock .mockImplementationOnce( () => new Promise((resolve) => { resolveFirstRead = resolve; }) ) .mockResolvedValueOnce({ account: createAccountResponse(), initialize: { codexHome: '/Users/test/.codex', platformFamily: 'unix', platformOs: 'macos', }, }); readRateLimitsMock.mockResolvedValue(createRateLimitsResponse()); const feature = createCodexAccountFeature({ logger: createLoggerPort(), configManager: createConfigManager('auto'), }); try { const firstRefresh = feature.refreshSnapshot(); const strongerRefresh = feature.refreshSnapshot({ includeRateLimits: true, forceRefreshToken: true, }); await vi.waitFor(() => { expect(resolveFirstRead).not.toBeNull(); }); const completeFirstRead = resolveFirstRead as ((value: unknown) => void) | null; if (!completeFirstRead) { throw new Error('Expected the first account read to remain pending.'); } completeFirstRead({ account: createAccountResponse(), initialize: { codexHome: '/Users/test/.codex', platformFamily: 'unix', platformOs: 'macos', }, }); const [firstSnapshot, strongerSnapshot] = await Promise.all([firstRefresh, strongerRefresh]); expect(firstSnapshot.managedAccount?.email).toBe('user@example.com'); expect(strongerSnapshot.rateLimits?.primary?.usedPercent).toBe(77); expect(readAccountMock).toHaveBeenCalledTimes(2); expect(readAccountMock.mock.calls[0]?.[0]).toMatchObject({ refreshToken: false, }); expect(readAccountMock.mock.calls[1]?.[0]).toMatchObject({ refreshToken: true, }); expect(readRateLimitsMock).toHaveBeenCalledTimes(1); } finally { await feature.dispose(); } }); it('logs out and refreshes to the new logged-out truth instead of keeping stale account state', async () => { readAccountMock .mockResolvedValueOnce({ account: createAccountResponse(), initialize: { codexHome: '/Users/test/.codex', platformFamily: 'unix', platformOs: 'macos', }, }) .mockResolvedValueOnce({ account: createAccountResponse({ account: null, requiresOpenaiAuth: false }), initialize: { codexHome: '/Users/test/.codex', platformFamily: 'unix', platformOs: 'macos', }, }); readRateLimitsMock.mockResolvedValue(createRateLimitsResponse()); logoutMock.mockResolvedValue({}); const feature = createCodexAccountFeature({ logger: createLoggerPort(), configManager: createConfigManager('chatgpt'), }); try { const initialSnapshot = await feature.refreshSnapshot(); const afterLogout = await feature.logout(); expect(initialSnapshot.managedAccount?.type).toBe('chatgpt'); expect(logoutMock).toHaveBeenCalledTimes(1); expect(afterLogout.managedAccount).toBeNull(); expect(afterLogout.requiresOpenaiAuth).toBe(false); expect(afterLogout.launchAllowed).toBe(false); expect(afterLogout.launchReadinessState).toBe('missing_auth'); expect(readAccountMock.mock.calls.at(-1)?.[0]).toMatchObject({ refreshToken: true, }); } finally { await feature.dispose(); } }); it('publishes the pending login state immediately after login start without waiting for a full refresh', async () => { readAccountMock.mockResolvedValue({ account: createAccountResponse(), initialize: { codexHome: '/Users/test/.codex', platformFamily: 'unix', platformOs: 'macos', }, }); loginStartMock.mockImplementation(() => { emitLoginState({ status: 'pending', error: null, startedAt: '2026-04-20T12:00:00.000Z', authUrl: 'https://chatgpt.com/auth', }); }); const feature = createCodexAccountFeature({ logger: createLoggerPort(), configManager: createConfigManager('chatgpt'), }); try { await feature.refreshSnapshot(); const pendingSnapshot = await feature.startChatgptLogin(); expect(pendingSnapshot.login).toMatchObject({ status: 'pending', startedAt: '2026-04-20T12:00:00.000Z', authUrl: 'https://chatgpt.com/auth', }); expect(loginStartMock).toHaveBeenCalledTimes(1); } finally { await feature.dispose(); } }); it('publishes a cancelled login snapshot immediately and then forces a settled refresh', async () => { readAccountMock.mockResolvedValue({ account: createAccountResponse(), initialize: { codexHome: '/Users/test/.codex', platformFamily: 'unix', platformOs: 'macos', }, }); readRateLimitsMock.mockResolvedValue(createRateLimitsResponse()); emitLoginState({ status: 'pending', error: null, startedAt: '2026-04-20T12:00:00.000Z', authUrl: 'https://chatgpt.com/auth', }); loginCancelMock.mockImplementation(() => { emitLoginState({ status: 'cancelled', error: null, startedAt: null, authUrl: null, }); for (const listener of loginSettledListeners) { listener(); } }); const feature = createCodexAccountFeature({ logger: createLoggerPort(), configManager: createConfigManager('chatgpt'), }); try { await feature.refreshSnapshot(); const cancelledSnapshot = await feature.cancelLogin(); expect(loginCancelMock).toHaveBeenCalledTimes(1); expect(cancelledSnapshot.login).toMatchObject({ status: 'cancelled', error: null, startedAt: null, }); await vi.waitFor(() => { expect( readAccountMock.mock.calls.some( (call) => (call[0] as { refreshToken?: boolean } | undefined)?.refreshToken === true ) ).toBe(true); }); } finally { await feature.dispose(); } }); });