243 lines
7.1 KiB
TypeScript
243 lines
7.1 KiB
TypeScript
// @vitest-environment node
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const openExternalMock = vi.fn<(url: string) => Promise<void>>();
|
|
|
|
vi.mock('electron', () => ({
|
|
shell: {
|
|
openExternal: (url: string) => openExternalMock(url),
|
|
},
|
|
}));
|
|
|
|
import { CodexLoginSessionManager } from '@features/codex-account/main/infrastructure/CodexLoginSessionManager';
|
|
|
|
import type { CodexAppServerSession } from '@main/services/infrastructure/codexAppServer';
|
|
|
|
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 };
|
|
}
|
|
|
|
function createSession(overrides?: {
|
|
request?: ReturnType<typeof vi.fn>;
|
|
close?: ReturnType<typeof vi.fn>;
|
|
}) {
|
|
const listeners = new Set<(method: string, params: unknown) => void>();
|
|
const request =
|
|
overrides?.request ??
|
|
vi.fn().mockResolvedValue({
|
|
type: 'chatgpt',
|
|
loginId: 'login-1',
|
|
authUrl: 'https://chatgpt.com/auth',
|
|
});
|
|
const close = overrides?.close ?? vi.fn().mockResolvedValue(undefined);
|
|
|
|
const session: CodexAppServerSession = {
|
|
initializeResponse: {
|
|
userAgent: 'codex-test',
|
|
codexHome: '/Users/tester/.codex',
|
|
platformFamily: 'darwin',
|
|
platformOs: 'macos',
|
|
},
|
|
request: request as CodexAppServerSession['request'],
|
|
notify: vi.fn().mockResolvedValue(undefined) as CodexAppServerSession['notify'],
|
|
onNotification: vi.fn((listener: (method: string, params: unknown) => void) => {
|
|
listeners.add(listener);
|
|
return () => listeners.delete(listener);
|
|
}) as CodexAppServerSession['onNotification'],
|
|
close: close as CodexAppServerSession['close'],
|
|
};
|
|
|
|
return {
|
|
session,
|
|
request,
|
|
close,
|
|
emitNotification(method: string, params: unknown) {
|
|
for (const listener of listeners) {
|
|
listener(method, params);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('CodexLoginSessionManager', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
openExternalMock.mockResolvedValue(undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('ignores duplicate start requests while the first login session is still starting', async () => {
|
|
const deferredSession = createDeferred<CodexAppServerSession>();
|
|
const sessionFactory = {
|
|
openSession: vi.fn(() => deferredSession.promise),
|
|
};
|
|
const manager = new CodexLoginSessionManager(sessionFactory as never, {
|
|
warn: vi.fn(),
|
|
});
|
|
|
|
const firstStart = manager.start({
|
|
binaryPath: '/usr/local/bin/codex',
|
|
env: {},
|
|
});
|
|
const secondStart = manager.start({
|
|
binaryPath: '/usr/local/bin/codex',
|
|
env: {},
|
|
});
|
|
|
|
expect(sessionFactory.openSession).toHaveBeenCalledTimes(1);
|
|
|
|
const fakeSession = createSession();
|
|
deferredSession.resolve(fakeSession.session);
|
|
|
|
await Promise.all([firstStart, secondStart]);
|
|
|
|
expect(fakeSession.request).toHaveBeenCalledTimes(1);
|
|
expect(openExternalMock).not.toHaveBeenCalled();
|
|
expect(manager.getState().status).toBe('pending');
|
|
expect(manager.getState().authUrl).toBe('https://chatgpt.com/auth');
|
|
expect(manager.getState().userCode).toBeNull();
|
|
});
|
|
|
|
it('cancels a login cleanly while the app-server session is still starting', async () => {
|
|
const deferredSession = createDeferred<CodexAppServerSession>();
|
|
const sessionFactory = {
|
|
openSession: vi.fn(() => deferredSession.promise),
|
|
};
|
|
const settledListener = vi.fn();
|
|
const manager = new CodexLoginSessionManager(sessionFactory as never, {
|
|
warn: vi.fn(),
|
|
});
|
|
manager.onSettled(settledListener);
|
|
|
|
const startPromise = manager.start({
|
|
binaryPath: '/usr/local/bin/codex',
|
|
env: {},
|
|
});
|
|
|
|
await manager.cancel();
|
|
|
|
const fakeSession = createSession();
|
|
deferredSession.resolve(fakeSession.session);
|
|
await startPromise;
|
|
|
|
expect(fakeSession.request).not.toHaveBeenCalled();
|
|
expect(fakeSession.close).toHaveBeenCalledTimes(1);
|
|
expect(openExternalMock).not.toHaveBeenCalled();
|
|
expect(settledListener).toHaveBeenCalledTimes(1);
|
|
expect(manager.getState()).toEqual({
|
|
status: 'cancelled',
|
|
error: null,
|
|
startedAt: null,
|
|
authUrl: null,
|
|
userCode: null,
|
|
});
|
|
});
|
|
|
|
it('returns to idle after a successful login completion notification', async () => {
|
|
const fakeSession = createSession();
|
|
const sessionFactory = {
|
|
openSession: vi.fn().mockResolvedValue(fakeSession.session),
|
|
};
|
|
const settledListener = vi.fn();
|
|
const manager = new CodexLoginSessionManager(sessionFactory as never, {
|
|
warn: vi.fn(),
|
|
});
|
|
manager.onSettled(settledListener);
|
|
|
|
await manager.start({
|
|
binaryPath: '/usr/local/bin/codex',
|
|
env: {},
|
|
});
|
|
|
|
expect(manager.getState().status).toBe('pending');
|
|
|
|
fakeSession.emitNotification('account/login/completed', {
|
|
loginId: 'login-1',
|
|
success: true,
|
|
error: null,
|
|
});
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(fakeSession.close).toHaveBeenCalledTimes(1);
|
|
expect(settledListener).toHaveBeenCalledTimes(1);
|
|
expect(manager.getState()).toEqual({
|
|
status: 'idle',
|
|
error: null,
|
|
startedAt: null,
|
|
authUrl: null,
|
|
userCode: null,
|
|
});
|
|
});
|
|
|
|
it('marks the login as failed when the pending login times out', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
const fakeSession = createSession();
|
|
const sessionFactory = {
|
|
openSession: vi.fn().mockResolvedValue(fakeSession.session),
|
|
};
|
|
const settledListener = vi.fn();
|
|
const manager = new CodexLoginSessionManager(sessionFactory as never, {
|
|
warn: vi.fn(),
|
|
});
|
|
manager.onSettled(settledListener);
|
|
|
|
await manager.start({
|
|
binaryPath: '/usr/local/bin/codex',
|
|
env: {},
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(10 * 60 * 1_000);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(fakeSession.close).toHaveBeenCalledTimes(1);
|
|
expect(settledListener).toHaveBeenCalledTimes(1);
|
|
expect(manager.getState()).toMatchObject({
|
|
status: 'failed',
|
|
error: 'Timed out while waiting for ChatGPT account login to finish.',
|
|
});
|
|
});
|
|
|
|
it('surfaces failed login completion notifications as a failed state', async () => {
|
|
const fakeSession = createSession();
|
|
const sessionFactory = {
|
|
openSession: vi.fn().mockResolvedValue(fakeSession.session),
|
|
};
|
|
const settledListener = vi.fn();
|
|
const manager = new CodexLoginSessionManager(sessionFactory as never, {
|
|
warn: vi.fn(),
|
|
});
|
|
manager.onSettled(settledListener);
|
|
|
|
await manager.start({
|
|
binaryPath: '/usr/local/bin/codex',
|
|
env: {},
|
|
});
|
|
|
|
fakeSession.emitNotification('account/login/completed', {
|
|
loginId: 'login-1',
|
|
success: false,
|
|
error: 'ChatGPT login was denied.',
|
|
});
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(fakeSession.close).toHaveBeenCalledTimes(1);
|
|
expect(settledListener).toHaveBeenCalledTimes(1);
|
|
expect(manager.getState()).toMatchObject({
|
|
status: 'failed',
|
|
error: 'ChatGPT login was denied.',
|
|
});
|
|
});
|
|
});
|