/** * WebServer.renderIndexHtml — server-side gating of the index shell: * - multi-monitor button reveal (stable class-marker, not brittle copy match) * - solo (/session/:id) global injection + escaping, and settings skipped * - gesture overlay availability vs. enablement (CODEMAN_GESTURE + setting) * - settings read FRESH so a post-save reload doesn't render stale state * * WebServer's constructor only assigns fields (no port bind), so we construct it * directly, swap in a tiny indexHtmlTemplate, and stub readSettings to avoid disk. * * Port: N/A (no server start). */ import { describe, it, expect, afterEach, vi } from 'vitest'; import { WebServer } from '../src/web/server.js'; import { isClaudeAvailable } from '../src/utils/claude-cli-resolver.js'; import { isOpenCodeAvailable } from '../src/utils/opencode-cli-resolver.js'; import { isCodexAvailable } from '../src/utils/codex-cli-resolver.js'; import { isGeminiAvailable } from '../src/utils/gemini-cli-resolver.js'; import { isAntigravityAvailable } from '../src/utils/antigravity-cli-resolver.js'; import { isPiAvailable } from '../src/utils/pi-cli-resolver.js'; import { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js'; import { isGitAvailable } from '../src/git-clone.js'; // renderIndexHtml probes the real PATH for every CLI, which would make the // assertions below depend on whatever happens to be installed on the machine // running the suite. Default them all to "not installed" and opt in per test. vi.mock('../src/utils/claude-cli-resolver.js', () => ({ isClaudeAvailable: vi.fn(() => false), findClaudeDir: vi.fn(() => null), })); vi.mock('../src/utils/opencode-cli-resolver.js', () => ({ isOpenCodeAvailable: vi.fn(() => false), resolveOpenCodeDir: vi.fn(() => null), })); vi.mock('../src/utils/codex-cli-resolver.js', () => ({ isCodexAvailable: vi.fn(() => false), resolveCodexDir: vi.fn(() => null), })); vi.mock('../src/utils/gemini-cli-resolver.js', () => ({ isGeminiAvailable: vi.fn(() => false), resolveGeminiDir: vi.fn(() => null), })); vi.mock('../src/utils/antigravity-cli-resolver.js', () => ({ isAntigravityAvailable: vi.fn(() => false), resolveAntigravityDir: vi.fn(() => null), })); vi.mock('../src/utils/pi-cli-resolver.js', () => ({ isPiAvailable: vi.fn(() => false), resolvePiDir: vi.fn(() => null), getPiCliVersion: vi.fn(() => null), })); vi.mock('../src/utils/cloudflared-resolver.js', () => ({ isCloudflaredAvailable: vi.fn(() => false), resolveCloudflaredPath: vi.fn(() => null), })); // git gates the Add Case -> Clone Repo tab (#236), so it rides in the same object. vi.mock('../src/git-clone.js', () => ({ isGitAvailable: vi.fn(() => false), })); const TEMPLATE = [ '', 'Codeman', '', '', '', '', ].join('\n'); function makeServer(settings: Record = {}) { const server = new WebServer(0, false, true); // eslint-disable-next-line @typescript-eslint/no-explicit-any (server as any).indexHtmlTemplate = TEMPLATE; const readSettings = vi.fn(async () => settings); // eslint-disable-next-line @typescript-eslint/no-explicit-any (server as any).readSettings = readSettings; return { server, readSettings }; } // eslint-disable-next-line @typescript-eslint/no-explicit-any const render = (server: WebServer, solo?: string): Promise => (server as any).renderIndexHtml(solo); const ORIG_GESTURE = process.env.CODEMAN_GESTURE; afterEach(() => { if (ORIG_GESTURE === undefined) delete process.env.CODEMAN_GESTURE; else process.env.CODEMAN_GESTURE = ORIG_GESTURE; }); describe('WebServer.renderIndexHtml', () => { it('keeps the multi-monitor button hidden by default and reads settings FRESH', async () => { const { server, readSettings } = makeServer({}); const html = await render(server); expect(html).toContain('btn-multimonitor--hidden'); // forceFresh=true — fixes the post-save reload race against the 2s cache. expect(readSettings).toHaveBeenCalledWith(true); }); it('reveals the multi-monitor button when showMultiMonitorButton is set', async () => { const { server } = makeServer({ showMultiMonitorButton: true }); const html = await render(server); expect(html).not.toContain('btn-multimonitor--hidden'); expect(html).toContain('btn-multimonitor"'); // class list still present, only the marker stripped }); it('injects the solo global and skips settings for a /session/:id window', async () => { const { server, readSettings } = makeServer({ showMultiMonitorButton: true }); const html = await render(server, 'sess-123'); expect(html).toContain('window.__CODEMAN_SOLO__="sess-123"'); expect(readSettings).not.toHaveBeenCalled(); // Solo skips settings, so the button is NOT revealed even though the setting is on. expect(html).toContain('btn-multimonitor--hidden'); }); it('escapes the solo id so it cannot break out of the inline '); expect(html).not.toContain(''); expect(html).toContain('\\u003c'); }); it('exposes gesture availability but injects the bundle only when enabled', async () => { process.env.CODEMAN_GESTURE = '1'; let { server } = makeServer({ gestureControlEnabled: false }); let html = await render(server); expect(html).toContain('window.__codemanGestureAvailable=true'); expect(html).not.toContain('gesture-codeman.js'); ({ server } = makeServer({ gestureControlEnabled: true })); html = await render(server); expect(html).toContain('window.__codemanGestureAvailable=true'); expect(html).toContain('gesture-codeman.js'); }); it('reports every tool the welcome buttons, run menu and Codex tab gate on', async () => { vi.mocked(isClaudeAvailable).mockReturnValue(true); vi.mocked(isOpenCodeAvailable).mockReturnValue(false); vi.mocked(isCodexAvailable).mockReturnValue(true); vi.mocked(isGeminiAvailable).mockReturnValue(false); vi.mocked(isAntigravityAvailable).mockReturnValue(false); vi.mocked(isPiAvailable).mockReturnValue(true); vi.mocked(isCloudflaredAvailable).mockReturnValue(true); vi.mocked(isGitAvailable).mockReturnValue(true); const { server } = makeServer({}); const html = await render(server); const flags = JSON.parse(html.match(/window\.__codemanCliAvailable=(\{.*?\});/)![1]); // Every key must be PRESENT, not merely truthy where installed: the client // treats a missing key as available, so a dropped key silently un-gates. expect(flags).toEqual({ claude: true, opencode: false, codex: true, gemini: false, antigravity: false, pi: true, cloudflared: true, git: true, }); }); it('still emits the object when nothing at all is installed', async () => { // The all-false case is the one that matters most and the easiest to get // wrong by only injecting when something resolves. for (const probe of [ isClaudeAvailable, isOpenCodeAvailable, isCodexAvailable, isGeminiAvailable, isAntigravityAvailable, isPiAvailable, isCloudflaredAvailable, isGitAvailable, ]) { vi.mocked(probe).mockReturnValue(false); } const { server } = makeServer({}); const html = await render(server); expect(html).toContain('window.__codemanCliAvailable='); const flags = JSON.parse(html.match(/window\.__codemanCliAvailable=(\{.*?\});/)![1]); expect(Object.values(flags).every((v) => v === false)).toBe(true); }); it('skips the probe for a solo window, which has no welcome screen or run menu', async () => { vi.mocked(isCodexAvailable).mockReturnValue(true); const { server } = makeServer({}); const html = await render(server, 'sess-123'); expect(html).not.toContain('__codemanCliAvailable'); }); it('does not expose gesture at all when CODEMAN_GESTURE is unset', async () => { delete process.env.CODEMAN_GESTURE; const { server } = makeServer({ gestureControlEnabled: true }); const html = await render(server); expect(html).not.toContain('__codemanGestureAvailable'); expect(html).not.toContain('gesture-codeman.js'); }); });