import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { getBrowserConfig, mutateUnifiedConfig, saveUnifiedConfig, } from '../../../../src/config/unified-config-loader'; import { createEmptyUnifiedConfig } from '../../../../src/config/unified-config-types'; import * as chromeReuse from '../../../../src/utils/browser/chrome-reuse'; import { getBrowserStatus } from '../../../../src/utils/browser/browser-status'; import { getEffectiveClaudeBrowserAttachConfig, resolveOptionalBrowserAttachRuntime, } from '../../../../src/utils/browser/browser-settings'; import * as codexDetector from '../../../../src/targets/codex-detector'; describe('browser status', () => { let tempHome = ''; let originalCcsHome: string | undefined; let originalBrowserUserDataDir: string | undefined; let originalBrowserProfileDir: string | undefined; let originalBrowserDevtoolsPort: string | undefined; beforeEach(() => { tempHome = mkdtempSync(join(tmpdir(), 'ccs-browser-status-')); originalCcsHome = process.env.CCS_HOME; originalBrowserUserDataDir = process.env.CCS_BROWSER_USER_DATA_DIR; originalBrowserProfileDir = process.env.CCS_BROWSER_PROFILE_DIR; originalBrowserDevtoolsPort = process.env.CCS_BROWSER_DEVTOOLS_PORT; process.env.CCS_HOME = tempHome; delete process.env.CCS_BROWSER_USER_DATA_DIR; delete process.env.CCS_BROWSER_PROFILE_DIR; delete process.env.CCS_BROWSER_DEVTOOLS_PORT; }); afterEach(() => { if (originalCcsHome !== undefined) { process.env.CCS_HOME = originalCcsHome; } else { delete process.env.CCS_HOME; } 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 (originalBrowserDevtoolsPort !== undefined) { process.env.CCS_BROWSER_DEVTOOLS_PORT = originalBrowserDevtoolsPort; } else { delete process.env.CCS_BROWSER_DEVTOOLS_PORT; } rmSync(tempHome, { recursive: true, force: true }); }); it('returns disabled/manual browser lanes with the recommended managed user-data dir by default', async () => { const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({ path: '/usr/local/bin/codex', needsShell: false, version: 'codex-cli 0.120.0', features: ['config-overrides'], }); try { const status = await getBrowserStatus(); expect(status.claude).toMatchObject({ enabled: false, state: 'disabled', policy: 'manual', source: 'config', effectiveUserDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), devtoolsPort: 9222, managedMcpServerName: 'ccs-browser', }); expect(status.claude.launchCommands.linux).toContain('--remote-debugging-port=9222'); expect(status.claude.detail).toContain('off by default'); expect(status.codex).toMatchObject({ enabled: false, policy: 'manual', state: 'disabled', serverName: 'ccs_browser', supportsConfigOverrides: false, }); expect(status.codex.detail).toContain('off by default'); expect(existsSync(join(tempHome, '.ccs', 'browser', 'chrome-user-data'))).toBe(false); } finally { codexSpy.mockRestore(); } }); it('resolves missing saved browser policies to manual while preserving explicit enabled values', async () => { const config = createEmptyUnifiedConfig(); config.browser = { claude: { enabled: true, user_data_dir: '/tmp/explicit-claude', devtools_port: 9333, } as typeof config.browser.claude, codex: { enabled: true, } as typeof config.browser.codex, }; saveUnifiedConfig(config); const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockRejectedValue( new Error('Chrome reuse metadata not found: /tmp/explicit-claude/DevToolsActivePort') ); const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({ path: '/usr/local/bin/codex', needsShell: false, version: 'codex-cli 0.120.0', features: ['config-overrides'], }); try { const status = await getBrowserStatus(); expect(status.claude).toMatchObject({ enabled: true, policy: 'manual', effectiveUserDataDir: '/tmp/explicit-claude', devtoolsPort: 9333, }); expect(status.codex).toMatchObject({ enabled: true, policy: 'manual', state: 'enabled', }); } finally { runtimeSpy.mockRestore(); codexSpy.mockRestore(); } }); it('bootstraps the managed default browser profile dir before reporting attach readiness', async () => { mutateUnifiedConfig((config) => { config.browser = { claude: { enabled: true, policy: 'auto', user_data_dir: '', devtools_port: 9222, }, codex: { enabled: true, policy: 'auto', }, }; }); const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockRejectedValue( new Error( `Chrome reuse metadata not found: ${join(tempHome, '.ccs', 'browser', 'chrome-user-data', 'DevToolsActivePort')}` ) ); const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({ path: '/usr/local/bin/codex', needsShell: false, version: 'codex-cli 0.120.0', features: ['config-overrides'], }); try { const status = await getBrowserStatus(); expect(status.claude.state).toBe('browser_not_running'); expect(status.claude.title).toBe('Claude Browser Attach is not ready yet.'); expect(status.claude.detail).toContain('created the managed browser profile directory'); expect(status.claude.nextStep).toContain('ccs browser setup'); expect(existsSync(join(tempHome, '.ccs', 'browser', 'chrome-user-data'))).toBe(true); } finally { runtimeSpy.mockRestore(); codexSpy.mockRestore(); } }); it('prefers CCS_BROWSER_USER_DATA_DIR over config when an env override is present', async () => { mutateUnifiedConfig((config) => { config.browser = { claude: { enabled: true, policy: 'auto', user_data_dir: '/config-browser', devtools_port: 9333, }, codex: { enabled: true, policy: 'auto', }, }; }); process.env.CCS_BROWSER_USER_DATA_DIR = '/env-browser'; process.env.CCS_BROWSER_DEVTOOLS_PORT = '9444'; const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockResolvedValue({ CCS_BROWSER_USER_DATA_DIR: '/env-browser', CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1', CCS_BROWSER_DEVTOOLS_PORT: '9444', CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9444', CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/test', }); const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({ path: '/usr/local/bin/codex', needsShell: false, version: 'codex-cli 0.120.0', features: ['config-overrides'], }); try { const status = await getBrowserStatus(); expect(status.claude).toMatchObject({ enabled: true, state: 'ready', source: 'CCS_BROWSER_USER_DATA_DIR', effectiveUserDataDir: '/env-browser', devtoolsPort: 9444, }); expect(status.claude.runtimeEnv?.CCS_BROWSER_DEVTOOLS_PORT).toBe('9444'); } finally { runtimeSpy.mockRestore(); codexSpy.mockRestore(); } }); it('returns a short managed attach warning when the managed browser dir is missing', async () => { mutateUnifiedConfig((config) => { config.browser = { claude: { enabled: true, policy: 'auto', user_data_dir: '', devtools_port: 9222, }, codex: { enabled: true, policy: 'auto', }, }; }); const resolution = await resolveOptionalBrowserAttachRuntime( getEffectiveClaudeBrowserAttachConfig(getBrowserConfig()) ); expect(resolution.runtimeEnv).toBeUndefined(); expect(resolution.warning).toContain('Claude Browser Attach is not ready yet.'); expect(resolution.warning).toContain('ccs browser setup'); expect(resolution.warning).toContain('ccs browser doctor'); }); it('keeps env override paths from implicitly enabling Claude browser attach when config is disabled', () => { mutateUnifiedConfig((config) => { config.browser = { claude: { enabled: false, policy: 'manual', user_data_dir: '/config-browser', devtools_port: 9333, }, codex: { enabled: false, policy: 'manual', }, }; }); process.env.CCS_BROWSER_USER_DATA_DIR = '/env-browser'; process.env.CCS_BROWSER_DEVTOOLS_PORT = '9444'; const effective = getEffectiveClaudeBrowserAttachConfig(getBrowserConfig()); expect(effective).toMatchObject({ enabled: false, source: 'CCS_BROWSER_USER_DATA_DIR', overrideActive: true, userDataDir: '/env-browser', devtoolsPort: 9444, }); }); it('returns the same managed attach warning when the configured DevTools port is unreachable', async () => { const managedDir = join(tempHome, '.ccs', 'browser', 'chrome-user-data'); mkdirSync(managedDir, { recursive: true }); const resolution = await resolveOptionalBrowserAttachRuntime({ enabled: true, source: 'config', overrideActive: false, userDataDir: managedDir, devtoolsPort: 43123, hasExplicitDevtoolsPort: true, }); expect(resolution.runtimeEnv).toBeUndefined(); expect(resolution.warning).toContain('Claude Browser Attach is not ready yet.'); expect(resolution.warning).toContain('ccs browser setup'); }); it('reports browser_not_running when attach metadata is missing for a custom path', async () => { mutateUnifiedConfig((config) => { config.browser = { claude: { enabled: true, policy: 'auto', user_data_dir: '/tmp/browser-profile', devtools_port: 9222, }, codex: { enabled: true, policy: 'auto', }, }; }); const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockRejectedValue( new Error('Chrome reuse metadata not found: /tmp/browser-profile/DevToolsActivePort') ); const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({ path: '/usr/local/bin/codex', needsShell: false, version: 'codex-cli 0.120.0', features: ['config-overrides'], }); try { const status = await getBrowserStatus(); expect(status.claude.state).toBe('browser_not_running'); expect(status.claude.detail).toContain('DevToolsActivePort'); expect(status.claude.nextStep).toContain('--remote-debugging-port=9222'); } finally { runtimeSpy.mockRestore(); codexSpy.mockRestore(); } }); it('preserves legacy metadata-based port discovery when only CCS_BROWSER_PROFILE_DIR is set', async () => { mutateUnifiedConfig((config) => { config.browser = { claude: { enabled: true, policy: 'manual', user_data_dir: '/config-browser', devtools_port: 9333, }, codex: { enabled: false, policy: 'manual', }, }; }); process.env.CCS_BROWSER_PROFILE_DIR = '/legacy-browser'; const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockResolvedValue({ CCS_BROWSER_USER_DATA_DIR: '/legacy-browser', CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1', CCS_BROWSER_DEVTOOLS_PORT: '50123', CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:50123', CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/legacy', }); const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({ path: '/usr/local/bin/codex', needsShell: false, version: 'codex-cli 0.120.0', features: ['config-overrides'], }); try { const status = await getBrowserStatus(); expect(runtimeSpy.mock.calls[0]?.[0]).toEqual({ profileDir: '/legacy-browser', devtoolsPort: undefined, }); expect(status.claude.runtimeEnv?.CCS_BROWSER_DEVTOOLS_PORT).toBe('50123'); } finally { runtimeSpy.mockRestore(); codexSpy.mockRestore(); } }); it('always forwards an explicit port for config-backed browser attach sessions', async () => { mutateUnifiedConfig((config) => { config.browser = { claude: { enabled: true, policy: 'auto', user_data_dir: '/tmp/config-browser', devtools_port: 9222, }, codex: { enabled: true, policy: 'auto', }, }; }); const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockResolvedValue({ CCS_BROWSER_USER_DATA_DIR: '/tmp/config-browser', CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1', CCS_BROWSER_DEVTOOLS_PORT: '9222', CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9222', CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/config', }); const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({ path: '/usr/local/bin/codex', needsShell: false, version: 'codex-cli 0.120.0', features: ['config-overrides'], }); try { await getBrowserStatus(); expect(runtimeSpy.mock.calls[0]?.[0]).toEqual({ profileDir: '/tmp/config-browser', devtoolsPort: '9222', }); } finally { runtimeSpy.mockRestore(); codexSpy.mockRestore(); } }); });