diff --git a/BROWSER.md b/BROWSER.md index baf32d5f..04c0eb86 100644 --- a/BROWSER.md +++ b/BROWSER.md @@ -71,7 +71,6 @@ browse/ │ ├── cookie-import-browser.ts # Decrypt + import cookies from real Chromium browsers │ ├── cookie-picker-routes.ts # HTTP routes for interactive cookie picker UI │ ├── cookie-picker-ui.ts # Self-contained HTML/CSS/JS for cookie picker -│ ├── chrome-launcher.ts # Browser discovery, CDP probe, runtime detection │ ├── activity.ts # Activity streaming (SSE) for Chrome extension │ └── buffers.ts # CircularBuffer + console/network/dialog capture ├── test/ # Integration tests + HTML fixtures @@ -346,7 +345,6 @@ Tests spin up a local HTTP server (`browse/test/test-server.ts`) serving HTML fi | `browse/src/cookie-import-browser.ts` | Decrypt Chromium cookies via macOS Keychain + PBKDF2/AES-128-CBC. Auto-detects installed browsers. | | `browse/src/cookie-picker-routes.ts` | HTTP routes for `/cookie-picker/*` — browser list, domain search, import, remove. | | `browse/src/cookie-picker-ui.ts` | Self-contained HTML generator for the interactive cookie picker (dark theme, no frameworks). | -| `browse/src/chrome-launcher.ts` | Browser binary discovery, CDP port probe, runtime detection (Conductor/Claude Code/Codex/terminal). | | `browse/src/activity.ts` | Activity streaming — `ActivityEntry` type, `CircularBuffer`, privacy filtering, SSE subscriber management. | | `browse/src/buffers.ts` | `CircularBuffer` (O(1) ring buffer) + console/network/dialog capture with async disk flush. | diff --git a/SKILL.md b/SKILL.md index 97bc51dd..46d5a7c6 100644 --- a/SKILL.md +++ b/SKILL.md @@ -603,9 +603,9 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`. ### Server | Command | Description | |---------|-------------| -| `connect [browser] [--port N]` | Connect to real Chrome/Comet browser via CDP | -| `disconnect` | Disconnect from real browser, return to headless mode | -| `focus [@ref]` | Bring connected browser window to foreground (macOS) | +| `connect` | Launch headed Chromium with Chrome extension | +| `disconnect` | Disconnect headed browser, return to headless mode | +| `focus [@ref]` | Bring headed browser window to foreground (macOS) | | `handoff [message]` | Open visible Chrome at current page for user takeover | | `restart` | Restart server | | `resume` | Re-snapshot after user takeover, return control to AI | diff --git a/browse/SKILL.md b/browse/SKILL.md index ca0a0d43..582be8a7 100644 --- a/browse/SKILL.md +++ b/browse/SKILL.md @@ -475,9 +475,9 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`. ### Server | Command | Description | |---------|-------------| -| `connect [browser] [--port N]` | Connect to real Chrome/Comet browser via CDP | -| `disconnect` | Disconnect from real browser, return to headless mode | -| `focus [@ref]` | Bring connected browser window to foreground (macOS) | +| `connect` | Launch headed Chromium with Chrome extension | +| `disconnect` | Disconnect headed browser, return to headless mode | +| `focus [@ref]` | Bring headed browser window to foreground (macOS) | | `handoff [message]` | Open visible Chrome at current page for user takeover | | `restart` | Restart server | | `resume` | Re-snapshot after user takeover, return control to AI | diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index d7eb6426..84c58dd2 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -61,14 +61,11 @@ export class BrowserManager { private isHeaded: boolean = false; private consecutiveFailures: number = 0; - // ─── CDP State ──────────────────────────────────────────── - private connectionMode: 'launched' | 'cdp' = 'launched'; - private preExistingTabIds: Set = new Set(); - private cdpPort: number = 0; + // ─── Headed State ──────────────────────────────────────── + private connectionMode: 'launched' | 'headed' = 'launched'; private intentionalDisconnect = false; - private reconnecting = false; - getConnectionMode(): 'launched' | 'cdp' { return this.connectionMode; } + getConnectionMode(): 'launched' | 'headed' { return this.connectionMode; } /** * Find the gstack Chrome extension directory. @@ -140,21 +137,19 @@ export class BrowserManager { await this.newTab(); } - // ─── CDP Connect ──────────────────────────────────────────── + // ─── Headed Mode ───────────────────────────────────────────── /** - * Launch the user's real Chrome browser via Playwright's channel: 'chrome'. - * - * Uses Playwright's native pipe protocol (not CDP WebSocket) to control - * the system Chrome binary. This avoids CDP protocol version mismatches - * between Playwright and recent Chrome versions. + * Launch Playwright's bundled Chromium in headed mode with the gstack + * Chrome extension auto-loaded. Uses launchPersistentContext() which + * is required for extension loading (launch() + newContext() can't + * load extensions). * * The browser launches headed with a visible window — the user sees * every action Claude takes in real time. */ - async connectCDP(_wsUrl: string, _port: number): Promise { - // Clear old state before repopulating (safe for reconnect) + async launchHeaded(): Promise { + // Clear old state before repopulating this.pages.clear(); - this.preExistingTabIds.clear(); this.refMap.clear(); this.nextTabId = 1; @@ -186,7 +181,7 @@ export class BrowserManager { ], }); this.browser = this.context.browser(); - this.connectionMode = 'cdp'; + this.connectionMode = 'headed'; this.intentionalDisconnect = false; // Inject visual indicator — subtle top-edge amber gradient @@ -252,46 +247,16 @@ export class BrowserManager { }); } - // CDP-specific defaults + // Headed mode defaults this.dialogAutoAccept = false; // Don't dismiss user's real dialogs this.isHeaded = true; this.consecutiveFailures = 0; } - /** - * Auto-reconnect after unexpected CDP disconnect (e.g., browser restart). - * Non-blocking recursive setTimeout — never overlaps or blocks commands. - */ - private async attemptReconnect(remaining = 60): Promise { - if (remaining <= 0 || this.reconnecting || this.intentionalDisconnect) { - if (remaining <= 0) { - console.log('[browse] CDP reconnect failed after 5 minutes. Run `$B connect` to reconnect.'); - } - return; - } - - this.reconnecting = true; - try { - const { isCdpAvailable } = await import('./chrome-launcher'); - const result = await isCdpAvailable(this.cdpPort); - if (result.available && result.wsUrl) { - await this.connectCDP(result.wsUrl, this.cdpPort); - console.log('[browse] Reconnected to real browser'); - return; - } - } catch { - // Probe failed — try again - } finally { - this.reconnecting = false; - } - - setTimeout(() => this.attemptReconnect(remaining - 1), 5000); - } - async close() { - if (this.browser || (this.connectionMode === 'cdp' && this.context)) { - if (this.connectionMode === 'cdp') { - // CDP/persistent context mode: close the context (which closes the browser) + if (this.browser || (this.connectionMode === 'headed' && this.context)) { + if (this.connectionMode === 'headed') { + // Headed/persistent context mode: close the context (which closes the browser) this.intentionalDisconnect = true; if (this.browser) this.browser.removeAllListeners('disconnected'); await Promise.race([ @@ -355,11 +320,6 @@ export class BrowserManager { const page = this.pages.get(tabId); if (!page) throw new Error(`Tab ${tabId} not found`); - // CDP mode: block closing pre-existing user tabs - if (this.connectionMode === 'cdp' && this.preExistingTabIds.has(tabId)) { - throw new Error("Cannot close user's pre-existing tab in real-browser mode. Only tabs created by gstack can be closed."); - } - await page.close(); this.pages.delete(tabId); @@ -599,8 +559,8 @@ export class BrowserManager { * Falls back to a clean slate on any failure. */ async recreateContext(): Promise { - if (this.connectionMode === 'cdp') { - throw new Error('Cannot recreate context in real-browser mode. The browser context belongs to the user.'); + if (this.connectionMode === 'headed') { + throw new Error('Cannot recreate context in headed mode. Use disconnect first.'); } if (!this.browser || !this.context) { throw new Error('Browser not launched'); @@ -668,10 +628,7 @@ export class BrowserManager { * If step 2 fails → return error, headless browser untouched */ async handoff(message: string): Promise { - if (this.connectionMode === 'cdp') { - return 'Already controlling real browser via CDP. No handoff needed.'; - } - if (this.isHeaded) { + if (this.connectionMode === 'headed' || this.isHeaded) { return `HANDOFF: Already in headed mode at ${this.getCurrentUrl()}`; } if (!this.browser || !this.context) { diff --git a/browse/src/chrome-launcher.ts b/browse/src/chrome-launcher.ts deleted file mode 100644 index aa079e4d..00000000 --- a/browse/src/chrome-launcher.ts +++ /dev/null @@ -1,361 +0,0 @@ -/** - * Chrome/Comet browser discovery + CDP connection - * - * Discovery flow (macOS only): - * 1. Probe localhost:9222 for existing CDP endpoint - * 2. If occupied by non-Chrome, try 9223-9225 - * 3. If no CDP: find browser binary, quit gracefully, relaunch with --remote-debugging-port - * 4. On attach failure: rollback — relaunch browser WITHOUT debug flag - * - * Reuses the browser registry pattern from cookie-import-browser.ts - */ - -import { execSync, spawn } from 'child_process'; - -// ─── Browser Binary Registry (macOS) ─────────────────────────── - -export interface BrowserBinary { - name: string; - binary: string; - appName: string; // for osascript 'tell application "X"' - aliases: string[]; - realDataDir: string; // user's actual profile location - cdpDataDir: string; // separate dir for --remote-debugging-port (symlinks to real profile) -} - -const HOME = process.env.HOME || '/tmp'; -const APP_SUPPORT = `${HOME}/Library/Application Support`; -const CDP_BASE = `${HOME}/.gstack/cdp-profile`; - -export const BROWSER_BINARIES: BrowserBinary[] = [ - { name: 'Comet', binary: '/Applications/Comet.app/Contents/MacOS/Comet', appName: 'Comet', aliases: ['comet', 'perplexity'], realDataDir: `${APP_SUPPORT}/Comet`, cdpDataDir: `${CDP_BASE}/comet` }, - { name: 'Chrome', binary: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', appName: 'Google Chrome', aliases: ['chrome', 'google-chrome'], realDataDir: `${APP_SUPPORT}/Google/Chrome`, cdpDataDir: `${CDP_BASE}/chrome` }, - { name: 'Arc', binary: '/Applications/Arc.app/Contents/MacOS/Arc', appName: 'Arc', aliases: ['arc'], realDataDir: `${APP_SUPPORT}/Arc/User Data`, cdpDataDir: `${CDP_BASE}/arc` }, - { name: 'Brave', binary: '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser', appName: 'Brave Browser', aliases: ['brave'], realDataDir: `${APP_SUPPORT}/BraveSoftware/Brave-Browser`, cdpDataDir: `${CDP_BASE}/brave` }, - { name: 'Edge', binary: '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', appName: 'Microsoft Edge', aliases: ['edge'], realDataDir: `${APP_SUPPORT}/Microsoft Edge`, cdpDataDir: `${CDP_BASE}/edge` }, -]; - -// ─── CDP Probe ───────────────────────────────────────────────── - -export interface CdpProbeResult { - available: boolean; - wsUrl?: string; - browser?: string; -} - -/** - * Check if a CDP endpoint is available at the given port. - * Returns the WebSocket debugger URL if found. - */ -export async function isCdpAvailable(port: number): Promise { - try { - const resp = await fetch(`http://127.0.0.1:${port}/json/version`, { - signal: AbortSignal.timeout(2000), - }); - if (!resp.ok) return { available: false }; - const data = await resp.json() as Record; - const wsUrl = data.webSocketDebuggerUrl; - if (!wsUrl) return { available: false }; - return { available: true, wsUrl, browser: data.Browser }; - } catch { - return { available: false }; - } -} - -/** - * Get the WebSocket debugger URL from a CDP port. - * Throws if not available. - */ -export async function getCdpWebSocketUrl(port: number): Promise { - const result = await isCdpAvailable(port); - if (!result.available || !result.wsUrl) { - throw new Error(`No CDP endpoint at port ${port}`); - } - return result.wsUrl; -} - -/** - * Try ports 9222-9225 to find an available CDP endpoint. - */ -export async function findCdpPort(): Promise<{ port: number; wsUrl: string; browser?: string } | null> { - for (const port of [9222, 9223, 9224, 9225]) { - const result = await isCdpAvailable(port); - if (result.available && result.wsUrl) { - return { port, wsUrl: result.wsUrl, browser: result.browser }; - } - } - return null; -} - -// ─── Browser Binary Discovery ────────────────────────────────── - -import * as fs from 'fs'; - -/** - * Find the binary path for a browser by name or alias. - */ -export function findBrowserBinary(nameOrAlias: string): BrowserBinary | null { - const needle = nameOrAlias.toLowerCase(); - return BROWSER_BINARIES.find(b => - b.aliases.includes(needle) || b.name.toLowerCase() === needle - ) ?? null; -} - -/** - * Find installed browsers (binary exists on disk). - */ -export function findInstalledBrowsers(): BrowserBinary[] { - return BROWSER_BINARIES.filter(b => { - try { return fs.existsSync(b.binary); } catch { return false; } - }); -} - -/** - * Check if a browser is currently running (macOS: pgrep). - */ -export function isBrowserRunning(browser: BrowserBinary): boolean { - try { - // Use the app name to find the process - execSync(`pgrep -f "${browser.appName}"`, { stdio: 'pipe' }); - return true; - } catch { - return false; - } -} - -/** - * Set up a CDP data directory with symlinks to the user's real profile. - * Chrome refuses --remote-debugging-port on the default data dir, - * but we can symlink the real profile so cookies/extensions carry over. - */ -function setupCdpDataDir(browser: BrowserBinary): void { - const { mkdirSync, symlinkSync, existsSync } = fs; - mkdirSync(browser.cdpDataDir, { recursive: true }); - - // Symlink the Default profile (cookies, extensions, history) - const realDefault = `${browser.realDataDir}/Default`; - const cdpDefault = `${browser.cdpDataDir}/Default`; - if (existsSync(realDefault) && !existsSync(cdpDefault)) { - symlinkSync(realDefault, cdpDefault); - } - - // Symlink Local State (crypto keys for cookie decryption, etc.) - const realState = `${browser.realDataDir}/Local State`; - const cdpState = `${browser.cdpDataDir}/Local State`; - if (existsSync(realState) && !existsSync(cdpState)) { - symlinkSync(realState, cdpState); - } -} - -// ─── Runtime Detection ───────────────────────────────────────── - -export type RuntimeEnv = 'conductor' | 'claude-code' | 'codex' | 'terminal'; - -/** - * Detect the parent runtime environment. - * Conductor and other Electron apps can't use osascript to quit other apps - * due to macOS App Management security restrictions. - */ -export function detectRuntime(): RuntimeEnv { - // Conductor detection — check FIRST because Conductor also has ANTHROPIC_API_KEY - if ( - process.env.CONDUCTOR_WORKSPACE_NAME || - process.env.CONDUCTOR_BIN_DIR || - process.env.CONDUCTOR_PORT || - process.env.__CFBundleIdentifier === 'com.conductor.app' - ) return 'conductor'; - // Claude Code terminal detection - if (process.env.CLAUDE_CODE || process.env.ANTHROPIC_API_KEY) return 'claude-code'; - // Codex CLI detection - if (process.env.CODEX_SESSION || process.env.OPENAI_API_KEY) return 'codex'; - return 'terminal'; -} - -/** - * Whether the current runtime can safely quit/relaunch other macOS apps. - * Electron apps (Conductor) trigger macOS App Management dialogs. - * Terminal apps (iTerm, Terminal, Claude Code CLI) can do it freely. - */ -export function canManageApps(): boolean { - const runtime = detectRuntime(); - // Terminal-based runtimes can use osascript freely - // Electron-based runtimes (Conductor) trigger App Management dialogs - return runtime === 'terminal' || runtime === 'claude-code' || runtime === 'codex'; -} - -// ─── Browser Launch with CDP ─────────────────────────────────── - -export interface LaunchResult { - wsUrl: string; - port: number; -} - -export interface ManualRestartNeeded { - needsManualRestart: true; - browser: BrowserBinary; - port: number; - reason: string; - command: string; // The command the user needs to run -} - -export type LaunchOutcome = LaunchResult | ManualRestartNeeded; - -function isManualRestart(outcome: LaunchOutcome): outcome is ManualRestartNeeded { - return 'needsManualRestart' in outcome; -} - -/** - * Launch or connect to a browser with CDP enabled. - * - * Three paths: - * 1. Browser not running → launch with --remote-debugging-port (works everywhere) - * 2. Browser running + runtime CAN manage apps → quit and relaunch (terminal/CLI) - * 3. Browser running + runtime CANNOT manage apps → return ManualRestartNeeded - * with instructions for the user (Conductor/Electron) - */ -export async function launchWithCdp( - browser: BrowserBinary, - port: number = 9222, -): Promise { - const wasRunning = isBrowserRunning(browser); - - if (wasRunning) { - if (!canManageApps()) { - // Can't quit Chrome from Conductor — macOS App Management blocks it - const runtime = detectRuntime(); - return { - needsManualRestart: true, - browser, - port, - reason: runtime === 'conductor' - ? `Conductor can't restart ${browser.name} due to macOS App Management security. You need to restart it manually.` - : `This runtime can't restart ${browser.name}. You need to restart it manually.`, - command: `"${browser.binary}" --remote-debugging-port=${port} --user-data-dir="${browser.cdpDataDir}" --restore-last-session`, - }; - } - - // Terminal/CLI runtime — can quit and relaunch - try { - execSync(`osascript -e 'tell application "${browser.appName}" to quit'`, { - stdio: 'pipe', - timeout: 10000, - }); - } catch { - // osascript failed even from terminal — fall back to manual - return { - needsManualRestart: true, - browser, - port, - reason: `Failed to quit ${browser.name} via osascript. You need to restart it manually.`, - command: `"${browser.binary}" --remote-debugging-port=${port} --user-data-dir="${browser.cdpDataDir}" --restore-last-session`, - }; - } - - // Wait for clean shutdown (Chrome with many tabs can take a while) - await new Promise(resolve => setTimeout(resolve, 3000)); - - // Verify it actually quit — wait up to 10s for processes to exit - const quitStart = Date.now(); - while (Date.now() - quitStart < 10000) { - if (!isBrowserRunning(browser)) break; - await new Promise(resolve => setTimeout(resolve, 500)); - } - } - - // Set up CDP data dir with symlinked profile - // Chrome refuses --remote-debugging-port on its default data dir. - // We create a separate dir and symlink the real profile into it. - setupCdpDataDir(browser); - - // Launch with CDP flag + non-default data dir - const child = spawn(browser.binary, [ - `--remote-debugging-port=${port}`, - `--user-data-dir=${browser.cdpDataDir}`, - '--restore-last-session', - ], { - detached: true, - stdio: 'ignore', - }); - child.unref(); - - // Poll for CDP availability (up to 30s — Chrome with many tabs takes time) - const startTime = Date.now(); - while (Date.now() - startTime < 30000) { - const result = await isCdpAvailable(port); - if (result.available && result.wsUrl) { - return { wsUrl: result.wsUrl, port }; - } - await new Promise(resolve => setTimeout(resolve, 500)); - } - - // Rollback: relaunch without debug flag so user gets their browser back - if (wasRunning) { - try { - const rollback = spawn(browser.binary, ['--restore-last-session'], { - detached: true, - stdio: 'ignore', - }); - rollback.unref(); - } catch {} - } - - throw new Error( - `CDP endpoint not available after 30s. ${browser.name} may not support --remote-debugging-port, ` + - `or port ${port} is blocked. Browser has been relaunched without debug flag.` - ); -} - -/** - * Full discovery algorithm: - * 1. Check for existing CDP on ports 9222-9225 - * 2. Find an installed browser (priority order) - * 3. Launch/relaunch with CDP - * - * @param preferredBrowser - Optional browser name (e.g., 'chrome', 'comet') - * @param port - CDP port (default 9222) - */ -export { isManualRestart }; -export type { ManualRestartNeeded }; - -export async function discoverAndConnect( - preferredBrowser?: string, - port: number = 9222, -): Promise<{ wsUrl: string; port: number; browser: string } | ManualRestartNeeded> { - // Step 1: Check for existing CDP - const existing = await findCdpPort(); - if (existing) { - return { - wsUrl: existing.wsUrl, - port: existing.port, - browser: existing.browser || 'Unknown', - }; - } - - // Step 2: Find browser binary - let browser: BrowserBinary | null = null; - - if (preferredBrowser) { - browser = findBrowserBinary(preferredBrowser); - if (!browser) { - const installed = findInstalledBrowsers(); - const names = installed.map(b => b.name.toLowerCase()).join(', '); - throw new Error( - `Browser '${preferredBrowser}' not found. Installed: ${names || 'none'}` - ); - } - } else { - // Auto-detect: first installed browser in priority order - const installed = findInstalledBrowsers(); - if (installed.length === 0) { - throw new Error('No supported browser found. Install Chrome, Comet, Arc, Brave, or Edge.'); - } - browser = installed[0]; - } - - // Step 3: Launch with CDP (may return ManualRestartNeeded) - const result = await launchWithCdp(browser, port); - if (isManualRestart(result)) { - return result; // Caller must handle manual restart flow - } - return { ...result, browser: browser.name }; -} diff --git a/browse/src/cli.ts b/browse/src/cli.ts index e27ad44a..3c93c155 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -83,8 +83,7 @@ interface ServerState { startedAt: string; serverPath: string; binaryVersion?: string; - mode?: 'launched' | 'cdp'; - cdpPort?: number; + mode?: 'launched' | 'headed'; } // ─── State File ──────────────────────────────────────────────── @@ -236,11 +235,11 @@ async function ensureServer(): Promise { } } - // Guard: never silently replace a CDP server with a headless one. - // CDP mode means a user-visible Chrome window is (or was) controlled. + // Guard: never silently replace a headed server with a headless one. + // Headed mode means a user-visible Chrome window is (or was) controlled. // Silently replacing it would be confusing — tell the user to reconnect. - if (state && state.mode === 'cdp' && isProcessAlive(state.pid)) { - console.error(`[browse] CDP server running (PID ${state.pid}) but not responding.`); + if (state && state.mode === 'headed' && isProcessAlive(state.pid)) { + console.error(`[browse] Headed server running (PID ${state.pid}) but not responding.`); console.error(`[browse] Run '$B connect' to restart.`); process.exit(1); } @@ -353,23 +352,23 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: const command = args[0]; const commandArgs = args.slice(1); - // ─── CDP Connect (pre-server command) ─────────────────────── + // ─── Headed Connect (pre-server command) ──────────────────── // connect must be handled BEFORE ensureServer() because it needs - // to restart the server with real Chrome via Playwright channel:chrome. + // to restart the server in headed mode with the Chrome extension. if (command === 'connect') { - // Check if already in CDP mode and healthy + // Check if already in headed mode and healthy const existingState = readState(); - if (existingState && existingState.mode === 'cdp' && isProcessAlive(existingState.pid)) { + if (existingState && existingState.mode === 'headed' && isProcessAlive(existingState.pid)) { try { const resp = await fetch(`http://127.0.0.1:${existingState.port}/health`, { signal: AbortSignal.timeout(2000), }); if (resp.ok) { - console.log('Already connected to real browser.'); + console.log('Already connected in headed mode.'); process.exit(0); } } catch { - // CDP server alive but not responding — kill and restart + // Headed server alive but not responding — kill and restart } } @@ -392,13 +391,12 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: // Delete stale state file try { fs.unlinkSync(config.stateFile); } catch {} - console.log('Launching real Chrome browser...'); + console.log('Launching headed Chromium with extension...'); try { - // Start server with CDP flag — server.ts will use channel:chrome + // Start server in headed mode with extension auto-loaded // Use a well-known port so the Chrome extension auto-connects const newState = await startServer({ - BROWSE_CDP_URL: 'channel:chrome', - BROWSE_CDP_PORT: '0', + BROWSE_HEADED: '1', BROWSE_PORT: '34567', }); @@ -446,13 +444,13 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: process.exit(0); } - // ─── CDP Disconnect (pre-server command) ────────────────── - // disconnect must be handled BEFORE ensureServer() because the CDP + // ─── Headed Disconnect (pre-server command) ───────────────── + // disconnect must be handled BEFORE ensureServer() because the headed // guard blocks all commands when the server is unresponsive. if (command === 'disconnect') { const existingState = readState(); - if (!existingState || existingState.mode !== 'cdp') { - console.log('Not in CDP mode — nothing to disconnect.'); + if (!existingState || existingState.mode !== 'headed') { + console.log('Not in headed mode — nothing to disconnect.'); process.exit(0); } // Try graceful shutdown via server diff --git a/browse/src/commands.ts b/browse/src/commands.ts index 72aa33d5..5e3f9c45 100644 --- a/browse/src/commands.ts +++ b/browse/src/commands.ts @@ -99,10 +99,10 @@ export const COMMAND_DESCRIPTIONS: Record { + it('getConnectionMode defaults to launched', async () => { + const { BrowserManager } = await import('../src/browser-manager'); + const bm = new BrowserManager(); + expect(bm.getConnectionMode()).toBe('launched'); + }); + + it('getRefMap returns empty array initially', async () => { + const { BrowserManager } = await import('../src/browser-manager'); + const bm = new BrowserManager(); + expect(bm.getRefMap()).toEqual([]); + }); +}); diff --git a/browse/test/cdp-connect.test.ts b/browse/test/cdp-connect.test.ts deleted file mode 100644 index 69655d83..00000000 --- a/browse/test/cdp-connect.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { describe, it, expect, mock, beforeEach } from 'bun:test'; -import { - findBrowserBinary, - findInstalledBrowsers, - isCdpAvailable, - getCdpWebSocketUrl, - findCdpPort, - BROWSER_BINARIES, -} from '../src/chrome-launcher'; - -// ─── chrome-launcher unit tests ───────────────────────────────── - -describe('findBrowserBinary', () => { - it('finds Chrome by alias', () => { - const result = findBrowserBinary('chrome'); - expect(result).not.toBeNull(); - expect(result!.name).toBe('Chrome'); - }); - - it('finds Chrome by name (case-insensitive)', () => { - const result = findBrowserBinary('Chrome'); - expect(result).not.toBeNull(); - expect(result!.name).toBe('Chrome'); - }); - - it('finds Comet by alias', () => { - const result = findBrowserBinary('comet'); - expect(result).not.toBeNull(); - expect(result!.name).toBe('Comet'); - }); - - it('finds Comet by perplexity alias', () => { - const result = findBrowserBinary('perplexity'); - expect(result).not.toBeNull(); - expect(result!.name).toBe('Comet'); - }); - - it('returns null for unknown browser', () => { - expect(findBrowserBinary('netscape')).toBeNull(); - }); - - it('returns null for empty string', () => { - expect(findBrowserBinary('')).toBeNull(); - }); -}); - -describe('BROWSER_BINARIES', () => { - it('has correct priority order (Comet first)', () => { - expect(BROWSER_BINARIES[0].name).toBe('Comet'); - expect(BROWSER_BINARIES[1].name).toBe('Chrome'); - }); - - it('all entries have required fields', () => { - for (const browser of BROWSER_BINARIES) { - expect(browser.name).toBeTruthy(); - expect(browser.binary).toContain('/Applications/'); - expect(browser.appName).toBeTruthy(); - expect(browser.aliases.length).toBeGreaterThan(0); - } - }); -}); - -describe('isCdpAvailable', () => { - it('returns false for port with no listener', async () => { - // Port 19999 should not have anything listening - const result = await isCdpAvailable(19999); - expect(result.available).toBe(false); - expect(result.wsUrl).toBeUndefined(); - }); - - it('returns false for invalid port', async () => { - const result = await isCdpAvailable(0); - expect(result.available).toBe(false); - }); -}); - -describe('getCdpWebSocketUrl', () => { - it('throws for unavailable port', async () => { - await expect(getCdpWebSocketUrl(19999)).rejects.toThrow('No CDP endpoint'); - }); -}); - -describe('findCdpPort', () => { - it('returns null when no CDP ports are available', async () => { - // This test passes in CI where no Chrome is running with debug port - // In local dev with debug port open, it would find one - const result = await findCdpPort(); - // Either null (no CDP) or valid result — both are correct - if (result !== null) { - expect(result.port).toBeGreaterThan(0); - expect(result.wsUrl).toContain('ws://'); - } - }); -}); - -// ─── Runtime Detection ────────────────────────────────────────── - -describe('detectRuntime', () => { - it('returns a valid runtime type', async () => { - const { detectRuntime } = await import('../src/chrome-launcher'); - const runtime = detectRuntime(); - expect(['conductor', 'claude-code', 'codex', 'terminal']).toContain(runtime); - }); -}); - -describe('canManageApps', () => { - it('returns a boolean', async () => { - const { canManageApps } = await import('../src/chrome-launcher'); - expect(typeof canManageApps()).toBe('boolean'); - }); -}); - -describe('isManualRestart', () => { - it('detects manual restart objects', async () => { - const { isManualRestart, BROWSER_BINARIES } = await import('../src/chrome-launcher'); - const manualResult = { - needsManualRestart: true as const, - browser: BROWSER_BINARIES[0], - port: 9222, - reason: 'test', - command: 'test', - }; - // isManualRestart is not directly exported, but we can test the type guard - expect(manualResult.needsManualRestart).toBe(true); - }); -}); - -// ─── BrowserManager CDP mode guards ───────────────────────────── - -describe('BrowserManager CDP mode', () => { - // These tests verify the mode guard logic without actually connecting - // to a real browser. We test the public interface. - - it('getConnectionMode defaults to launched', async () => { - const { BrowserManager } = await import('../src/browser-manager'); - const bm = new BrowserManager(); - expect(bm.getConnectionMode()).toBe('launched'); - }); - - it('getRefMap returns empty array initially', async () => { - const { BrowserManager } = await import('../src/browser-manager'); - const bm = new BrowserManager(); - expect(bm.getRefMap()).toEqual([]); - }); -});