agent-ecosystem/test/features/codex-account/main/createCodexAccountFeature.test.ts

1035 lines
32 KiB
TypeScript

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<typeof import('../../../../src/main/utils/shellEnv')>();
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<void> {
await loginStartMock();
}
async cancel(): Promise<void> {
await loginCancelMock();
}
async dispose(): Promise<void> {
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<T = void>(): {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (error: unknown) => void;
} {
let resolve: ((value: T | PromiseLike<T>) => void) | null = null;
let reject: ((error: unknown) => void) | null = null;
const promise = new Promise<T>((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<Partial<CodexAccountSnapshotDto>>({
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<void>();
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<typeof createAccountResponse>;
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();
}
});
});