import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; import express from 'express'; import { existsSync, mkdtempSync, rmSync } from 'node:fs'; import type { Server } from 'node:http'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import browserRoutes from '../../../src/web-server/routes/browser-routes'; import { loadOrCreateUnifiedConfig } from '../../../src/config/unified-config-loader'; describe('browser routes', () => { let server: Server; let baseUrl = ''; let tempHome = ''; let originalCcsHome: string | undefined; let originalDashboardAuthEnabled: string | undefined; let originalBrowserUserDataDir: string | undefined; let originalBrowserProfileDir: string | undefined; let originalBrowserDevtoolsHost: string | undefined; let originalBrowserDevtoolsPort: string | undefined; let originalBrowserDevtoolsHttpUrl: string | undefined; let originalBrowserDevtoolsWsUrl: string | undefined; let originalBrowserEvalMode: string | undefined; let forcedRemoteAddress = '127.0.0.1'; beforeAll(async () => { const app = express(); app.use(express.json()); app.use((req, _res, next) => { Object.defineProperty(req.socket, 'remoteAddress', { value: forcedRemoteAddress, configurable: true, }); next(); }); app.use('/api/browser', browserRoutes); await new Promise((resolve, reject) => { server = app.listen(0, '127.0.0.1'); const onError = (error: Error) => reject(error); server.once('error', onError); server.once('listening', () => { server.off('error', onError); resolve(); }); }); const address = server.address(); if (!address || typeof address === 'string') { throw new Error('Unable to resolve test server port'); } baseUrl = `http://127.0.0.1:${address.port}`; }); afterAll(async () => { await new Promise((resolve) => server.close(() => resolve())); }); beforeEach(() => { tempHome = mkdtempSync(join(tmpdir(), 'ccs-browser-routes-')); originalCcsHome = process.env.CCS_HOME; originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED; originalBrowserUserDataDir = process.env.CCS_BROWSER_USER_DATA_DIR; originalBrowserProfileDir = process.env.CCS_BROWSER_PROFILE_DIR; originalBrowserDevtoolsHost = process.env.CCS_BROWSER_DEVTOOLS_HOST; originalBrowserDevtoolsPort = process.env.CCS_BROWSER_DEVTOOLS_PORT; originalBrowserDevtoolsHttpUrl = process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL; originalBrowserDevtoolsWsUrl = process.env.CCS_BROWSER_DEVTOOLS_WS_URL; originalBrowserEvalMode = process.env.CCS_BROWSER_EVAL_MODE; process.env.CCS_HOME = tempHome; process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false'; delete process.env.CCS_BROWSER_USER_DATA_DIR; delete process.env.CCS_BROWSER_PROFILE_DIR; delete process.env.CCS_BROWSER_DEVTOOLS_HOST; delete process.env.CCS_BROWSER_DEVTOOLS_PORT; delete process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL; delete process.env.CCS_BROWSER_DEVTOOLS_WS_URL; delete process.env.CCS_BROWSER_EVAL_MODE; forcedRemoteAddress = '127.0.0.1'; }); afterEach(() => { if (originalCcsHome !== undefined) { process.env.CCS_HOME = originalCcsHome; } else { delete process.env.CCS_HOME; } if (originalDashboardAuthEnabled !== undefined) { process.env.CCS_DASHBOARD_AUTH_ENABLED = originalDashboardAuthEnabled; } else { delete process.env.CCS_DASHBOARD_AUTH_ENABLED; } if (originalBrowserUserDataDir !== undefined) { process.env.CCS_BROWSER_USER_DATA_DIR = originalBrowserUserDataDir; } else { delete process.env.CCS_BROWSER_USER_DATA_DIR; } if (originalBrowserProfileDir !== undefined) { process.env.CCS_BROWSER_PROFILE_DIR = originalBrowserProfileDir; } else { delete process.env.CCS_BROWSER_PROFILE_DIR; } if (originalBrowserDevtoolsHost !== undefined) { process.env.CCS_BROWSER_DEVTOOLS_HOST = originalBrowserDevtoolsHost; } else { delete process.env.CCS_BROWSER_DEVTOOLS_HOST; } if (originalBrowserDevtoolsPort !== undefined) { process.env.CCS_BROWSER_DEVTOOLS_PORT = originalBrowserDevtoolsPort; } else { delete process.env.CCS_BROWSER_DEVTOOLS_PORT; } if (originalBrowserDevtoolsHttpUrl !== undefined) { process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL = originalBrowserDevtoolsHttpUrl; } else { delete process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL; } if (originalBrowserDevtoolsWsUrl !== undefined) { process.env.CCS_BROWSER_DEVTOOLS_WS_URL = originalBrowserDevtoolsWsUrl; } else { delete process.env.CCS_BROWSER_DEVTOOLS_WS_URL; } if (originalBrowserEvalMode !== undefined) { process.env.CCS_BROWSER_EVAL_MODE = originalBrowserEvalMode; } else { delete process.env.CCS_BROWSER_EVAL_MODE; } rmSync(tempHome, { recursive: true, force: true }); }); it('blocks remote access when dashboard auth is disabled', async () => { forcedRemoteAddress = '10.10.0.24'; const response = await fetch(`${baseUrl}/api/browser`); expect(response.status).toBe(403); expect(await response.json()).toEqual({ error: 'Browser endpoints require localhost access when dashboard auth is disabled.', }); }); it('returns the default browser config and status payload', async () => { const response = await fetch(`${baseUrl}/api/browser`); expect(response.status).toBe(200); const payload = await response.json(); expect(payload.config).toMatchObject({ claude: { enabled: false, policy: 'manual', userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), devtoolsPort: 9222, evalMode: 'readonly', }, codex: { enabled: false, policy: 'manual', evalMode: 'readonly', }, }); expect(payload.status.claude).toMatchObject({ state: 'disabled', policy: 'manual', evalMode: 'readonly', managedMcpServerName: 'ccs-browser', }); expect(payload.status.codex).toMatchObject({ enabled: false, state: 'disabled', policy: 'manual', evalMode: 'readonly', serverName: 'ccs_browser', }); expect(payload.status.codex.detail).toContain('off by default'); }); it('returns evalMode through the standalone browser status route', async () => { const updateResponse = await fetch(`${baseUrl}/api/browser`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ claude: { enabled: true, policy: 'manual', evalMode: 'readwrite', }, codex: { enabled: true, policy: 'auto', evalMode: 'disabled', }, }), }); expect(updateResponse.status).toBe(200); const response = await fetch(`${baseUrl}/api/browser/status`); expect(response.status).toBe(200); expect(await response.json()).toMatchObject({ claude: { policy: 'manual', evalMode: 'readwrite', }, codex: { policy: 'auto', evalMode: 'disabled', }, }); }); it('updates the saved browser config through the dashboard route', async () => { const response = await fetch(`${baseUrl}/api/browser`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ claude: { enabled: true, policy: 'manual', userDataDir: '/tmp/ccs-browser', devtoolsPort: 9333, evalMode: 'readwrite', }, codex: { enabled: true, policy: 'auto', evalMode: 'disabled', }, }), }); expect(response.status).toBe(200); const payload = await response.json(); expect(payload.browser.config).toMatchObject({ claude: { enabled: true, policy: 'manual', userDataDir: '/tmp/ccs-browser', devtoolsPort: 9333, evalMode: 'readwrite', }, codex: { enabled: true, policy: 'auto', evalMode: 'disabled', }, }); expect(payload.browser.status).toMatchObject({ claude: { policy: 'manual', evalMode: 'readwrite', }, codex: { policy: 'auto', evalMode: 'disabled', }, }); const config = loadOrCreateUnifiedConfig(); expect(config.browser).toMatchObject({ claude: { enabled: true, policy: 'manual', user_data_dir: '/tmp/ccs-browser', devtools_port: 9333, eval_mode: 'readwrite', }, codex: { enabled: true, policy: 'auto', eval_mode: 'disabled', }, }); }); it('updates evalMode without changing the saved policy', async () => { const firstResponse = await fetch(`${baseUrl}/api/browser`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ codex: { enabled: true, policy: 'auto', evalMode: 'readonly', }, }), }); expect(firstResponse.status).toBe(200); const secondResponse = await fetch(`${baseUrl}/api/browser`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ codex: { evalMode: 'readwrite', }, }), }); expect(secondResponse.status).toBe(200); const payload = await secondResponse.json(); expect(payload.browser.config.codex).toMatchObject({ enabled: true, policy: 'auto', evalMode: 'readwrite', }); const config = loadOrCreateUnifiedConfig(); expect(config.browser?.codex).toMatchObject({ enabled: true, policy: 'auto', eval_mode: 'readwrite', }); }); it('treats a blank user-data directory as a reset to the recommended path', async () => { const firstResponse = await fetch(`${baseUrl}/api/browser`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ claude: { enabled: true, policy: 'manual', userDataDir: '/tmp/ccs-browser-custom', devtoolsPort: 9333, evalMode: 'readonly', }, }), }); expect(firstResponse.status).toBe(200); const resetResponse = await fetch(`${baseUrl}/api/browser`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ claude: { userDataDir: ' ', }, }), }); expect(resetResponse.status).toBe(200); const payload = await resetResponse.json(); expect(payload.browser.config.claude).toMatchObject({ enabled: true, policy: 'manual', userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), devtoolsPort: 9333, evalMode: 'readonly', }); expect(payload.browser.status.claude.state).toBe('browser_not_running'); expect(payload.browser.status.claude.detail).toContain('CCS created the managed browser profile'); expect(payload.browser.status.claude.evalMode).toBe('readonly'); expect(existsSync(join(tempHome, '.ccs', 'browser', 'chrome-user-data'))).toBe(true); const config = loadOrCreateUnifiedConfig(); expect(config.browser).toMatchObject({ claude: { enabled: true, policy: 'manual', user_data_dir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), devtools_port: 9333, eval_mode: 'readonly', }, }); }); it('rejects invalid DevTools ports at the route boundary', async () => { const response = await fetch(`${baseUrl}/api/browser`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ claude: { devtoolsPort: 0, }, }), }); expect(response.status).toBe(400); expect(await response.json()).toEqual({ error: 'Invalid value for claude.devtoolsPort. Must be an integer between 1 and 65535.', }); }); it('rejects null browser lane payloads instead of treating them as no-ops', async () => { const response = await fetch(`${baseUrl}/api/browser`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ claude: null, }), }); expect(response.status).toBe(400); expect(await response.json()).toEqual({ error: 'Invalid value for claude. Must be an object.', }); }); it('rejects unknown browser config fields instead of silently ignoring them', async () => { const response = await fetch(`${baseUrl}/api/browser`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ codxe: { enabled: true, }, }), }); expect(response.status).toBe(400); expect(await response.json()).toEqual({ error: 'Unknown browser config field(s): codxe.', }); }); it('rejects invalid browser policy values at the route boundary', async () => { const response = await fetch(`${baseUrl}/api/browser`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ codex: { policy: 'always', }, }), }); expect(response.status).toBe(400); expect(await response.json()).toEqual({ error: 'Invalid value for codex.policy. Must be auto or manual.', }); }); it('rejects invalid browser evalMode values at the route boundary', async () => { const response = await fetch(`${baseUrl}/api/browser`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ claude: { evalMode: 'always', }, }), }); expect(response.status).toBe(400); expect(await response.json()).toEqual({ error: 'Invalid value for claude.evalMode. Must be one of: disabled, readonly, readwrite.', }); }); it('rejects unknown nested browser lane fields instead of silently ignoring them', async () => { const response = await fetch(`${baseUrl}/api/browser`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ claude: { userDatDir: '/tmp/typo', }, }), }); expect(response.status).toBe(400); expect(await response.json()).toEqual({ error: 'Unknown claude browser field(s): userDatDir.', }); }); });