|
4 | 4 |
|
5 | 5 | import { Builder, Browser, Capabilities, WebDriver } from 'selenium-webdriver'; |
6 | 6 | import firefox from 'selenium-webdriver/firefox.js'; |
7 | | -import { mkdirSync, openSync, closeSync } from 'node:fs'; |
| 7 | +import { mkdirSync, openSync, closeSync, existsSync, readdirSync, statSync } from 'node:fs'; |
8 | 8 | import { homedir } from 'node:os'; |
9 | | -import { join } from 'node:path'; |
| 9 | +import { join, delimiter } from 'node:path'; |
10 | 10 | import type { FirefoxLaunchOptions } from './types.js'; |
11 | 11 | import { log, logDebug } from '../utils/logger.js'; |
12 | 12 | import { resolveProfilePath } from './profile.js'; |
13 | 13 |
|
14 | 14 | // --------------------------------------------------------------------------- |
15 | | -// Geckodriver binary finder — used only for --connect-existing mode |
| 15 | +// Geckodriver binary finder |
16 | 16 | // --------------------------------------------------------------------------- |
17 | 17 |
|
18 | | -/** |
19 | | - * Finds the geckodriver binary path via selenium-manager. |
20 | | - * Uses --driver (not --browser) to avoid downloading Firefox, which is |
21 | | - * already running in connect-existing mode. |
22 | | - */ |
23 | | -async function findGeckodriver(): Promise<string> { |
24 | | - const path = await import('node:path'); |
25 | | - const { execFileSync } = await import('node:child_process'); |
| 18 | +function findGeckodriverInPath(binaryName: string): string | null { |
| 19 | + for (const dir of (process.env.PATH ?? '').split(delimiter)) { |
| 20 | + if (!dir) { |
| 21 | + continue; |
| 22 | + } |
| 23 | + const candidate = join(dir, binaryName); |
| 24 | + if (existsSync(candidate)) { |
| 25 | + return candidate; |
| 26 | + } |
| 27 | + } |
| 28 | + return null; |
| 29 | +} |
26 | 30 |
|
| 31 | +function findGeckodriverInSeleniumCache(binaryName: string): string | null { |
| 32 | + const cacheBase = join(homedir(), '.cache/selenium/geckodriver'); |
27 | 33 | try { |
28 | | - const { createRequire } = await import('node:module'); |
29 | | - const require = createRequire(import.meta.url); |
30 | | - const swPkg = require.resolve('selenium-webdriver/package.json'); |
31 | | - const swDir = path.dirname(swPkg); |
32 | | - const platform = |
33 | | - process.platform === 'win32' ? 'windows' : process.platform === 'darwin' ? 'macos' : 'linux'; |
34 | | - const ext = process.platform === 'win32' ? '.exe' : ''; |
35 | | - const smBin = path.join(swDir, 'bin', platform, `selenium-manager${ext}`); |
36 | | - const result = JSON.parse( |
37 | | - execFileSync(smBin, ['--driver', 'geckodriver', '--output', 'json'], { encoding: 'utf-8' }) |
38 | | - ); |
39 | | - return result.result.driver_path as string; |
40 | | - } catch { |
41 | | - // Fallback: walk the selenium cache directory to find any geckodriver binary |
42 | | - const os = await import('node:os'); |
43 | | - const fs = await import('node:fs'); |
44 | | - const cacheBase = path.join(os.homedir(), '.cache/selenium/geckodriver'); |
45 | | - const ext = process.platform === 'win32' ? '.exe' : ''; |
46 | | - const binaryName = `geckodriver${ext}`; |
47 | | - try { |
48 | | - if (fs.existsSync(cacheBase)) { |
49 | | - for (const platformDir of fs.readdirSync(cacheBase)) { |
50 | | - const platformPath = path.join(cacheBase, platformDir); |
51 | | - if (!fs.statSync(platformPath).isDirectory()) { |
52 | | - continue; |
53 | | - } |
54 | | - for (const versionDir of fs.readdirSync(platformPath).sort().reverse()) { |
55 | | - const candidate = path.join(platformPath, versionDir, binaryName); |
56 | | - if (fs.existsSync(candidate)) { |
57 | | - return candidate; |
58 | | - } |
59 | | - } |
| 34 | + if (!existsSync(cacheBase)) { |
| 35 | + return null; |
| 36 | + } |
| 37 | + for (const platformDir of readdirSync(cacheBase)) { |
| 38 | + const platformPath = join(cacheBase, platformDir); |
| 39 | + if (!statSync(platformPath).isDirectory()) { |
| 40 | + continue; |
| 41 | + } |
| 42 | + for (const versionDir of readdirSync(platformPath).sort().reverse()) { |
| 43 | + const candidate = join(platformPath, versionDir, binaryName); |
| 44 | + if (existsSync(candidate)) { |
| 45 | + return candidate; |
60 | 46 | } |
61 | 47 | } |
62 | | - } catch { |
63 | | - // ignore permission errors |
64 | 48 | } |
65 | | - throw new Error('Cannot find geckodriver binary. Ensure selenium-webdriver is installed.'); |
| 49 | + } catch { |
| 50 | + // ignore permission errors |
66 | 51 | } |
| 52 | + return null; |
| 53 | +} |
| 54 | + |
| 55 | +async function findGeckodriverInNpmPackage(): Promise<string | null> { |
| 56 | + try { |
| 57 | + const { download } = await import('geckodriver'); |
| 58 | + log('geckodriver not found in PATH or selenium cache, downloading via npm package...'); |
| 59 | + return await download(); |
| 60 | + } catch { |
| 61 | + return null; |
| 62 | + } |
| 63 | +} |
| 64 | + |
| 65 | +/** |
| 66 | + * Finds the geckodriver binary using three strategies in order: |
| 67 | + * 1. Search PATH directly (fast, no subprocess) |
| 68 | + * 2. Walk the selenium cache directory |
| 69 | + * 3. Download via the bundled geckodriver npm package |
| 70 | + * Throws if none succeeds. |
| 71 | + */ |
| 72 | +async function findGeckodriver(): Promise<string> { |
| 73 | + const ext = process.platform === 'win32' ? '.exe' : ''; |
| 74 | + const binaryName = `geckodriver${ext}`; |
| 75 | + |
| 76 | + const found = |
| 77 | + findGeckodriverInPath(binaryName) ?? |
| 78 | + findGeckodriverInSeleniumCache(binaryName) ?? |
| 79 | + (await findGeckodriverInNpmPackage()); |
| 80 | + |
| 81 | + if (!found) { |
| 82 | + throw new Error('Cannot find geckodriver binary. Ensure geckodriver is in PATH.'); |
| 83 | + } |
| 84 | + return found; |
67 | 85 | } |
68 | 86 |
|
69 | 87 | export class FirefoxCore { |
@@ -119,7 +137,6 @@ export class FirefoxCore { |
119 | 137 | } else if (this.options.connectExisting) { |
120 | 138 | const port = this.options.marionettePort ?? 2828; |
121 | 139 |
|
122 | | - // Find geckodriver binary (--driver avoids downloading Firefox via selenium-manager) |
123 | 140 | const geckodriverPath = await findGeckodriver(); |
124 | 141 | logDebug(`Using geckodriver: ${geckodriverPath}`); |
125 | 142 |
|
@@ -206,19 +223,23 @@ export class FirefoxCore { |
206 | 223 | } |
207 | 224 | } |
208 | 225 |
|
209 | | - // Configure geckodriver service to capture output |
210 | | - const serviceBuilder = new firefox.ServiceBuilder(); |
| 226 | + let serviceBuilder; |
| 227 | + if (process.platform === 'win32') { |
| 228 | + // On windows, firefox.ServiceBuilder() invoked from the MCP will hang. |
| 229 | + // geckodriver has to be in the PATH. See Bug 2040849. |
| 230 | + const geckodriverPath = await findGeckodriver(); |
| 231 | + logDebug(`Using geckodriver: ${geckodriverPath}`); |
| 232 | + serviceBuilder = new firefox.ServiceBuilder(geckodriverPath); |
| 233 | + } else { |
| 234 | + // On other platforms, the default ServiceBuilder should locate and |
| 235 | + // start geckodriver successfully. |
| 236 | + serviceBuilder = new firefox.ServiceBuilder(); |
| 237 | + } |
211 | 238 |
|
212 | | - // If we have a log file, open it and redirect geckodriver output there |
213 | | - // This captures both geckodriver logs and Firefox stderr (including MOZ_LOG) |
214 | 239 | if (this.logFilePath) { |
215 | 240 | // Open file for appending, create if doesn't exist |
216 | 241 | this.logFileFd = openSync(this.logFilePath, 'a'); |
217 | | - |
218 | | - // Configure stdio: stdin=ignore, stdout=logfile, stderr=logfile |
219 | | - // This redirects all output from geckodriver and Firefox to the log file |
220 | 242 | serviceBuilder.setStdio(['ignore', this.logFileFd, this.logFileFd]); |
221 | | - |
222 | 243 | log(`Capturing Firefox output to: ${this.logFilePath}`); |
223 | 244 | } |
224 | 245 |
|
|
0 commit comments