/** * @fileoverview Unit tests for the Codex run-mode UI surface in session-ui.js / * settings-ui.js / index.html. Loads the browser modules into a vm sandbox (no * real DOM) and exercises run-mode selection + Codex quick-start wiring. */ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import vm from 'node:vm'; import { describe, expect, it } from 'vitest'; function loadRunModeHarness() { const elements: Record = {}; const storage = new Map(); const CodemanApp = function CodemanApp(this: any) {}; const context = vm.createContext({ CodemanApp, VoiceInput: {}, localStorage: { getItem: (key: string) => storage.get(key) ?? null, setItem: (key: string, value: string) => storage.set(key, value), }, document: { getElementById: (id: string) => elements[id] ?? null, }, console, }); const settingsUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/settings-ui.js'), 'utf8'); const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); vm.runInContext(settingsUi, context, { filename: 'settings-ui.js' }); vm.runInContext(sessionUi, context, { filename: 'session-ui.js' }); const runModeMenu = { classList: { remove: () => {} } }; const gearBtn = { className: '' }; const runBtn = { className: '', nextElementSibling: gearBtn }; const runBtnLabel = { textContent: '' }; elements.runModeMenu = runModeMenu; elements.runBtn = runBtn; elements.runBtnLabel = runBtnLabel; const app = new (CodemanApp as any)(); app.loadAppSettingsFromStorage = () => ({}); app.saveAppSettingsToStorage = () => {}; app._apiPut = () => Promise.resolve(); return { app, storage, runBtnLabel }; } describe('run mode UI', () => { it('updates the visible mode when selecting Claude after server sync set Codex', async () => { const { app, storage, runBtnLabel } = loadRunModeHarness(); storage.set('codeman_runMode', 'claude'); await app.loadAppSettingsFromServer(Promise.resolve({ runMode: 'codex' })); expect(app.runMode).toBe('codex'); expect(runBtnLabel.textContent).toBe('Run CX'); app.setRunMode('claude'); expect(app.runMode).toBe('claude'); expect(runBtnLabel.textContent).toBe('Run'); }); it('accepts Gemini mode from server sync and updates the run button label', async () => { const { app, storage, runBtnLabel } = loadRunModeHarness(); storage.set('codeman_runMode', 'claude'); await app.loadAppSettingsFromServer(Promise.resolve({ runMode: 'gemini' })); expect(app.runMode).toBe('gemini'); expect(runBtnLabel.textContent).toBe('Run GM'); }); it('accepts Antigravity mode from server sync and updates the run button label', async () => { const { app, storage, runBtnLabel } = loadRunModeHarness(); storage.set('codeman_runMode', 'claude'); await app.loadAppSettingsFromServer(Promise.resolve({ runMode: 'antigravity' })); expect(app.runMode).toBe('antigravity'); expect(runBtnLabel.textContent).toBe('Run AG'); }); }); describe('Run launch synchronization', () => { it('coalesces overlapping Run activations and disables the button while the request is active', async () => { const runBtn = { disabled: false, setAttribute: vi.fn(), removeAttribute: vi.fn(), }; const CodemanApp = function CodemanApp(this: any) {}; const context = vm.createContext({ CodemanApp, localStorage: { getItem: () => null, setItem: () => {} }, document: { getElementById: (id: string) => (id === 'runBtn' ? runBtn : null) }, console, }); const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); vm.runInContext(sessionUi, context, { filename: 'session-ui.js' }); const app = new (CodemanApp as any)(); app._runMinLockMs = 0; let finishRun!: () => void; app.runClaude = vi.fn( () => new Promise((resolveRun) => { finishRun = resolveRun; }) ); const first = app.run(); const duplicate = app.run(); expect(app.runClaude).toHaveBeenCalledTimes(1); expect(runBtn.disabled).toBe(true); expect(runBtn.setAttribute).toHaveBeenCalledWith('aria-busy', 'true'); finishRun(); await Promise.all([first, duplicate]); expect(runBtn.disabled).toBe(false); expect(runBtn.removeAttribute).toHaveBeenCalledWith('aria-busy'); }); it('renders a POST response session immediately without waiting for SSE', async () => { const CodemanApp = function CodemanApp(this: any) {}; const context = vm.createContext({ CodemanApp, localStorage: { getItem: () => null, setItem: () => {} }, document: { getElementById: () => null }, fetch: vi.fn(), console, }); const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); vm.runInContext(sessionUi, context, { filename: 'session-ui.js' }); const app = new (CodemanApp as any)(); app.sessions = new Map(); app._onSessionCreated = vi.fn((session: any) => app.sessions.set(session.id, session)); app._renderSessionTabsImmediate = vi.fn(); const snapshot = { id: 'sess-new', name: 'w1-case', workingDir: '/tmp/case' }; await app._ensureCreatedSessionVisible(snapshot.id, snapshot); expect(context.fetch).not.toHaveBeenCalled(); expect(app.sessions.get(snapshot.id)).toEqual(snapshot); expect(app._renderSessionTabsImmediate).toHaveBeenCalledTimes(1); }); it('loads the new session when a quick-start response wins the race with SSE', async () => { const snapshot = { id: 'sess-race', name: 'w1-remote', workingDir: '/remote/work' }; const fetchMock = vi.fn(async () => ({ json: async () => ({ success: true, data: snapshot }), })); const CodemanApp = function CodemanApp(this: any) {}; const context = vm.createContext({ CodemanApp, localStorage: { getItem: () => null, setItem: () => {} }, document: { getElementById: () => null }, fetch: fetchMock, console, }); const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); vm.runInContext(sessionUi, context, { filename: 'session-ui.js' }); const app = new (CodemanApp as any)(); app.sessions = new Map(); app._onSessionCreated = vi.fn((session: any) => app.sessions.set(session.id, session)); app._renderSessionTabsImmediate = vi.fn(); await app._ensureCreatedSessionVisible(snapshot.id); expect(fetchMock).toHaveBeenCalledWith('/api/sessions/sess-race'); expect(app.sessions.get(snapshot.id)).toEqual(snapshot); expect(app._renderSessionTabsImmediate).toHaveBeenCalledTimes(1); }); }); describe('Codex quick start settings', () => { it('renders Codex CLI settings in a dedicated app settings tab', () => { const html = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8'); expect(html).toContain('data-tab="settings-codex">Codex CLI'); const claudeTab = html.match( /