/** * @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('keeps launch progress out of an active session terminal', () => { const CodemanApp = function CodemanApp(this: any) {}; const context = vm.createContext({ CodemanApp, localStorage: { getItem: () => null, setItem: () => {} }, document: { getElementById: () => 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.activeSessionId = 'existing-session'; app.terminal = { clear: vi.fn(), writeln: vi.fn(), }; app.showToast = vi.fn(); const ownsTerminal = app._beginSessionLaunchStatus('Starting Codex session', '1;32'); app._appendSessionLaunchStatus(ownsTerminal, 'Creating session'); app._reportSessionLaunchError(ownsTerminal, 'Launch failed'); expect(ownsTerminal).toBe(false); expect(app.terminal.clear).not.toHaveBeenCalled(); expect(app.terminal.writeln).not.toHaveBeenCalled(); expect(app.showToast).toHaveBeenNthCalledWith(1, 'Starting Codex session', 'info'); expect(app.showToast).toHaveBeenNthCalledWith(2, 'Launch failed', 'error'); }); it('still renders launch progress in the terminal on the session-less home screen', () => { const CodemanApp = function CodemanApp(this: any) {}; const context = vm.createContext({ CodemanApp, localStorage: { getItem: () => null, setItem: () => {} }, document: { getElementById: () => 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.activeSessionId = null; // home screen: nothing else owns the terminal app.terminal = { clear: vi.fn(), writeln: vi.fn() }; app.showToast = vi.fn(); const ownsTerminal = app._beginSessionLaunchStatus('Starting Codex session', '1;32'); app._appendSessionLaunchStatus(ownsTerminal, 'Creating session'); app._reportSessionLaunchError(ownsTerminal, 'Launch failed'); expect(ownsTerminal).toBe(true); expect(app.terminal.clear).toHaveBeenCalledTimes(1); expect(app.terminal.writeln.mock.calls.map((c: string[]) => c[0]).join('\n')).toContain('Starting Codex session'); expect(app.terminal.writeln.mock.calls.map((c: string[]) => c[0]).join('\n')).toContain('Creating session'); expect(app.terminal.writeln.mock.calls.map((c: string[]) => c[0]).join('\n')).toContain('Error: Launch failed'); expect(app.showToast).not.toHaveBeenCalled(); }); /** * Static guard over session-ui.js itself. The helpers above can be perfectly * correct while a run*() entry point still writes to the shared xterm * directly, which is the actual bug: a launch started while another session * is active wipes that session's terminal, and _cleanupPreviousSession() * then serializes the wiped view into its restore snapshot. Asserting on the * helpers alone cannot see that, so pin the call sites here. This also * covers run modes added later, which is how runAntigravity was caught. */ it('routes every run mode through the ownership helpers, never the terminal directly', () => { const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); // Methods live in one Object.assign(prototype, {...}) block at a fixed // 2-space indent, so `\n },` reliably closes the one we are inside. const bodies = new Map(); const header = /^ {2}async (run[A-Za-z]*)\(\) \{$/gm; for (let m = header.exec(src); m; m = header.exec(src)) { const start = m.index + m[0].length; const end = src.indexOf('\n },', start); expect(end, `could not find the end of ${m[1]}()`).toBeGreaterThan(start); bodies.set(m[1], src.slice(start, end)); } // Fail loudly if the scan matched nothing: a silently empty scan would make // every assertion below vacuously true. expect([...bodies.keys()]).toEqual( expect.arrayContaining(['runClaude', 'runShell', 'runOpenCode', 'runCodex', 'runGemini', 'runAntigravity']) ); for (const [name, body] of bodies) { expect(body, `${name}() must not clear a terminal it may not own`).not.toContain('this.terminal.clear('); expect(body, `${name}() must not write launch status straight to the terminal`).not.toContain( 'this.terminal.writeln(' ); } }); 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( /