/** * @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'); }); it('accepts OMP 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: 'omp' })); expect(app.runMode).toBe('omp'); expect(runBtnLabel.textContent).toBe('Run OMP'); }); }); 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. // // `(\w*)` in the param list, not `()`, and the leading `_?`: PR B2 // consolidated the eight run() bodies into one shared // `_runCliMode(mode)`, and the ORIGINAL `\(\)`-only pattern matched every // one-line wrapper (`async runOpenCode() { return this._runCliMode(...) }`) // but not `_runCliMode` itself, where the real terminal-ownership logic // now lives — so this guard could see 8 clean one-liners and stay green // while the actual bug shipped unseen for all eight external CLIs at // once. Confirmed live: adding `this.terminal.clear()` to `_runCliMode` // left this test 32/32 green under the old pattern. const bodies = new Map(); const header = /^ {2}async (_?run[A-Za-z]*)\(\w*\) \{$/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', '_runCliMode', 'runOpenCode', 'runCodex', 'runGemini', 'runAntigravity', 'runPi', 'runGrok', ]) ); 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 their own group inside Agents & CLIs', () => { const html = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8'); // The settings modal is one scrolling document: Codex is a GROUP that can be // hidden wholesale, not a tab (see _applyCodexSettingsVisibility). const clis = html.match(/
/); expect(clis?.[1]).toBeTruthy(); const codexGroup = clis![1].match(/id="appSettingsCodexGroup"([\s\S]*)$/); expect(codexGroup?.[1]).toContain('appSettingsCodexDangerouslyBypassApprovals'); expect(codexGroup?.[1]).toContain('appSettingsCodexAnimations'); expect(codexGroup?.[1]).not.toContain('appSettingsCodexRenderMode'); // The Claude settings above it must not have absorbed the codex inputs. const beforeCodex = clis![1].slice(0, clis![1].indexOf('id="appSettingsCodexGroup"')); expect(beforeCodex).not.toContain('appSettingsCodexDangerouslyBypassApprovals'); expect(beforeCodex).not.toContain('appSettingsCodexAnimations'); }); describe('Codex CLI group visibility', () => { // Both settings in the group are handed to `codex` at launch, so on an // instance where the binary does not resolve the group is a promise nothing // can keep. renderIndexHtml injects window.__codemanCliAvailable; this pins // the client half. Coupled test: it drives the REAL settings-ui.js against a // stub element, so deleting the call in openAppSettings() is what it catches. function loadSettingsUi(codexAvailable: boolean | undefined) { const codexTabBtn = { id: 'appSettingsCodexGroup', style: { display: 'PRISTINE' } }; const CodemanApp = function CodemanApp(this: any) {}; const context: any = vm.createContext({ CodemanApp, MobileDetection: { getDeviceType: () => 'desktop', isTouchDevice: () => false, isHandheldDevice: () => false }, localStorage: { getItem: () => null, setItem: () => {} }, document: { getElementById: (id: string) => (id === 'appSettingsCodexGroup' ? codexTabBtn : null), querySelector: () => null, }, console, }); context.window = context; if (codexAvailable !== undefined) context.__codemanCliAvailable = { codex: codexAvailable }; const settingsUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/settings-ui.js'), 'utf8'); vm.runInContext(settingsUi, context, { filename: 'settings-ui.js' }); return { app: new (CodemanApp as any)(), codexTabBtn }; } it('hides the Codex group when the codex binary is not available', () => { const { app, codexTabBtn } = loadSettingsUi(false); app._applyCodexSettingsVisibility(); expect(codexTabBtn.style.display).toBe('none'); }); it('hides the Codex group when the availability flag was never injected', () => { const { app, codexTabBtn } = loadSettingsUi(undefined); app._applyCodexSettingsVisibility(); expect(codexTabBtn.style.display).toBe('none'); }); it('shows the Codex group when codex is available', () => { const { app, codexTabBtn } = loadSettingsUi(true); app._applyCodexSettingsVisibility(); expect(codexTabBtn.style.display).toBe(''); }); it('applies the gating from openAppSettings, not just in isolation', () => { const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/settings-ui.js'), 'utf8'); const open = src.slice(src.indexOf('\n openAppSettings() {')); const body = open.slice(0, open.indexOf('\n },')); expect(body).toContain('_applyCodexSettingsVisibility()'); }); }); describe('CLI availability gating (#200/#201)', () => { const CATALOG = [ { id: 'claude', label: 'Claude Code', shortBadge: 'CC', kind: 'agent', enabled: true }, { id: 'opencode', label: 'OpenCode', shortBadge: 'OC', kind: 'agent', enabled: true }, { id: 'codex', label: 'Codex', shortBadge: 'CX', kind: 'agent', enabled: true }, { id: 'gemini', label: 'Gemini', shortBadge: 'GM', kind: 'agent', enabled: true }, { id: 'antigravity', label: 'Antigravity', shortBadge: 'AG', kind: 'agent', enabled: true }, { id: 'pi', label: 'Pi', shortBadge: 'PI', kind: 'agent', enabled: true }, { id: 'grok', label: 'Grok', shortBadge: 'GK', kind: 'agent', enabled: true }, { id: 'deepseek', label: 'DeepSeek', shortBadge: 'DS', kind: 'agent', enabled: true }, { id: 'omp', label: 'OMP', shortBadge: 'OM', kind: 'agent', enabled: true }, { id: 'custom-agent', label: 'Custom Agent', shortBadge: 'CA', kind: 'agent', enabled: true }, { id: 'shell', label: 'Shell', shortBadge: 'SH', kind: 'shell', enabled: true }, ]; function element() { const el: any = { style: { display: 'PRISTINE' }, dataset: {}, children: [] as any[], setAttribute: () => {}, appendChild(child: any) { this.children.push(child); return child; }, append(child: any) { this.children.push(child); }, replaceChildren(...children: any[]) { this.children = children; }, }; return el; } // Drives the REAL settings-ui.js + session-ui.js against stub elements, including // a custom registry entry so a static stock-only list cannot pass this test. function loadUi(flags: Record | undefined, catalog = CATALOG) { const CodemanApp = function CodemanApp(this: any) {}; const welcomeCliActions = element(); const tunnelBtn = element(); const runModeCliOptions = element(); const modeBtns: Record = {}; for (const cli of catalog) { modeBtns[cli.id] = element(); modeBtns[cli.id].dataset.mode = cli.id; } const menu = { querySelector: (sel: string) => sel === '#runModeDeepSeekInstall' || sel === '#runModeDeepSeekWeb' ? null : null, querySelectorAll: () => Object.values(modeBtns), }; const context: any = vm.createContext({ CodemanApp, MobileDetection: { getDeviceType: () => 'desktop', isTouchDevice: () => false, isHandheldDevice: () => false }, localStorage: { getItem: () => null, setItem: () => {} }, document: { getElementById: (id: string) => id === 'welcomeCliActions' ? welcomeCliActions : id === 'welcomeTunnelBtn' ? tunnelBtn : id === 'runModeCliOptions' ? runModeCliOptions : null, createElement: element, createElementNS: element, querySelector: () => null, }, console, }); context.window = context; context.__codemanCliCatalog = catalog; if (flags !== undefined) context.__codemanCliAvailable = flags; for (const file of ['settings-ui.js', 'session-ui.js']) { const src = readFileSync(resolve(import.meta.dirname, `../src/web/public/${file}`), 'utf8'); vm.runInContext(src, context, { filename: file }); } return { app: new (CodemanApp as any)(), welcomeCliActions, tunnelBtn, runModeCliOptions, modeBtns, menu }; } const ALL_OFF = { claude: false, opencode: false, codex: false, gemini: false, antigravity: false, pi: false, grok: false, deepseek: false, omp: false, 'custom-agent': false, cloudflared: false, }; it('renders only enabled and available registry entries on the welcome screen', () => { const { app, welcomeCliActions, tunnelBtn } = loadUi({ ...ALL_OFF, claude: true, 'custom-agent': true }); app.applyWelcomeCliVisibility(); const offered = welcomeCliActions.children.map((btn: any) => btn.dataset.mode); expect(offered).toEqual(['claude', 'custom-agent', 'shell']); // #200 originally DELETED the tunnel button and its QR outright; it is gated // on cloudflared instead, so a box that has cloudflared keeps the feature. expect(tunnelBtn.style.display).toBe('none'); const withTunnel = loadUi({ ...ALL_OFF, cloudflared: true }); withTunnel.app.applyWelcomeCliVisibility(); expect(withTunnel.tunnelBtn.style.display).toBe('flex'); const withPi = loadUi({ ...ALL_OFF, pi: true }); withPi.app.applyWelcomeCliVisibility(); expect(withPi.welcomeCliActions.children.map((btn: any) => btn.dataset.mode)).toEqual(['pi', 'shell']); }); it('gates every run mode in the dropdown, antigravity included, and never shell', () => { const { app, modeBtns, menu } = loadUi({ ...ALL_OFF, claude: true, antigravity: true, omp: true }); app._refreshRunModeAvailability(menu); expect(modeBtns.claude.style.display).toBe('flex'); expect(modeBtns.antigravity.style.display).toBe('flex'); expect(modeBtns.opencode.style.display).toBe('none'); expect(modeBtns.codex.style.display).toBe('none'); expect(modeBtns.gemini.style.display).toBe('none'); expect(modeBtns.omp.style.display).toBe('flex'); // Shell needs no external CLI, and leaving it alone is what guarantees the // menu is never empty on a box with nothing installed. expect(modeBtns.shell.style.display).toBe('PRISTINE'); }); it('renders each enabled agent registry entry in the Run menu, including custom entries', () => { const { app, runModeCliOptions } = loadUi({ ...ALL_OFF, claude: true, 'custom-agent': true }); app.renderRegistryRunOptions(); expect(runModeCliOptions.children.map((btn: any) => btn.dataset.mode)).toContain('custom-agent'); expect(runModeCliOptions.children.map((btn: any) => btn.dataset.mode)).not.toContain('shell'); }); it('labels welcome buttons "Run