From 9f9705cfd903df3641b0e0e5f5ca97a7bc212f39 Mon Sep 17 00:00:00 2001 From: Zephan Date: Sat, 4 Apr 2026 00:48:17 +0800 Subject: [PATCH 1/6] feat(browser): auto-detect chromium browsers for bridge --- scripts/postinstall.js | 5 +- src/browser.test.ts | 124 ++++++++++++++++++++++++++- src/browser/bridge.ts | 86 ++++++++++++++----- src/browser/candidates.test.ts | 134 ++++++++++++++++++++++++++++++ src/browser/candidates.ts | 147 +++++++++++++++++++++++++++++++++ src/doctor.test.ts | 13 ++- src/doctor.ts | 8 +- src/execution.ts | 5 +- 8 files changed, 492 insertions(+), 30 deletions(-) create mode 100644 src/browser/candidates.test.ts create mode 100644 src/browser/candidates.ts 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..bb14a2cc 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,100 @@ 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 () => { + mockFetchDaemonStatus.mockResolvedValue({ extensionConnected: false } as any); + mockGetBrowserCandidates.mockReturnValue([ + { id: 'chrome', name: 'Chrome', executable: '/chrome' }, + { id: 'edge', name: 'Edge', executable: '/edge' }, + ]); + mockIsExtensionConnected.mockResolvedValue(false); + mockLaunchBrowserCandidate.mockImplementation(async (candidate: { id: string }) => { + if (candidate.id === 'edge') { + mockIsExtensionConnected.mockResolvedValue(true); + } + }); + + const bridge = new BrowserBridge(); + + await bridge.connect({ timeout: 1 }); + + expect(mockLaunchBrowserCandidate).toHaveBeenNthCalledWith(1, expect.objectContaining({ id: 'chrome' })); + expect(mockLaunchBrowserCandidate).toHaveBeenNthCalledWith(2, expect.objectContaining({ id: 'edge' })); + }); + + it('includes detected and tried browsers in the final error', async () => { + mockFetchDaemonStatus.mockResolvedValue({ extensionConnected: false } as any); + mockGetBrowserCandidates.mockReturnValue([ + { id: 'chrome', name: 'Chrome', executable: '/chrome' }, + { id: 'edge', name: 'Edge', executable: '/edge' }, + ]); + mockIsExtensionConnected.mockResolvedValue(false); + + const bridge = new BrowserBridge(); + let message = ''; + + await bridge.connect({ timeout: 1 }).catch((error) => { + message = error instanceof Error ? error.message : String(error); + }); + + expect(message).toContain('Detected browsers: Chrome, Edge'); + expect(message).toContain('Tried browsers: Chrome, Edge'); + }); + + 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' }, + { id: 'edge', name: 'Edge', executable: '/edge' }, + ]; + }); + 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: 1 }); + + await vi.advanceTimersByTimeAsync(1200); + 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..f227feb7 100644 --- a/src/browser/bridge.ts +++ b/src/browser/bridge.ts @@ -10,9 +10,12 @@ 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'; @@ -23,6 +26,8 @@ export class BrowserBridge implements IBrowserFactory { private _state: BrowserBridgeState = 'idle'; private _page: Page | null = null; private _daemonProc: ChildProcess | null = null; + private _lastDetectedBrowsers: string[] = []; + private _lastTriedBrowsers: string[] = []; get state(): BrowserBridgeState { return this._state; @@ -69,18 +74,10 @@ 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.', - ); + if (await this._tryLaunchBrowsers(timeoutMs)) return; + throw new Error(this._buildExtensionError(this._lastDetectedBrowsers, this._lastTriedBrowsers)); } // No daemon — spawn one @@ -107,17 +104,10 @@ 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; - } + if (await this._tryLaunchBrowsers(timeoutMs)) 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(this._lastDetectedBrowsers, this._lastTriedBrowsers)); } throw new Error( @@ -126,4 +116,60 @@ export class BrowserBridge implements IBrowserFactory { `Make sure port ${DEFAULT_DAEMON_PORT} is available.`, ); } + + private async _tryLaunchBrowsers(timeoutMs: number): Promise { + const candidates = getBrowserCandidates(); + this._lastDetectedBrowsers = candidates.map(candidate => candidate.name); + this._lastTriedBrowsers = []; + + if (await isExtensionConnected()) return true; + const startedAt = Date.now(); + + 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) { + this._lastTriedBrowsers.push(candidate.name); + if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) { + process.stderr.write(` Trying browser: ${candidate.name}\n`); + } + await launchBrowserCandidate(candidate); + if (await isExtensionConnected()) return true; + const remainingMs = Math.max(0, timeoutMs - (Date.now() - startedAt)); + if (remainingMs <= 0) break; + if (await this._waitForExtensionConnection(Math.min(perBrowserWaitMs, remainingMs))) return true; + } + + const remainingMs = Math.max(0, timeoutMs - (Date.now() - startedAt)); + if (remainingMs > 0 && await this._waitForExtensionConnection(remainingMs)) return true; + return false; + } + + 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..338178e3 --- /dev/null +++ b/src/browser/candidates.test.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it, vi, afterEach } from 'vitest'; + +const { mockExistsSync, mockExecFileSync, mockSpawn } = vi.hoisted(() => ({ + mockExistsSync: vi.fn(), + mockExecFileSync: vi.fn(), + mockSpawn: vi.fn(), +})); + +vi.mock('node:fs', () => ({ + existsSync: mockExistsSync, +})); + +vi.mock('node:child_process', () => ({ + execFileSync: mockExecFileSync, + spawn: mockSpawn, +})); + +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'); + + mockExecFileSync.mockImplementation((cmd: string, args: string[]) => { + if (cmd !== 'which') throw new Error(`unexpected cmd: ${cmd}`); + 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'); + }); + + 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('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 in order when apps are discoverable', async () => { + restorePlatform = setPlatform('darwin'); + + mockExecFileSync.mockImplementation((cmd: string, args: string[], opts: any) => { + expect(cmd).toBe('osascript'); + expect(opts?.encoding).toBe('utf-8'); + const script = String(args[1] ?? ''); + if (script.includes('Google Chrome')) return '/Applications/Google Chrome.app/\n'; + if (script.includes('Microsoft Edge')) return '/Applications/Microsoft Edge.app/\n'; + if (script.includes('Chromium')) return '/Applications/Chromium.app/\n'; + throw new Error('app not found'); + }); + + 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'); + }); + + it('launches a detected candidate (linux)', async () => { + restorePlatform = setPlatform('linux'); + mockSpawn.mockReturnValue({ unref: vi.fn() }); + + const { launchBrowserCandidate } = await import('./candidates.js'); + await launchBrowserCandidate({ id: 'edge', name: 'Edge', executable: '/usr/bin/microsoft-edge-stable' }); + + expect(mockSpawn).toHaveBeenCalledWith( + '/usr/bin/microsoft-edge-stable', + [], + expect.objectContaining({ detached: true, stdio: 'ignore' }), + ); + }); + + it('launches a detected candidate on macOS via app bundle path', async () => { + restorePlatform = setPlatform('darwin'); + mockSpawn.mockReturnValue({ unref: vi.fn() }); + + const { launchBrowserCandidate } = await import('./candidates.js'); + await launchBrowserCandidate({ id: 'edge', name: 'Edge', executable: '/Applications/Microsoft Edge.app' }); + + expect(mockSpawn).toHaveBeenCalledWith( + 'open', + ['/Applications/Microsoft Edge.app'], + expect.objectContaining({ detached: true, stdio: 'ignore' }), + ); + }); +}); diff --git a/src/browser/candidates.ts b/src/browser/candidates.ts new file mode 100644 index 00000000..e7704c33 --- /dev/null +++ b/src/browser/candidates.ts @@ -0,0 +1,147 @@ +import { execFileSync, spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import * as path from 'node:path'; + +export interface BrowserCandidate { + id: 'chrome' | 'edge' | 'chromium'; + name: string; + /** + * macOS: app bundle path (e.g. /Applications/Google Chrome.app) + * Linux/Windows: executable path + */ + executable: string; +} + +function trimPath(value: string): string { + return value.trim().replace(/\/$/, ''); +} + +function tryWhich(bin: string): string | null { + try { + const out = execFileSync('which', [bin], { encoding: 'utf-8', stdio: 'pipe' }); + const firstLine = String(out).split('\n')[0] ?? ''; + const resolved = firstLine.trim(); + return resolved ? resolved : null; + } catch { + return null; + } +} + +function tryDiscoverMacApp(displayName: string): string | null { + if (process.platform !== 'darwin') return null; + try { + const out = execFileSync('osascript', [ + '-e', + `POSIX path of (path to application "${displayName}")`, + ], { encoding: 'utf-8', stdio: 'pipe', timeout: 5_000 }); + const resolved = trimPath(String(out)); + return resolved ? resolved : null; + } catch { + return null; + } +} + +function winPathsFromEnv(parts: string[]): string[] { + const result: string[] = []; + const pf = process.env.ProgramFiles; + const pfx86 = process.env['ProgramFiles(x86)']; + const local = process.env.LOCALAPPDATA; + + const bases = [ + pf, + pfx86, + local, + ].filter((x): x is string => typeof x === 'string' && x.length > 0); + + for (const base of bases) { + result.push(path.win32.join(base, ...parts)); + } + return result; +} + +function firstExisting(paths: string[]): string | null { + for (const p of paths) { + try { + if (existsSync(p)) return p; + } catch { + // ignore invalid path inputs + } + } + return null; +} + +export function getBrowserCandidates(): BrowserCandidate[] { + const out: BrowserCandidate[] = []; + + const defs: Array<{ + id: BrowserCandidate['id']; + name: string; + macAppName: string; + linuxBins: string[]; + winExeParts: string[][]; + }> = [ + { + id: 'chrome', + name: 'Chrome', + macAppName: 'Google Chrome', + linuxBins: ['google-chrome-stable', 'google-chrome'], + winExeParts: [ + ['Google', 'Chrome', 'Application', 'chrome.exe'], + ], + }, + { + id: 'edge', + name: 'Edge', + macAppName: 'Microsoft Edge', + linuxBins: ['microsoft-edge-stable', 'microsoft-edge'], + winExeParts: [ + ['Microsoft', 'Edge', 'Application', 'msedge.exe'], + ], + }, + { + id: 'chromium', + name: 'Chromium', + macAppName: 'Chromium', + linuxBins: ['chromium', 'chromium-browser'], + winExeParts: [ + ['Chromium', 'Application', 'chrome.exe'], + ], + }, + ]; + + for (const def of defs) { + let executable: string | null = null; + + if (process.platform === 'darwin') { + executable = tryDiscoverMacApp(def.macAppName); + } else if (process.platform === 'linux') { + for (const bin of def.linuxBins) { + executable = tryWhich(bin); + if (executable) break; + } + } else if (process.platform === 'win32') { + const candidates: string[] = []; + for (const parts of def.winExeParts) { + candidates.push(...winPathsFromEnv(parts)); + } + executable = firstExisting(candidates); + } + + if (executable) out.push({ id: def.id, name: def.name, executable: trimPath(executable) }); + } + + return out; +} + +export async function launchBrowserCandidate(candidate: BrowserCandidate): Promise { + const opts = { detached: true as const, stdio: 'ignore' as const, env: { ...process.env } }; + + if (process.platform === 'darwin') { + const child = spawn('open', [candidate.executable], opts); + child.unref(); + return; + } + + const child = spawn(candidate.executable, [], opts); + child.unref(); +} diff --git a/src/doctor.test.ts b/src/doctor.test.ts index df28637e..7895d1d0 100644 --- a/src/doctor.test.ts +++ b/src/doctor.test.ts @@ -59,7 +59,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'); @@ -108,4 +108,15 @@ 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'); + }); }); diff --git a/src/doctor.ts b/src/doctor.ts index 3d051112..71b17263 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -84,11 +84,13 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise Date: Sat, 4 Apr 2026 01:53:33 +0800 Subject: [PATCH 2/6] feat(browser): prefer running browsers for bridge --- src/browser.test.ts | 1 + src/browser/bridge.ts | 16 ++++++++-- src/browser/candidates.test.ts | 44 ++++++++++++++++++++++----- src/browser/candidates.ts | 55 ++++++++++++++++++++++++++++++++-- src/doctor.test.ts | 31 ++++++++++++++++++- src/doctor.ts | 6 +++- 6 files changed, 138 insertions(+), 15 deletions(-) diff --git a/src/browser.test.ts b/src/browser.test.ts index bb14a2cc..fd5df0a2 100644 --- a/src/browser.test.ts +++ b/src/browser.test.ts @@ -192,6 +192,7 @@ describe('BrowserBridge state', () => { expect(mockLaunchBrowserCandidate).toHaveBeenNthCalledWith(1, expect.objectContaining({ id: 'chrome' })); expect(mockLaunchBrowserCandidate).toHaveBeenNthCalledWith(2, expect.objectContaining({ id: 'edge' })); + expect(bridge.inferredBrowserName).toBe('Edge'); }); it('includes detected and tried browsers in the final error', async () => { diff --git a/src/browser/bridge.ts b/src/browser/bridge.ts index f227feb7..eb4f2d9f 100644 --- a/src/browser/bridge.ts +++ b/src/browser/bridge.ts @@ -28,11 +28,16 @@ export class BrowserBridge implements IBrowserFactory { private _daemonProc: ChildProcess | null = null; private _lastDetectedBrowsers: string[] = []; private _lastTriedBrowsers: string[] = []; + 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'); @@ -40,6 +45,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); @@ -135,10 +141,16 @@ export class BrowserBridge implements IBrowserFactory { process.stderr.write(` Trying browser: ${candidate.name}\n`); } await launchBrowserCandidate(candidate); - if (await isExtensionConnected()) return true; + if (await isExtensionConnected()) { + this._inferredBrowserName = candidate.name; + return true; + } const remainingMs = Math.max(0, timeoutMs - (Date.now() - startedAt)); if (remainingMs <= 0) break; - if (await this._waitForExtensionConnection(Math.min(perBrowserWaitMs, remainingMs))) return true; + if (await this._waitForExtensionConnection(Math.min(perBrowserWaitMs, remainingMs))) { + this._inferredBrowserName = candidate.name; + return true; + } } const remainingMs = Math.max(0, timeoutMs - (Date.now() - startedAt)); diff --git a/src/browser/candidates.test.ts b/src/browser/candidates.test.ts index 338178e3..af7ed316 100644 --- a/src/browser/candidates.test.ts +++ b/src/browser/candidates.test.ts @@ -50,12 +50,15 @@ describe('browser candidates', () => { restorePlatform = setPlatform('linux'); mockExecFileSync.mockImplementation((cmd: string, args: string[]) => { - if (cmd !== 'which') throw new Error(`unexpected cmd: ${cmd}`); - 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'); + const bin = cmd === 'pgrep' ? args[1] : args[0]; + if (cmd === 'which') { + 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'); + } + if (cmd === 'pgrep') throw new Error('not running'); + throw new Error(`unexpected cmd: ${cmd}`); }); const { getBrowserCandidates } = await import('./candidates.js'); @@ -69,6 +72,31 @@ describe('browser candidates', () => { ]); }); + it('prioritizes running browsers while preserving brand order', async () => { + restorePlatform = setPlatform('linux'); + + mockExecFileSync.mockImplementation((cmd: string, args: string[]) => { + const bin = cmd === 'pgrep' ? args[1] : args[0]; + if (cmd === 'which') { + 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'); + } + if (cmd === 'pgrep') { + if (bin === 'microsoft-edge-stable') return '123\n'; + throw new Error('not running'); + } + throw new Error('not found'); + }); + + 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'); @@ -109,7 +137,7 @@ describe('browser candidates', () => { mockSpawn.mockReturnValue({ unref: vi.fn() }); const { launchBrowserCandidate } = await import('./candidates.js'); - await launchBrowserCandidate({ id: 'edge', name: 'Edge', executable: '/usr/bin/microsoft-edge-stable' }); + await launchBrowserCandidate({ id: 'edge', name: 'Edge', executable: '/usr/bin/microsoft-edge-stable', running: false }); expect(mockSpawn).toHaveBeenCalledWith( '/usr/bin/microsoft-edge-stable', @@ -123,7 +151,7 @@ describe('browser candidates', () => { mockSpawn.mockReturnValue({ unref: vi.fn() }); const { launchBrowserCandidate } = await import('./candidates.js'); - await launchBrowserCandidate({ id: 'edge', name: 'Edge', executable: '/Applications/Microsoft Edge.app' }); + await launchBrowserCandidate({ id: 'edge', name: 'Edge', executable: '/Applications/Microsoft Edge.app', running: false }); expect(mockSpawn).toHaveBeenCalledWith( 'open', diff --git a/src/browser/candidates.ts b/src/browser/candidates.ts index e7704c33..535a5f8a 100644 --- a/src/browser/candidates.ts +++ b/src/browser/candidates.ts @@ -5,6 +5,7 @@ import * as path from 'node:path'; export interface BrowserCandidate { id: 'chrome' | 'edge' | 'chromium'; name: string; + running: boolean; /** * macOS: app bundle path (e.g. /Applications/Google Chrome.app) * Linux/Windows: executable path @@ -70,8 +71,42 @@ function firstExisting(paths: string[]): string | null { return null; } +function isRunningUnix(processNames: string[]): boolean { + for (const processName of processNames) { + try { + execFileSync('pgrep', ['-x', processName], { encoding: 'utf-8', stdio: 'pipe' }); + return true; + } catch { + // try next process name + } + } + return false; +} + +function isRunningWindows(imageNames: string[]): boolean { + for (const imageName of imageNames) { + try { + const out = execFileSync('tasklist', ['/FI', `IMAGENAME eq ${imageName}`], { encoding: 'utf-8', stdio: 'pipe' }); + if (String(out).includes(imageName)) return true; + } catch { + // try next image name + } + } + return false; +} + +function isBrowserRunning(processNames: string[]): boolean { + if (process.platform === 'darwin' || process.platform === 'linux') { + return isRunningUnix(processNames); + } + if (process.platform === 'win32') { + return isRunningWindows(processNames); + } + return false; +} + export function getBrowserCandidates(): BrowserCandidate[] { - const out: BrowserCandidate[] = []; + const installed: BrowserCandidate[] = []; const defs: Array<{ id: BrowserCandidate['id']; @@ -79,6 +114,7 @@ export function getBrowserCandidates(): BrowserCandidate[] { macAppName: string; linuxBins: string[]; winExeParts: string[][]; + processNames: string[]; }> = [ { id: 'chrome', @@ -88,6 +124,7 @@ export function getBrowserCandidates(): BrowserCandidate[] { winExeParts: [ ['Google', 'Chrome', 'Application', 'chrome.exe'], ], + processNames: ['Google Chrome', 'google-chrome-stable', 'google-chrome', 'chrome', 'chrome.exe'], }, { id: 'edge', @@ -97,6 +134,7 @@ export function getBrowserCandidates(): BrowserCandidate[] { winExeParts: [ ['Microsoft', 'Edge', 'Application', 'msedge.exe'], ], + processNames: ['Microsoft Edge', 'microsoft-edge-stable', 'microsoft-edge', 'msedge', 'msedge.exe'], }, { id: 'chromium', @@ -106,6 +144,7 @@ export function getBrowserCandidates(): BrowserCandidate[] { winExeParts: [ ['Chromium', 'Application', 'chrome.exe'], ], + processNames: ['Chromium', 'chromium', 'chromium-browser', 'chrome.exe'], }, ]; @@ -127,10 +166,20 @@ export function getBrowserCandidates(): BrowserCandidate[] { executable = firstExisting(candidates); } - if (executable) out.push({ id: def.id, name: def.name, executable: trimPath(executable) }); + if (executable) { + installed.push({ + id: def.id, + name: def.name, + executable: trimPath(executable), + running: isBrowserRunning(def.processNames), + }); + } } - return out; + return [ + ...installed.filter(candidate => candidate.running), + ...installed.filter(candidate => !candidate.running), + ]; } export async function launchBrowserCandidate(candidate: BrowserCandidate): Promise { diff --git a/src/doctor.test.ts b/src/doctor.test.ts index 7895d1d0..d27e2bbb 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', () => { @@ -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, @@ -119,4 +135,17 @@ describe('doctor report rendering', () => { 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' })); + }); }); diff --git a/src/doctor.ts b/src/doctor.ts index 71b17263..a6931d89 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 Date: Sat, 4 Apr 2026 02:02:30 +0800 Subject: [PATCH 3/6] fix(doctor): preserve inferred browser across auto-start --- src/doctor.test.ts | 13 +++++++++++++ src/doctor.ts | 5 +++++ 2 files changed, 18 insertions(+) diff --git a/src/doctor.test.ts b/src/doctor.test.ts index d27e2bbb..49e3e632 100644 --- a/src/doctor.test.ts +++ b/src/doctor.test.ts @@ -148,4 +148,17 @@ describe('doctor report rendering', () => { 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 a6931d89..cfd044a8 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -57,10 +57,12 @@ 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. @@ -72,6 +74,9 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise Date: Sat, 4 Apr 2026 02:23:04 +0800 Subject: [PATCH 4/6] fix(browser): wait on running browsers before auto-launch --- src/browser.test.ts | 62 +++++++++++++++++++++++++++++++++++++++++++ src/browser/bridge.ts | 38 +++++++++++++++++++------- 2 files changed, 90 insertions(+), 10 deletions(-) diff --git a/src/browser.test.ts b/src/browser.test.ts index fd5df0a2..996c79db 100644 --- a/src/browser.test.ts +++ b/src/browser.test.ts @@ -195,6 +195,68 @@ describe('BrowserBridge state', () => { expect(bridge.inferredBrowserName).toBe('Edge'); }); + it('waits on running browsers before launching unopened browsers', 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: 1 }); + + setTimeout(() => { + connected = true; + }, 450); + + await vi.advanceTimersByTimeAsync(1_000); + await promise; + + expect(mockLaunchBrowserCandidate).not.toHaveBeenCalled(); + expect(bridge.inferredBrowserName).toBe('Edge'); + } 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: 1 }); + + await vi.advanceTimersByTimeAsync(1_000); + 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 () => { mockFetchDaemonStatus.mockResolvedValue({ extensionConnected: false } as any); mockGetBrowserCandidates.mockReturnValue([ diff --git a/src/browser/bridge.ts b/src/browser/bridge.ts index eb4f2d9f..660b2d54 100644 --- a/src/browser/bridge.ts +++ b/src/browser/bridge.ts @@ -130,31 +130,49 @@ export class BrowserBridge implements IBrowserFactory { if (await isExtensionConnected()) return true; const startedAt = Date.now(); - + const runningCandidates = candidates.filter(candidate => candidate.running); + const stoppedCandidates = candidates.filter(candidate => !candidate.running); const perBrowserWaitMs = candidates.length > 0 ? Math.min(MAX_PER_BROWSER_WAIT_MS, Math.max(EXTENSION_POLL_INTERVAL_MS, Math.floor(timeoutMs / candidates.length))) : timeoutMs; + if (await this._tryCandidateGroup(runningCandidates, { launch: false, timeoutMs, startedAt, perBrowserWaitMs })) return true; + if (await this._tryCandidateGroup(stoppedCandidates, { launch: true, timeoutMs, startedAt, perBrowserWaitMs })) return true; + + const remainingMs = Math.max(0, timeoutMs - (Date.now() - startedAt)); + if (remainingMs > 0 && await this._waitForExtensionConnection(remainingMs)) return true; + return false; + } + + private async _tryCandidateGroup( + candidates: ReturnType, + opts: { launch: boolean; timeoutMs: number; startedAt: number; perBrowserWaitMs: number }, + ): Promise { + if (candidates.length === 0) return false; + for (const candidate of candidates) { + const remainingMs = Math.max(0, opts.timeoutMs - (Date.now() - opts.startedAt)); + if (remainingMs <= 0) break; + this._lastTriedBrowsers.push(candidate.name); if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) { process.stderr.write(` Trying browser: ${candidate.name}\n`); } - await launchBrowserCandidate(candidate); - if (await isExtensionConnected()) { - this._inferredBrowserName = candidate.name; - return true; + + if (opts.launch) { + await launchBrowserCandidate(candidate); + if (await isExtensionConnected()) { + this._inferredBrowserName = candidate.name; + return true; + } } - const remainingMs = Math.max(0, timeoutMs - (Date.now() - startedAt)); - if (remainingMs <= 0) break; - if (await this._waitForExtensionConnection(Math.min(perBrowserWaitMs, remainingMs))) { + + if (await this._waitForExtensionConnection(Math.min(opts.perBrowserWaitMs, remainingMs))) { this._inferredBrowserName = candidate.name; return true; } } - const remainingMs = Math.max(0, timeoutMs - (Date.now() - startedAt)); - if (remainingMs > 0 && await this._waitForExtensionConnection(remainingMs)) return true; return false; } From ea269b19149f2203c2f0d16b95aaac68b7d0d866 Mon Sep 17 00:00:00 2001 From: jackwener Date: Sat, 4 Apr 2026 02:31:06 +0800 Subject: [PATCH 5/6] refactor(browser): simplify candidates and reuse launcher helpers - Reuse discoverAppPath/detectProcess from launcher.ts instead of duplicating - Add spawn error handler to prevent unhandled ENOENT crashes - Simplify _tryLaunchBrowsers: remove _tryCandidateGroup indirection - Extract BROWSER_DEFS constant for clarity --- src/browser.test.ts | 95 ++++++++------ src/browser/bridge.ts | 33 ++--- src/browser/candidates.test.ts | 62 ++++++---- src/browser/candidates.ts | 219 +++++++++++++-------------------- 4 files changed, 191 insertions(+), 218 deletions(-) diff --git a/src/browser.test.ts b/src/browser.test.ts index 996c79db..4d06f641 100644 --- a/src/browser.test.ts +++ b/src/browser.test.ts @@ -174,28 +174,35 @@ describe('BrowserBridge state', () => { }); it('tries detected browsers in order until the extension connects', async () => { - mockFetchDaemonStatus.mockResolvedValue({ extensionConnected: false } as any); - mockGetBrowserCandidates.mockReturnValue([ - { id: 'chrome', name: 'Chrome', executable: '/chrome' }, - { id: 'edge', name: 'Edge', executable: '/edge' }, - ]); - mockIsExtensionConnected.mockResolvedValue(false); - mockLaunchBrowserCandidate.mockImplementation(async (candidate: { id: string }) => { - if (candidate.id === 'edge') { - mockIsExtensionConnected.mockResolvedValue(true); - } - }); + 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 bridge = new BrowserBridge(); + const promise = bridge.connect({ timeout: 5 }); - await bridge.connect({ timeout: 1 }); + 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'); + 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 before launching unopened browsers', async () => { + it('waits on running browsers without launching them', async () => { vi.useFakeTimers(); try { vi.setSystemTime(0); @@ -211,17 +218,19 @@ describe('BrowserBridge state', () => { mockLaunchBrowserCandidate.mockResolvedValue(undefined); const bridge = new BrowserBridge(); - const promise = bridge.connect({ timeout: 1 }); + const promise = bridge.connect({ timeout: 5 }); setTimeout(() => { connected = true; }, 450); - await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(5000); await promise; + // Running browsers should not be launched expect(mockLaunchBrowserCandidate).not.toHaveBeenCalled(); - expect(bridge.inferredBrowserName).toBe('Edge'); + // Chrome is first running candidate being polled when extension connects + expect(bridge.inferredBrowserName).toBe('Chrome'); } finally { vi.useRealTimers(); } @@ -244,9 +253,9 @@ describe('BrowserBridge state', () => { }); const bridge = new BrowserBridge(); - const promise = bridge.connect({ timeout: 1 }); + const promise = bridge.connect({ timeout: 5 }); - await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(5000); await promise; expect(mockLaunchBrowserCandidate).toHaveBeenCalledTimes(1); @@ -258,22 +267,30 @@ describe('BrowserBridge state', () => { }); it('includes detected and tried browsers in the final error', async () => { - mockFetchDaemonStatus.mockResolvedValue({ extensionConnected: false } as any); - mockGetBrowserCandidates.mockReturnValue([ - { id: 'chrome', name: 'Chrome', executable: '/chrome' }, - { id: 'edge', name: 'Edge', executable: '/edge' }, - ]); - mockIsExtensionConnected.mockResolvedValue(false); + 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 bridge = new BrowserBridge(); + let message = ''; - await bridge.connect({ timeout: 1 }).catch((error) => { - message = error instanceof Error ? error.message : String(error); - }); + const promise = bridge.connect({ timeout: 5 }).catch((error) => { + message = error instanceof Error ? error.message : String(error); + }); - expect(message).toContain('Detected browsers: Chrome, Edge'); - expect(message).toContain('Tried browsers: Chrome, Edge'); + 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 () => { @@ -299,8 +316,8 @@ describe('BrowserBridge state', () => { mockGetBrowserCandidates.mockImplementation(() => { vi.setSystemTime(800); return [ - { id: 'chrome', name: 'Chrome', executable: '/chrome' }, - { id: 'edge', name: 'Edge', executable: '/edge' }, + { id: 'chrome', name: 'Chrome', executable: '/chrome', running: false }, + { id: 'edge', name: 'Edge', executable: '/edge', running: false }, ]; }); mockIsExtensionConnected.mockResolvedValue(false); @@ -311,9 +328,9 @@ describe('BrowserBridge state', () => { }); const bridge = new BrowserBridge(); - const promise = bridge.connect({ timeout: 1 }); + const promise = bridge.connect({ timeout: 5 }); - await vi.advanceTimersByTimeAsync(1200); + await vi.advanceTimersByTimeAsync(5000); await promise; expect(mockLaunchBrowserCandidate).toHaveBeenNthCalledWith(1, expect.objectContaining({ id: 'chrome' })); diff --git a/src/browser/bridge.ts b/src/browser/bridge.ts index 660b2d54..6d21a400 100644 --- a/src/browser/bridge.ts +++ b/src/browser/bridge.ts @@ -125,41 +125,22 @@ export class BrowserBridge implements IBrowserFactory { private async _tryLaunchBrowsers(timeoutMs: number): Promise { const candidates = getBrowserCandidates(); - this._lastDetectedBrowsers = candidates.map(candidate => candidate.name); + this._lastDetectedBrowsers = candidates.map(c => c.name); this._lastTriedBrowsers = []; if (await isExtensionConnected()) return true; - const startedAt = Date.now(); - const runningCandidates = candidates.filter(candidate => candidate.running); - const stoppedCandidates = candidates.filter(candidate => !candidate.running); - const perBrowserWaitMs = candidates.length > 0 - ? Math.min(MAX_PER_BROWSER_WAIT_MS, Math.max(EXTENSION_POLL_INTERVAL_MS, Math.floor(timeoutMs / candidates.length))) - : timeoutMs; - - if (await this._tryCandidateGroup(runningCandidates, { launch: false, timeoutMs, startedAt, perBrowserWaitMs })) return true; - if (await this._tryCandidateGroup(stoppedCandidates, { launch: true, timeoutMs, startedAt, perBrowserWaitMs })) return true; - - const remainingMs = Math.max(0, timeoutMs - (Date.now() - startedAt)); - if (remainingMs > 0 && await this._waitForExtensionConnection(remainingMs)) return true; - return false; - } - private async _tryCandidateGroup( - candidates: ReturnType, - opts: { launch: boolean; timeoutMs: number; startedAt: number; perBrowserWaitMs: number }, - ): Promise { - if (candidates.length === 0) return false; + const deadline = Date.now() + timeoutMs; for (const candidate of candidates) { - const remainingMs = Math.max(0, opts.timeoutMs - (Date.now() - opts.startedAt)); - if (remainingMs <= 0) break; + if (Date.now() >= deadline) break; this._lastTriedBrowsers.push(candidate.name); if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) { process.stderr.write(` Trying browser: ${candidate.name}\n`); } - if (opts.launch) { + if (!candidate.running) { await launchBrowserCandidate(candidate); if (await isExtensionConnected()) { this._inferredBrowserName = candidate.name; @@ -167,12 +148,16 @@ export class BrowserBridge implements IBrowserFactory { } } - if (await this._waitForExtensionConnection(Math.min(opts.perBrowserWaitMs, remainingMs))) { + const waitMs = Math.min(MAX_PER_BROWSER_WAIT_MS, Math.max(0, deadline - Date.now())); + if (waitMs > 0 && await this._waitForExtensionConnection(waitMs)) { this._inferredBrowserName = candidate.name; return true; } } + // Use any remaining time for a final wait + const remaining = Math.max(0, deadline - Date.now()); + if (remaining > 0 && await this._waitForExtensionConnection(remaining)) return true; return false; } diff --git a/src/browser/candidates.test.ts b/src/browser/candidates.test.ts index af7ed316..0ececeb9 100644 --- a/src/browser/candidates.test.ts +++ b/src/browser/candidates.test.ts @@ -1,9 +1,11 @@ import { beforeEach, describe, expect, it, vi, afterEach } from 'vitest'; -const { mockExistsSync, mockExecFileSync, mockSpawn } = vi.hoisted(() => ({ +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', () => ({ @@ -15,6 +17,11 @@ vi.mock('node:child_process', () => ({ 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 }); @@ -49,15 +56,15 @@ describe('browser candidates', () => { it('returns linux candidates in Chrome -> Edge -> Chromium order', async () => { restorePlatform = setPlatform('linux'); + mockDetectProcess.mockReturnValue(false); mockExecFileSync.mockImplementation((cmd: string, args: string[]) => { - const bin = cmd === 'pgrep' ? args[1] : args[0]; 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'); } - if (cmd === 'pgrep') throw new Error('not running'); throw new Error(`unexpected cmd: ${cmd}`); }); @@ -75,19 +82,16 @@ describe('browser candidates', () => { 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[]) => { - const bin = cmd === 'pgrep' ? args[1] : args[0]; 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'); } - if (cmd === 'pgrep') { - if (bin === 'microsoft-edge-stable') return '123\n'; - throw new Error('not running'); - } - throw new Error('not found'); + throw new Error(`unexpected cmd: ${cmd}`); }); const { getBrowserCandidates } = await import('./candidates.js'); @@ -112,29 +116,28 @@ describe('browser candidates', () => { expect(candidates.map((c) => c.id)).toEqual(['edge']); }); - it('returns macOS app candidates in order when apps are discoverable', async () => { + it('returns macOS app candidates using discoverAppPath from launcher', async () => { restorePlatform = setPlatform('darwin'); - mockExecFileSync.mockImplementation((cmd: string, args: string[], opts: any) => { - expect(cmd).toBe('osascript'); - expect(opts?.encoding).toBe('utf-8'); - const script = String(args[1] ?? ''); - if (script.includes('Google Chrome')) return '/Applications/Google Chrome.app/\n'; - if (script.includes('Microsoft Edge')) return '/Applications/Microsoft Edge.app/\n'; - if (script.includes('Chromium')) return '/Applications/Chromium.app/\n'; - throw new Error('app not found'); + 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() }); + 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 }); @@ -146,9 +149,9 @@ describe('browser candidates', () => { ); }); - it('launches a detected candidate on macOS via app bundle path', async () => { + it('launches a detected candidate on macOS via open command', async () => { restorePlatform = setPlatform('darwin'); - mockSpawn.mockReturnValue({ unref: vi.fn() }); + 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 }); @@ -159,4 +162,21 @@ describe('browser candidates', () => { 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 index 535a5f8a..b0f7e261 100644 --- a/src/browser/candidates.ts +++ b/src/browser/candidates.ts @@ -1,196 +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 (e.g. /Applications/Google Chrome.app) - * Linux/Windows: executable path - */ + /** macOS: app bundle path; Linux/Windows: executable path */ executable: string; } -function trimPath(value: string): string { - return value.trim().replace(/\/$/, ''); +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', 'chrome.exe'], + }, +]; + function tryWhich(bin: string): string | null { try { const out = execFileSync('which', [bin], { encoding: 'utf-8', stdio: 'pipe' }); - const firstLine = String(out).split('\n')[0] ?? ''; - const resolved = firstLine.trim(); - return resolved ? resolved : null; - } catch { - return null; - } -} - -function tryDiscoverMacApp(displayName: string): string | null { - if (process.platform !== 'darwin') return null; - try { - const out = execFileSync('osascript', [ - '-e', - `POSIX path of (path to application "${displayName}")`, - ], { encoding: 'utf-8', stdio: 'pipe', timeout: 5_000 }); - const resolved = trimPath(String(out)); - return resolved ? resolved : null; + const resolved = String(out).split('\n')[0]?.trim(); + return resolved || null; } catch { return null; } } -function winPathsFromEnv(parts: string[]): string[] { - const result: string[] = []; - const pf = process.env.ProgramFiles; - const pfx86 = process.env['ProgramFiles(x86)']; - const local = process.env.LOCALAPPDATA; - +function winFirstExisting(parts: string[][]): string | null { const bases = [ - pf, - pfx86, - local, + process.env.ProgramFiles, + process.env['ProgramFiles(x86)'], + process.env.LOCALAPPDATA, ].filter((x): x is string => typeof x === 'string' && x.length > 0); - for (const base of bases) { - result.push(path.win32.join(base, ...parts)); - } - return result; -} - -function firstExisting(paths: string[]): string | null { - for (const p of paths) { - try { - if (existsSync(p)) return p; - } catch { - // ignore invalid path inputs + 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 isRunningUnix(processNames: string[]): boolean { - for (const processName of processNames) { - try { - execFileSync('pgrep', ['-x', processName], { encoding: 'utf-8', stdio: 'pipe' }); - return true; - } catch { - // try next process name - } +function isBrowserRunning(processNames: string[]): boolean { + if (process.platform === 'darwin' || process.platform === 'linux') { + return processNames.some(name => detectProcess(name)); } - return false; -} - -function isRunningWindows(imageNames: string[]): boolean { - for (const imageName of imageNames) { - try { - const out = execFileSync('tasklist', ['/FI', `IMAGENAME eq ${imageName}`], { encoding: 'utf-8', stdio: 'pipe' }); - if (String(out).includes(imageName)) return true; - } catch { - // try next image 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 isBrowserRunning(processNames: string[]): boolean { - if (process.platform === 'darwin' || process.platform === 'linux') { - return isRunningUnix(processNames); +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 isRunningWindows(processNames); + return winFirstExisting(def.winExeParts); } - return false; + return null; } export function getBrowserCandidates(): BrowserCandidate[] { const installed: BrowserCandidate[] = []; - const defs: Array<{ - id: BrowserCandidate['id']; - name: string; - macAppName: string; - linuxBins: string[]; - winExeParts: string[][]; - processNames: string[]; - }> = [ - { - 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', 'chrome.exe'], - }, - ]; - - for (const def of defs) { - let executable: string | null = null; - - if (process.platform === 'darwin') { - executable = tryDiscoverMacApp(def.macAppName); - } else if (process.platform === 'linux') { - for (const bin of def.linuxBins) { - executable = tryWhich(bin); - if (executable) break; - } - } else if (process.platform === 'win32') { - const candidates: string[] = []; - for (const parts of def.winExeParts) { - candidates.push(...winPathsFromEnv(parts)); - } - executable = firstExisting(candidates); - } - + for (const def of BROWSER_DEFS) { + const executable = findExecutable(def); if (executable) { installed.push({ id: def.id, name: def.name, - executable: trimPath(executable), + executable, running: isBrowserRunning(def.processNames), }); } } + // Running browsers first, preserving brand order within each group return [ - ...installed.filter(candidate => candidate.running), - ...installed.filter(candidate => !candidate.running), + ...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, env: { ...process.env } }; - if (process.platform === 'darwin') { - const child = spawn('open', [candidate.executable], opts); - child.unref(); - return; - } + const cmd = process.platform === 'darwin' + ? { bin: 'open', args: [candidate.executable] } + : { bin: candidate.executable, args: [] as string[] }; - const child = spawn(candidate.executable, [], opts); + const child = spawn(cmd.bin, cmd.args, opts); + child.on('error', () => {}); // Swallow spawn errors (e.g. ENOENT) child.unref(); } From 0f602a230ca58d768bd8f4486e03f57e5ad63256 Mon Sep 17 00:00:00 2001 From: jackwener Date: Sat, 4 Apr 2026 02:46:28 +0800 Subject: [PATCH 6/6] fix(browser): fair time budget, remove mutable state, fix processNames conflict - Restore per-browser time budget to fairly distribute timeout across candidates - Replace _lastDetectedBrowsers/_lastTriedBrowsers with LaunchResult return value - Remove 'chrome.exe' from Chromium processNames to avoid Windows conflict with Chrome - Remove redundant env spread in spawn options --- src/browser/bridge.ts | 43 ++++++++++++++++++++++++--------------- src/browser/candidates.ts | 4 ++-- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/src/browser/bridge.ts b/src/browser/bridge.ts index 6d21a400..eb81d454 100644 --- a/src/browser/bridge.ts +++ b/src/browser/bridge.ts @@ -22,12 +22,16 @@ export type BrowserBridgeState = 'idle' | 'connecting' | 'connected' | 'closing' /** * 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 _lastDetectedBrowsers: string[] = []; - private _lastTriedBrowsers: string[] = []; private _inferredBrowserName: string | null = null; get state(): BrowserBridgeState { @@ -82,8 +86,9 @@ export class BrowserBridge implements IBrowserFactory { if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) { process.stderr.write('⏳ Waiting for Browser Bridge extension to connect...\n'); } - if (await this._tryLaunchBrowsers(timeoutMs)) return; - throw new Error(this._buildExtensionError(this._lastDetectedBrowsers, this._lastTriedBrowsers)); + const result = await this._tryLaunchBrowsers(timeoutMs); + if (result.connected) return; + throw new Error(this._buildExtensionError(result.detected, result.tried)); } // No daemon — spawn one @@ -110,10 +115,11 @@ export class BrowserBridge implements IBrowserFactory { this._daemonProc.unref(); // Wait for daemon + extension with faster polling - if (await this._tryLaunchBrowsers(timeoutMs)) return; + const result = await this._tryLaunchBrowsers(timeoutMs); + if (result.connected) return; if ((await fetchDaemonStatus()) !== null) { - throw new Error(this._buildExtensionError(this._lastDetectedBrowsers, this._lastTriedBrowsers)); + throw new Error(this._buildExtensionError(result.detected, result.tried)); } throw new Error( @@ -123,19 +129,22 @@ export class BrowserBridge implements IBrowserFactory { ); } - private async _tryLaunchBrowsers(timeoutMs: number): Promise { + private async _tryLaunchBrowsers(timeoutMs: number): Promise { const candidates = getBrowserCandidates(); - this._lastDetectedBrowsers = candidates.map(c => c.name); - this._lastTriedBrowsers = []; + const detected = candidates.map(c => c.name); + const tried: string[] = []; - if (await isExtensionConnected()) return true; + 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; - this._lastTriedBrowsers.push(candidate.name); + tried.push(candidate.name); if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) { process.stderr.write(` Trying browser: ${candidate.name}\n`); } @@ -144,21 +153,23 @@ export class BrowserBridge implements IBrowserFactory { await launchBrowserCandidate(candidate); if (await isExtensionConnected()) { this._inferredBrowserName = candidate.name; - return true; + return { connected: true, detected, tried }; } } - const waitMs = Math.min(MAX_PER_BROWSER_WAIT_MS, Math.max(0, deadline - Date.now())); + const waitMs = Math.min(perBrowserWaitMs, Math.max(0, deadline - Date.now())); if (waitMs > 0 && await this._waitForExtensionConnection(waitMs)) { this._inferredBrowserName = candidate.name; - return true; + 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 true; - return false; + if (remaining > 0 && await this._waitForExtensionConnection(remaining)) { + return { connected: true, detected, tried }; + } + return { connected: false, detected, tried }; } private async _waitForExtensionConnection(timeoutMs: number): Promise { diff --git a/src/browser/candidates.ts b/src/browser/candidates.ts index b0f7e261..2215db42 100644 --- a/src/browser/candidates.ts +++ b/src/browser/candidates.ts @@ -50,7 +50,7 @@ const BROWSER_DEFS: BrowserDef[] = [ macAppName: 'Chromium', linuxBins: ['chromium', 'chromium-browser'], winExeParts: [['Chromium', 'Application', 'chrome.exe']], - processNames: ['Chromium', 'chromium', 'chromium-browser', 'chrome.exe'], + processNames: ['Chromium', 'chromium', 'chromium-browser'], }, ]; @@ -135,7 +135,7 @@ export function getBrowserCandidates(): BrowserCandidate[] { } export async function launchBrowserCandidate(candidate: BrowserCandidate): Promise { - const opts = { detached: true as const, stdio: 'ignore' as const, env: { ...process.env } }; + const opts = { detached: true as const, stdio: 'ignore' as const }; const cmd = process.platform === 'darwin' ? { bin: 'open', args: [candidate.executable] }