diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 04fb5a48..3da6d8c3 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -217,7 +217,10 @@ function main() { console.log(' \x1b[1mNext step — Browser Bridge setup\x1b[0m'); console.log(' Browser commands (bilibili, zhihu, twitter...) require the extension:'); console.log(' 1. Download: https://github.com/jackwener/opencli/releases'); - console.log(' 2. In Chrome or Chromium, open chrome://extensions → enable Developer Mode → Load unpacked'); + console.log(' 2. In a Chromium-based browser, open the extensions page:'); + console.log(' - Chrome: chrome://extensions'); + console.log(' - Edge: edge://extensions'); + console.log(' Enable Developer Mode → Load unpacked'); console.log(''); console.log(' Then run \x1b[36mopencli doctor\x1b[0m to verify.'); console.log(''); diff --git a/src/browser.test.ts b/src/browser.test.ts index dd420268..4d06f641 100644 --- a/src/browser.test.ts +++ b/src/browser.test.ts @@ -1,10 +1,31 @@ -import { describe, it, expect, vi } from 'vitest'; +import { beforeEach, describe, it, expect, vi } from 'vitest'; +const { + mockFetchDaemonStatus, + mockIsExtensionConnected, + mockGetBrowserCandidates, + mockLaunchBrowserCandidate, +} = vi.hoisted(() => ({ + mockFetchDaemonStatus: vi.fn(), + mockIsExtensionConnected: vi.fn(), + mockGetBrowserCandidates: vi.fn(), + mockLaunchBrowserCandidate: vi.fn(), +})); + +vi.mock('./browser/daemon-client.js', () => ({ + fetchDaemonStatus: mockFetchDaemonStatus, + isExtensionConnected: mockIsExtensionConnected, +})); + +vi.mock('./browser/candidates.js', () => ({ + getBrowserCandidates: mockGetBrowserCandidates, + launchBrowserCandidate: mockLaunchBrowserCandidate, +})); + import { BrowserBridge, generateStealthJs } from './browser/index.js'; import { extractTabEntries, diffTabIndexes, appendLimited } from './browser/tabs.js'; import { withTimeoutMs } from './runtime.js'; import { __test__ as cdpTest } from './browser/cdp.js'; import { isRetryableSettleError } from './browser/page.js'; -import * as daemonClient from './browser/daemon-client.js'; describe('browser helpers', () => { it('extracts tab entries from string snapshots', () => { @@ -103,6 +124,14 @@ describe('browser helpers', () => { }); describe('BrowserBridge state', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetchDaemonStatus.mockReset(); + mockIsExtensionConnected.mockReset(); + mockGetBrowserCandidates.mockReset(); + mockLaunchBrowserCandidate.mockReset(); + }); + it('transitions to closed after close()', async () => { const bridge = new BrowserBridge(); @@ -135,13 +164,180 @@ describe('BrowserBridge state', () => { }); it('fails fast when daemon is running but extension is disconnected', async () => { - vi.spyOn(daemonClient, 'isExtensionConnected').mockResolvedValue(false); - vi.spyOn(daemonClient, 'fetchDaemonStatus').mockResolvedValue({ extensionConnected: false } as any); + mockFetchDaemonStatus.mockResolvedValue({ extensionConnected: false } as any); + mockIsExtensionConnected.mockResolvedValue(false); + mockGetBrowserCandidates.mockReturnValue([]); const bridge = new BrowserBridge(); await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Browser Extension is not connected'); }); + + it('tries detected browsers in order until the extension connects', async () => { + vi.useFakeTimers(); + try { + mockFetchDaemonStatus.mockResolvedValue({ extensionConnected: false } as any); + mockGetBrowserCandidates.mockReturnValue([ + { id: 'chrome', name: 'Chrome', executable: '/chrome', running: false }, + { id: 'edge', name: 'Edge', executable: '/edge', running: false }, + ]); + mockIsExtensionConnected.mockResolvedValue(false); + mockLaunchBrowserCandidate.mockImplementation(async (candidate: { id: string }) => { + if (candidate.id === 'edge') { + mockIsExtensionConnected.mockResolvedValue(true); + } + }); + + const bridge = new BrowserBridge(); + const promise = bridge.connect({ timeout: 5 }); + + await vi.advanceTimersByTimeAsync(5000); + await promise; + + expect(mockLaunchBrowserCandidate).toHaveBeenNthCalledWith(1, expect.objectContaining({ id: 'chrome' })); + expect(mockLaunchBrowserCandidate).toHaveBeenNthCalledWith(2, expect.objectContaining({ id: 'edge' })); + expect(bridge.inferredBrowserName).toBe('Edge'); + } finally { + vi.useRealTimers(); + } + }); + + it('waits on running browsers without launching them', async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(0); + mockFetchDaemonStatus.mockResolvedValue({ extensionConnected: false } as any); + mockGetBrowserCandidates.mockReturnValue([ + { id: 'chrome', name: 'Chrome', executable: '/chrome', running: true }, + { id: 'edge', name: 'Edge', executable: '/edge', running: true }, + { id: 'chromium', name: 'Chromium', executable: '/chromium', running: false }, + ]); + + let connected = false; + mockIsExtensionConnected.mockImplementation(async () => connected); + mockLaunchBrowserCandidate.mockResolvedValue(undefined); + + const bridge = new BrowserBridge(); + const promise = bridge.connect({ timeout: 5 }); + + setTimeout(() => { + connected = true; + }, 450); + + await vi.advanceTimersByTimeAsync(5000); + await promise; + + // Running browsers should not be launched + expect(mockLaunchBrowserCandidate).not.toHaveBeenCalled(); + // Chrome is first running candidate being polled when extension connects + expect(bridge.inferredBrowserName).toBe('Chrome'); + } finally { + vi.useRealTimers(); + } + }); + + it('launches unopened browsers only after running browsers fail', async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(0); + mockFetchDaemonStatus.mockResolvedValue({ extensionConnected: false } as any); + mockGetBrowserCandidates.mockReturnValue([ + { id: 'edge', name: 'Edge', executable: '/edge', running: true }, + { id: 'chromium', name: 'Chromium', executable: '/chromium', running: false }, + ]); + + let connected = false; + mockIsExtensionConnected.mockImplementation(async () => connected); + mockLaunchBrowserCandidate.mockImplementation(async (candidate: { id: string }) => { + if (candidate.id === 'chromium') connected = true; + }); + + const bridge = new BrowserBridge(); + const promise = bridge.connect({ timeout: 5 }); + + await vi.advanceTimersByTimeAsync(5000); + await promise; + + expect(mockLaunchBrowserCandidate).toHaveBeenCalledTimes(1); + expect(mockLaunchBrowserCandidate).toHaveBeenCalledWith(expect.objectContaining({ id: 'chromium' })); + expect(bridge.inferredBrowserName).toBe('Chromium'); + } finally { + vi.useRealTimers(); + } + }); + + it('includes detected and tried browsers in the final error', async () => { + vi.useFakeTimers(); + try { + mockFetchDaemonStatus.mockResolvedValue({ extensionConnected: false } as any); + mockGetBrowserCandidates.mockReturnValue([ + { id: 'chrome', name: 'Chrome', executable: '/chrome', running: false }, + { id: 'edge', name: 'Edge', executable: '/edge', running: false }, + ]); + mockIsExtensionConnected.mockResolvedValue(false); + + const bridge = new BrowserBridge(); + let message = ''; + + const promise = bridge.connect({ timeout: 5 }).catch((error) => { + message = error instanceof Error ? error.message : String(error); + }); + + await vi.advanceTimersByTimeAsync(5000); + await promise; + + expect(message).toContain('Detected browsers: Chrome, Edge'); + expect(message).toContain('Tried browsers: Chrome, Edge'); + } finally { + vi.useRealTimers(); + } + }); + + it('honors short timeouts without waiting a full poll interval', async () => { + vi.useFakeTimers(); + mockFetchDaemonStatus.mockResolvedValue({ extensionConnected: false } as any); + mockGetBrowserCandidates.mockReturnValue([]); + mockIsExtensionConnected.mockResolvedValue(false); + + const bridge = new BrowserBridge(); + const promise = bridge.connect({ timeout: 0.05 }); + const rejection = expect(promise).rejects.toThrow('Browser Extension is not connected'); + + await vi.advanceTimersByTimeAsync(60); + await rejection; + + vi.useRealTimers(); + }); + + it('does not count browser discovery time against trying later browsers', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + mockFetchDaemonStatus.mockResolvedValue({ extensionConnected: false } as any); + mockGetBrowserCandidates.mockImplementation(() => { + vi.setSystemTime(800); + return [ + { id: 'chrome', name: 'Chrome', executable: '/chrome', running: false }, + { id: 'edge', name: 'Edge', executable: '/edge', running: false }, + ]; + }); + mockIsExtensionConnected.mockResolvedValue(false); + mockLaunchBrowserCandidate.mockImplementation(async (candidate: { id: string }) => { + if (candidate.id === 'edge') { + mockIsExtensionConnected.mockResolvedValue(true); + } + }); + + const bridge = new BrowserBridge(); + const promise = bridge.connect({ timeout: 5 }); + + await vi.advanceTimersByTimeAsync(5000); + await promise; + + expect(mockLaunchBrowserCandidate).toHaveBeenNthCalledWith(1, expect.objectContaining({ id: 'chrome' })); + expect(mockLaunchBrowserCandidate).toHaveBeenNthCalledWith(2, expect.objectContaining({ id: 'edge' })); + + vi.useRealTimers(); + }); }); describe('stealth anti-detection', () => { diff --git a/src/browser/bridge.ts b/src/browser/bridge.ts index 385f9910..eb81d454 100644 --- a/src/browser/bridge.ts +++ b/src/browser/bridge.ts @@ -10,24 +10,38 @@ import type { IPage } from '../types.js'; import type { IBrowserFactory } from '../runtime.js'; import { Page } from './page.js'; import { fetchDaemonStatus, isExtensionConnected } from './daemon-client.js'; +import { getBrowserCandidates, launchBrowserCandidate } from './candidates.js'; import { DEFAULT_DAEMON_PORT } from '../constants.js'; const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension +const EXTENSION_POLL_INTERVAL_MS = 200; +const MAX_PER_BROWSER_WAIT_MS = 2000; export type BrowserBridgeState = 'idle' | 'connecting' | 'connected' | 'closing' | 'closed'; /** * Browser factory: manages daemon lifecycle and provides IPage instances. */ +interface LaunchResult { + connected: boolean; + detected: string[]; + tried: string[]; +} + export class BrowserBridge implements IBrowserFactory { private _state: BrowserBridgeState = 'idle'; private _page: Page | null = null; private _daemonProc: ChildProcess | null = null; + private _inferredBrowserName: string | null = null; get state(): BrowserBridgeState { return this._state; } + get inferredBrowserName(): string | null { + return this._inferredBrowserName; + } + async connect(opts: { timeout?: number; workspace?: string } = {}): Promise { if (this._state === 'connected' && this._page) return this._page; if (this._state === 'connecting') throw new Error('Already connecting'); @@ -35,6 +49,7 @@ export class BrowserBridge implements IBrowserFactory { if (this._state === 'closed') throw new Error('Session is closed'); this._state = 'connecting'; + this._inferredBrowserName = null; try { await this._ensureDaemon(opts.timeout); @@ -69,18 +84,11 @@ export class BrowserBridge implements IBrowserFactory { // Daemon running but no extension — wait for extension with progress if (status !== null) { if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) { - process.stderr.write('⏳ Waiting for Chrome/Chromium extension to connect...\n'); - process.stderr.write(' Make sure Chrome or Chromium is open and the OpenCLI extension is enabled.\n'); + process.stderr.write('⏳ Waiting for Browser Bridge extension to connect...\n'); } - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - await new Promise(resolve => setTimeout(resolve, 200)); - if (await isExtensionConnected()) return; - } - throw new Error( - 'Daemon is running but the Browser Extension is not connected.\n' + - 'Please install and enable the opencli Browser Bridge extension in Chrome or Chromium.', - ); + const result = await this._tryLaunchBrowsers(timeoutMs); + if (result.connected) return; + throw new Error(this._buildExtensionError(result.detected, result.tried)); } // No daemon — spawn one @@ -107,17 +115,11 @@ export class BrowserBridge implements IBrowserFactory { this._daemonProc.unref(); // Wait for daemon + extension with faster polling - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - await new Promise(resolve => setTimeout(resolve, 200)); - if (await isExtensionConnected()) return; - } + const result = await this._tryLaunchBrowsers(timeoutMs); + if (result.connected) return; if ((await fetchDaemonStatus()) !== null) { - throw new Error( - 'Daemon is running but the Browser Extension is not connected.\n' + - 'Please install and enable the opencli Browser Bridge extension in Chrome or Chromium.', - ); + throw new Error(this._buildExtensionError(result.detected, result.tried)); } throw new Error( @@ -126,4 +128,74 @@ export class BrowserBridge implements IBrowserFactory { `Make sure port ${DEFAULT_DAEMON_PORT} is available.`, ); } + + private async _tryLaunchBrowsers(timeoutMs: number): Promise { + const candidates = getBrowserCandidates(); + const detected = candidates.map(c => c.name); + const tried: string[] = []; + + if (await isExtensionConnected()) return { connected: true, detected, tried }; + + const deadline = Date.now() + timeoutMs; + const perBrowserWaitMs = candidates.length > 0 + ? Math.min(MAX_PER_BROWSER_WAIT_MS, Math.max(EXTENSION_POLL_INTERVAL_MS, Math.floor(timeoutMs / candidates.length))) + : timeoutMs; + + for (const candidate of candidates) { + if (Date.now() >= deadline) break; + + tried.push(candidate.name); + if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) { + process.stderr.write(` Trying browser: ${candidate.name}\n`); + } + + if (!candidate.running) { + await launchBrowserCandidate(candidate); + if (await isExtensionConnected()) { + this._inferredBrowserName = candidate.name; + return { connected: true, detected, tried }; + } + } + + const waitMs = Math.min(perBrowserWaitMs, Math.max(0, deadline - Date.now())); + if (waitMs > 0 && await this._waitForExtensionConnection(waitMs)) { + this._inferredBrowserName = candidate.name; + return { connected: true, detected, tried }; + } + } + + // Use any remaining time for a final wait + const remaining = Math.max(0, deadline - Date.now()); + if (remaining > 0 && await this._waitForExtensionConnection(remaining)) { + return { connected: true, detected, tried }; + } + return { connected: false, detected, tried }; + } + + private async _waitForExtensionConnection(timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const sleepMs = Math.min(EXTENSION_POLL_INTERVAL_MS, Math.max(0, deadline - Date.now())); + if (sleepMs <= 0) break; + await new Promise(resolve => setTimeout(resolve, sleepMs)); + if (await isExtensionConnected()) return true; + } + return false; + } + + private _buildExtensionError( + detected: string[], + tried: string[], + ): string { + const detectedText = detected.length > 0 ? detected.join(', ') : 'none'; + const triedText = tried.length > 0 ? tried.join(', ') : 'none'; + return ( + 'Daemon is running but the Browser Extension is not connected.\n' + + `Detected browsers: ${detectedText}\n` + + `Tried browsers: ${triedText}\n` + + 'Please install and enable the opencli Browser Bridge extension in a Chromium-based browser.\n' + + ' Chrome: chrome://extensions\n' + + ' Edge: edge://extensions' + ); + } } diff --git a/src/browser/candidates.test.ts b/src/browser/candidates.test.ts new file mode 100644 index 00000000..0ececeb9 --- /dev/null +++ b/src/browser/candidates.test.ts @@ -0,0 +1,182 @@ +import { beforeEach, describe, expect, it, vi, afterEach } from 'vitest'; + +const { mockExistsSync, mockExecFileSync, mockSpawn, mockDiscoverAppPath, mockDetectProcess } = vi.hoisted(() => ({ + mockExistsSync: vi.fn(), + mockExecFileSync: vi.fn(), + mockSpawn: vi.fn(), + mockDiscoverAppPath: vi.fn(), + mockDetectProcess: vi.fn(), +})); + +vi.mock('node:fs', () => ({ + existsSync: mockExistsSync, +})); + +vi.mock('node:child_process', () => ({ + execFileSync: mockExecFileSync, + spawn: mockSpawn, +})); + +vi.mock('../launcher.js', () => ({ + discoverAppPath: mockDiscoverAppPath, + detectProcess: mockDetectProcess, +})); + +function setPlatform(platform: NodeJS.Platform): () => void { + const desc = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); + return () => { + if (desc) Object.defineProperty(process, 'platform', desc); + }; +} + +function setEnv(key: string, value: string): () => void { + const prev = process.env[key]; + process.env[key] = value; + return () => { + if (prev === undefined) delete process.env[key]; + else process.env[key] = prev; + }; +} + +describe('browser candidates', () => { + let restorePlatform = () => {}; + let restoreEnv: Array<() => void> = []; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + restorePlatform(); + for (const restore of restoreEnv) restore(); + restoreEnv = []; + }); + + it('returns linux candidates in Chrome -> Edge -> Chromium order', async () => { + restorePlatform = setPlatform('linux'); + + mockDetectProcess.mockReturnValue(false); + mockExecFileSync.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'which') { + const bin = args[0]; + if (bin === 'google-chrome-stable') return '/usr/bin/google-chrome-stable\n'; + if (bin === 'microsoft-edge-stable') return '/usr/bin/microsoft-edge-stable\n'; + if (bin === 'chromium') return '/usr/bin/chromium\n'; + throw new Error('not found'); + } + throw new Error(`unexpected cmd: ${cmd}`); + }); + + const { getBrowserCandidates } = await import('./candidates.js'); + const candidates = getBrowserCandidates(); + + expect(candidates.map((c) => c.id)).toEqual(['chrome', 'edge', 'chromium']); + expect(candidates.map((c) => c.executable)).toEqual([ + '/usr/bin/google-chrome-stable', + '/usr/bin/microsoft-edge-stable', + '/usr/bin/chromium', + ]); + }); + + it('prioritizes running browsers while preserving brand order', async () => { + restorePlatform = setPlatform('linux'); + + mockDetectProcess.mockImplementation((name: string) => name === 'microsoft-edge-stable'); + mockExecFileSync.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'which') { + const bin = args[0]; + if (bin === 'google-chrome-stable') return '/usr/bin/google-chrome-stable\n'; + if (bin === 'microsoft-edge-stable') return '/usr/bin/microsoft-edge-stable\n'; + if (bin === 'chromium') return '/usr/bin/chromium\n'; + throw new Error('not found'); + } + throw new Error(`unexpected cmd: ${cmd}`); + }); + + const { getBrowserCandidates } = await import('./candidates.js'); + const candidates = getBrowserCandidates(); + + expect(candidates.map((c) => c.id)).toEqual(['edge', 'chrome', 'chromium']); + expect(candidates.map((c) => c.running)).toEqual([true, false, false]); + }); + + it('skips browsers that are not installed (windows path probe)', async () => { + restorePlatform = setPlatform('win32'); + + restoreEnv.push(setEnv('ProgramFiles', 'C:\\Program Files')); + restoreEnv.push(setEnv('ProgramFiles(x86)', 'C:\\Program Files (x86)')); + restoreEnv.push(setEnv('LOCALAPPDATA', 'C:\\Users\\oops\\AppData\\Local')); + + mockExistsSync.mockImplementation((file: string) => file.replace(/\\/g, '/').endsWith('/Microsoft/Edge/Application/msedge.exe')); + + const { getBrowserCandidates } = await import('./candidates.js'); + const candidates = getBrowserCandidates(); + + expect(candidates.map((c) => c.id)).toEqual(['edge']); + }); + + it('returns macOS app candidates using discoverAppPath from launcher', async () => { + restorePlatform = setPlatform('darwin'); + + mockDiscoverAppPath.mockImplementation((name: string) => { + if (name === 'Google Chrome') return '/Applications/Google Chrome.app'; + if (name === 'Microsoft Edge') return '/Applications/Microsoft Edge.app'; + if (name === 'Chromium') return '/Applications/Chromium.app'; + return null; + }); + mockDetectProcess.mockReturnValue(false); + + const { getBrowserCandidates } = await import('./candidates.js'); + const candidates = getBrowserCandidates(); + + expect(candidates.map((c) => c.id)).toEqual(['chrome', 'edge', 'chromium']); + expect(candidates[0]?.executable).toBe('/Applications/Google Chrome.app'); + expect(mockDiscoverAppPath).toHaveBeenCalledWith('Google Chrome'); + }); + + it('launches a detected candidate (linux)', async () => { + restorePlatform = setPlatform('linux'); + mockSpawn.mockReturnValue({ unref: vi.fn(), on: vi.fn() }); + + const { launchBrowserCandidate } = await import('./candidates.js'); + await launchBrowserCandidate({ id: 'edge', name: 'Edge', executable: '/usr/bin/microsoft-edge-stable', running: false }); + + expect(mockSpawn).toHaveBeenCalledWith( + '/usr/bin/microsoft-edge-stable', + [], + expect.objectContaining({ detached: true, stdio: 'ignore' }), + ); + }); + + it('launches a detected candidate on macOS via open command', async () => { + restorePlatform = setPlatform('darwin'); + mockSpawn.mockReturnValue({ unref: vi.fn(), on: vi.fn() }); + + const { launchBrowserCandidate } = await import('./candidates.js'); + await launchBrowserCandidate({ id: 'edge', name: 'Edge', executable: '/Applications/Microsoft Edge.app', running: false }); + + expect(mockSpawn).toHaveBeenCalledWith( + 'open', + ['/Applications/Microsoft Edge.app'], + expect.objectContaining({ detached: true, stdio: 'ignore' }), + ); + }); + + it('swallows spawn ENOENT errors gracefully', async () => { + restorePlatform = setPlatform('linux'); + let errorHandler: ((err: Error) => void) | undefined; + mockSpawn.mockReturnValue({ + unref: vi.fn(), + on: vi.fn((event: string, handler: (err: Error) => void) => { + if (event === 'error') errorHandler = handler; + }), + }); + + const { launchBrowserCandidate } = await import('./candidates.js'); + await launchBrowserCandidate({ id: 'chrome', name: 'Chrome', executable: '/nonexistent', running: false }); + + // Calling the error handler should not throw + expect(() => errorHandler?.(new Error('spawn ENOENT'))).not.toThrow(); + }); +}); diff --git a/src/browser/candidates.ts b/src/browser/candidates.ts new file mode 100644 index 00000000..2215db42 --- /dev/null +++ b/src/browser/candidates.ts @@ -0,0 +1,147 @@ +/** + * Browser candidate detection — find installed Chromium-based browsers. + * + * Reuses launcher.ts helpers (discoverAppPath, detectProcess) on macOS, + * and adds Linux/Windows detection. + */ + +import { execFileSync, spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import * as path from 'node:path'; +import { discoverAppPath, detectProcess } from '../launcher.js'; + +export interface BrowserCandidate { + id: 'chrome' | 'edge' | 'chromium'; + name: string; + running: boolean; + /** macOS: app bundle path; Linux/Windows: executable path */ + executable: string; +} + +interface BrowserDef { + id: BrowserCandidate['id']; + name: string; + macAppName: string; + linuxBins: string[]; + winExeParts: string[][]; + processNames: string[]; +} + +const BROWSER_DEFS: BrowserDef[] = [ + { + id: 'chrome', + name: 'Chrome', + macAppName: 'Google Chrome', + linuxBins: ['google-chrome-stable', 'google-chrome'], + winExeParts: [['Google', 'Chrome', 'Application', 'chrome.exe']], + processNames: ['Google Chrome', 'google-chrome-stable', 'google-chrome', 'chrome', 'chrome.exe'], + }, + { + id: 'edge', + name: 'Edge', + macAppName: 'Microsoft Edge', + linuxBins: ['microsoft-edge-stable', 'microsoft-edge'], + winExeParts: [['Microsoft', 'Edge', 'Application', 'msedge.exe']], + processNames: ['Microsoft Edge', 'microsoft-edge-stable', 'microsoft-edge', 'msedge', 'msedge.exe'], + }, + { + id: 'chromium', + name: 'Chromium', + macAppName: 'Chromium', + linuxBins: ['chromium', 'chromium-browser'], + winExeParts: [['Chromium', 'Application', 'chrome.exe']], + processNames: ['Chromium', 'chromium', 'chromium-browser'], + }, +]; + +function tryWhich(bin: string): string | null { + try { + const out = execFileSync('which', [bin], { encoding: 'utf-8', stdio: 'pipe' }); + const resolved = String(out).split('\n')[0]?.trim(); + return resolved || null; + } catch { + return null; + } +} + +function winFirstExisting(parts: string[][]): string | null { + const bases = [ + process.env.ProgramFiles, + process.env['ProgramFiles(x86)'], + process.env.LOCALAPPDATA, + ].filter((x): x is string => typeof x === 'string' && x.length > 0); + + for (const p of parts) { + for (const base of bases) { + const full = path.win32.join(base, ...p); + try { if (existsSync(full)) return full; } catch { /* skip */ } + } + } + return null; +} + +function isBrowserRunning(processNames: string[]): boolean { + if (process.platform === 'darwin' || process.platform === 'linux') { + return processNames.some(name => detectProcess(name)); + } + if (process.platform === 'win32') { + for (const imageName of processNames) { + try { + const out = execFileSync('tasklist', ['/FI', `IMAGENAME eq ${imageName}`], { encoding: 'utf-8', stdio: 'pipe' }); + if (String(out).includes(imageName)) return true; + } catch { /* skip */ } + } + } + return false; +} + +function findExecutable(def: BrowserDef): string | null { + if (process.platform === 'darwin') { + return discoverAppPath(def.macAppName); + } + if (process.platform === 'linux') { + for (const bin of def.linuxBins) { + const found = tryWhich(bin); + if (found) return found; + } + return null; + } + if (process.platform === 'win32') { + return winFirstExisting(def.winExeParts); + } + return null; +} + +export function getBrowserCandidates(): BrowserCandidate[] { + const installed: BrowserCandidate[] = []; + + for (const def of BROWSER_DEFS) { + const executable = findExecutable(def); + if (executable) { + installed.push({ + id: def.id, + name: def.name, + executable, + running: isBrowserRunning(def.processNames), + }); + } + } + + // Running browsers first, preserving brand order within each group + return [ + ...installed.filter(c => c.running), + ...installed.filter(c => !c.running), + ]; +} + +export async function launchBrowserCandidate(candidate: BrowserCandidate): Promise { + const opts = { detached: true as const, stdio: 'ignore' as const }; + + const cmd = process.platform === 'darwin' + ? { bin: 'open', args: [candidate.executable] } + : { bin: candidate.executable, args: [] as string[] }; + + const child = spawn(cmd.bin, cmd.args, opts); + child.on('error', () => {}); // Swallow spawn errors (e.g. ENOENT) + child.unref(); +} diff --git a/src/doctor.test.ts b/src/doctor.test.ts index df28637e..49e3e632 100644 --- a/src/doctor.test.ts +++ b/src/doctor.test.ts @@ -1,10 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const { mockCheckDaemonStatus, mockListSessions, mockConnect, mockClose } = vi.hoisted(() => ({ +const { mockCheckDaemonStatus, mockListSessions, mockConnect, mockClose, mockInferredBrowserName } = vi.hoisted(() => ({ mockCheckDaemonStatus: vi.fn(), mockListSessions: vi.fn(), mockConnect: vi.fn(), mockClose: vi.fn(), + mockInferredBrowserName: { value: null as string | null }, })); vi.mock('./browser/discover.js', () => ({ @@ -19,6 +20,9 @@ vi.mock('./browser/index.js', () => ({ BrowserBridge: class { connect = mockConnect; close = mockClose; + get inferredBrowserName() { + return mockInferredBrowserName.value; + } }, })); @@ -29,6 +33,7 @@ describe('doctor report rendering', () => { beforeEach(() => { vi.clearAllMocks(); + mockInferredBrowserName.value = null; }); it('renders OK-style report when daemon and extension connected', () => { @@ -59,7 +64,7 @@ describe('doctor report rendering', () => { const text = strip(renderBrowserDoctorReport({ daemonRunning: true, extensionConnected: false, - issues: ['Daemon is running but the Chrome extension is not connected.'], + issues: ['Daemon is running but the Browser Bridge extension is not connected.'], })); expect(text).toContain('[OK] Daemon: running on port 19825'); @@ -77,6 +82,17 @@ describe('doctor report rendering', () => { expect(text).toContain('[OK] Connectivity: connected in 1.2s'); }); + it('renders inferred browser only when this run inferred it', () => { + const text = strip(renderBrowserDoctorReport({ + daemonRunning: true, + extensionConnected: true, + connectivity: { ok: true, durationMs: 1234, browserName: 'Edge' }, + issues: [], + })); + + expect(text).toContain('[OK] Browser: Edge (inferred from this run)'); + }); + it('renders connectivity SKIP when not tested', () => { const text = strip(renderBrowserDoctorReport({ daemonRunning: true, @@ -108,4 +124,41 @@ describe('doctor report rendering', () => { expect.stringContaining('Daemon is not running'), ])); }); + + it('mentions Chromium-based extension install hints when daemon is running but extension is disconnected', async () => { + mockCheckDaemonStatus.mockResolvedValue({ running: true, extensionConnected: false }); + + const report = await runBrowserDoctor({ live: false }); + + const text = report.issues.join('\n'); + expect(text).toContain('Chromium-based'); + expect(text).toContain('chrome://extensions'); + expect(text).toContain('edge://extensions'); + }); + + it('includes inferred browser name when connectivity established during this run', async () => { + mockCheckDaemonStatus + .mockResolvedValueOnce({ running: false, extensionConnected: false }) + .mockResolvedValueOnce({ running: true, extensionConnected: true }); + mockConnect.mockResolvedValue({ evaluate: vi.fn().mockResolvedValue(2) }); + mockClose.mockResolvedValue(undefined); + mockInferredBrowserName.value = 'Edge'; + + const report = await runBrowserDoctor({ live: true }); + + expect(report.connectivity).toEqual(expect.objectContaining({ ok: true, browserName: 'Edge' })); + }); + + it('preserves inferred browser from auto-start when live connectivity reuses an existing connection', async () => { + mockCheckDaemonStatus + .mockResolvedValueOnce({ running: false, extensionConnected: false }) + .mockResolvedValueOnce({ running: true, extensionConnected: true }); + mockConnect.mockResolvedValue({ evaluate: vi.fn().mockResolvedValue(2) }); + mockClose.mockResolvedValue(undefined); + mockInferredBrowserName.value = 'Edge'; + + const report = await runBrowserDoctor({ live: true }); + + expect(report.connectivity).toEqual(expect.objectContaining({ ok: true, browserName: 'Edge' })); + }); }); diff --git a/src/doctor.ts b/src/doctor.ts index 3d051112..cfd044a8 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -23,6 +23,7 @@ export type ConnectivityResult = { ok: boolean; error?: string; durationMs: number; + browserName?: string; }; @@ -47,7 +48,7 @@ export async function checkConnectivity(opts?: { timeout?: number }): Promise { // Try to auto-start daemon if it's not running, so we show accurate status. let initialStatus = await checkDaemonStatus(); + let inferredBrowserName: string | undefined; if (!initialStatus.running) { try { const bridge = new BrowserBridge(); await bridge.connect({ timeout: 5 }); + inferredBrowserName = bridge.inferredBrowserName ?? undefined; await bridge.close(); } catch { // Auto-start failed; we'll report it below. @@ -71,6 +74,9 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise