craftdriver 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +1 -0
  3. package/dist/cli/dispatcher.js +1 -1
  4. package/dist/cli/dispatcher.js.map +1 -1
  5. package/dist/index.d.ts +6 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +4 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/lib/browser.d.ts +147 -21
  10. package/dist/lib/browser.d.ts.map +1 -1
  11. package/dist/lib/browser.js +380 -139
  12. package/dist/lib/browser.js.map +1 -1
  13. package/dist/lib/capabilities.d.ts +35 -0
  14. package/dist/lib/capabilities.d.ts.map +1 -0
  15. package/dist/lib/capabilities.js +58 -0
  16. package/dist/lib/capabilities.js.map +1 -0
  17. package/dist/lib/chrome.d.ts +7 -2
  18. package/dist/lib/chrome.d.ts.map +1 -1
  19. package/dist/lib/chrome.js +10 -1
  20. package/dist/lib/chrome.js.map +1 -1
  21. package/dist/lib/driverManager.d.ts +92 -0
  22. package/dist/lib/driverManager.d.ts.map +1 -1
  23. package/dist/lib/driverManager.js +229 -10
  24. package/dist/lib/driverManager.js.map +1 -1
  25. package/dist/lib/electron.d.ts +60 -0
  26. package/dist/lib/electron.d.ts.map +1 -0
  27. package/dist/lib/electron.js +85 -0
  28. package/dist/lib/electron.js.map +1 -0
  29. package/dist/lib/electronDeeplink.d.ts +22 -0
  30. package/dist/lib/electronDeeplink.d.ts.map +1 -0
  31. package/dist/lib/electronDeeplink.js +118 -0
  32. package/dist/lib/electronDeeplink.js.map +1 -0
  33. package/dist/lib/electronDiagnostics.d.ts +49 -0
  34. package/dist/lib/electronDiagnostics.d.ts.map +1 -0
  35. package/dist/lib/electronDiagnostics.js +195 -0
  36. package/dist/lib/electronDiagnostics.js.map +1 -0
  37. package/dist/lib/electronDialogMock.d.ts +51 -0
  38. package/dist/lib/electronDialogMock.d.ts.map +1 -0
  39. package/dist/lib/electronDialogMock.js +106 -0
  40. package/dist/lib/electronDialogMock.js.map +1 -0
  41. package/dist/lib/electronFuses.d.ts +13 -0
  42. package/dist/lib/electronFuses.d.ts.map +1 -0
  43. package/dist/lib/electronFuses.js +118 -0
  44. package/dist/lib/electronFuses.js.map +1 -0
  45. package/dist/lib/electronMainBridge.d.ts +28 -0
  46. package/dist/lib/electronMainBridge.d.ts.map +1 -0
  47. package/dist/lib/electronMainBridge.js +270 -0
  48. package/dist/lib/electronMainBridge.js.map +1 -0
  49. package/dist/lib/electronMainLogs.d.ts +81 -0
  50. package/dist/lib/electronMainLogs.d.ts.map +1 -0
  51. package/dist/lib/electronMainLogs.js +176 -0
  52. package/dist/lib/electronMainLogs.js.map +1 -0
  53. package/dist/lib/electronMock.d.ts +52 -0
  54. package/dist/lib/electronMock.d.ts.map +1 -0
  55. package/dist/lib/electronMock.js +96 -0
  56. package/dist/lib/electronMock.js.map +1 -0
  57. package/dist/lib/electronRemote.d.ts +106 -0
  58. package/dist/lib/electronRemote.d.ts.map +1 -0
  59. package/dist/lib/electronRemote.js +406 -0
  60. package/dist/lib/electronRemote.js.map +1 -0
  61. package/dist/lib/electronVersions.d.ts +34 -0
  62. package/dist/lib/electronVersions.d.ts.map +1 -0
  63. package/dist/lib/electronVersions.js +90 -0
  64. package/dist/lib/electronVersions.js.map +1 -0
  65. package/dist/lib/errors.d.ts +27 -0
  66. package/dist/lib/errors.d.ts.map +1 -1
  67. package/dist/lib/errors.js +27 -0
  68. package/dist/lib/errors.js.map +1 -1
  69. package/dist/lib/launchTarget.d.ts +30 -0
  70. package/dist/lib/launchTarget.d.ts.map +1 -0
  71. package/dist/lib/launchTarget.js +112 -0
  72. package/dist/lib/launchTarget.js.map +1 -0
  73. package/dist/lib/page.d.ts +1 -0
  74. package/dist/lib/page.d.ts.map +1 -1
  75. package/dist/lib/page.js +1 -1
  76. package/dist/lib/page.js.map +1 -1
  77. package/dist/lib/service.d.ts +21 -0
  78. package/dist/lib/service.d.ts.map +1 -1
  79. package/dist/lib/service.js +94 -6
  80. package/dist/lib/service.js.map +1 -1
  81. package/docs/api-reference.md +37 -2
  82. package/docs/browser-api.md +7 -1
  83. package/docs/electron.md +419 -0
  84. package/docs/error-codes.md +5 -0
  85. package/docs/index.md +4 -0
  86. package/docs/recipes/electron-app-from-another-repo.md +136 -0
  87. package/docs/recipes/electron-deep-link.md +117 -0
  88. package/docs/recipes/electron-mock-apis.md +88 -0
  89. package/docs/recipes/electron-native-dialog.md +105 -0
  90. package/docs/recipes.md +28 -9
  91. package/package.json +5 -1
@@ -1,7 +1,13 @@
1
1
  import { Builder } from './builder.js';
2
2
  import { ChromeService } from './chrome.js';
3
+ import { ElectronService, writeElectronDebug } from './electron.js';
4
+ import { diagnoseElectronLaunchFailure } from './electronDiagnostics.js';
5
+ import { ElectronRemote } from './electronRemote.js';
6
+ import { findFreePort } from './service.js';
3
7
  import { FirefoxService } from './firefox.js';
4
8
  import { resolveBrowserBinaryPath } from './driverManager.js';
9
+ import { buildLaunchCapabilities } from './capabilities.js';
10
+ import { resolveLaunchTarget } from './launchTarget.js';
5
11
  import { By } from './by.js';
6
12
  import { until } from './wait.js';
7
13
  import { DEFAULT_ELEMENT_TIMEOUT_MS, DEFAULT_NAVIGATION_TIMEOUT_MS, STATE_POLL_INTERVAL_MS, NETWORK_IDLE_SETTLE_MS, PORT_RELEASE_DELAY_MS, BIDI_CONNECT_MAX_ATTEMPTS, BIDI_CONNECT_BACKOFF_STEP_MS, EVAL_REALM_RETRY_ATTEMPTS, EVAL_REALM_RETRY_DELAY_MS, } from './timing.js';
@@ -18,7 +24,7 @@ import { Mouse } from './mouse.js';
18
24
  import { ActionsBuilder } from './actions.js';
19
25
  import { BiDiSession, SessionStateManager, } from './bidi/index.js';
20
26
  import { Frame } from './frame.js';
21
- import { Page } from './page.js';
27
+ import { Page, isNoSuchWindowError } from './page.js';
22
28
  import { bidiWaitFor } from './loadState.js';
23
29
  import { BrowserContext } from './browserContext.js';
24
30
  import { Tracer } from './tracing.js';
@@ -63,6 +69,75 @@ export const devices = {
63
69
  userAgent: 'Mozilla/5.0 (iPad; CPU OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1',
64
70
  },
65
71
  };
72
+ /**
73
+ * Resolve a `goog:chromeOptions.mobileEmulation` payload from the public
74
+ * `MobileEmulation | DeviceName` option — a built-in device name maps to
75
+ * Chrome's `deviceName`, otherwise a custom `deviceMetrics`/`userAgent` config
76
+ * is built. Kept next to `devices`; `buildLaunchCapabilities` takes the result.
77
+ */
78
+ function resolveMobileEmulationConfig(mobileEmulation) {
79
+ const emulation = typeof mobileEmulation === 'string' ? devices[mobileEmulation] : mobileEmulation;
80
+ if ('deviceName' in emulation && emulation.deviceName) {
81
+ // Use Chrome's built-in device name
82
+ return { deviceName: emulation.deviceName };
83
+ }
84
+ // Build custom mobile emulation config
85
+ const mobileConfig = {};
86
+ if (emulation.deviceMetrics) {
87
+ mobileConfig.deviceMetrics = {
88
+ width: emulation.deviceMetrics.width,
89
+ height: emulation.deviceMetrics.height,
90
+ pixelRatio: emulation.deviceMetrics.pixelRatio,
91
+ mobile: emulation.deviceMetrics.mobile ?? true,
92
+ touch: emulation.deviceMetrics.touch ?? true,
93
+ };
94
+ }
95
+ if (emulation.userAgent) {
96
+ mobileConfig.userAgent = emulation.userAgent;
97
+ }
98
+ return mobileConfig;
99
+ }
100
+ async function resolveElectronAppBinaryPath(appBinaryPath) {
101
+ const resolved = path.resolve(appBinaryPath);
102
+ let stat;
103
+ try {
104
+ stat = await fs.stat(resolved);
105
+ }
106
+ catch (error) {
107
+ const code = error.code;
108
+ if (code === 'ENOENT') {
109
+ throw new Error(`electron.appBinaryPath "${appBinaryPath}" does not exist.`);
110
+ }
111
+ if (code === 'EACCES') {
112
+ throw new Error(`electron.appBinaryPath "${appBinaryPath}" is not accessible.`);
113
+ }
114
+ throw error;
115
+ }
116
+ if (!stat.isFile()) {
117
+ throw new Error(`electron.appBinaryPath "${appBinaryPath}" is not a file.`);
118
+ }
119
+ if (process.platform !== 'win32') {
120
+ try {
121
+ await fs.access(resolved, fsSync.constants.X_OK);
122
+ }
123
+ catch {
124
+ throw new Error(`electron.appBinaryPath "${appBinaryPath}" is not executable.`);
125
+ }
126
+ }
127
+ return resolved;
128
+ }
129
+ /**
130
+ * Pure predicate behind {@link Browser.waitForPage}'s matcher form: do a page's
131
+ * `url`/`title` satisfy the matcher? Only the fields named in `matcher` are checked,
132
+ * and every named field must match (string = substring, RegExp = test). Exported for
133
+ * unit testing.
134
+ */
135
+ export function matchPageFields(fields, matcher) {
136
+ const hit = (value, pattern) => pattern === undefined ||
137
+ (value !== undefined &&
138
+ (typeof pattern === 'string' ? value.includes(pattern) : pattern.test(value)));
139
+ return hit(fields.url, matcher.url) && hit(fields.title, matcher.title);
140
+ }
66
141
  // ---------------------------------------------------------------------------
67
142
  // BiDi value helpers — used only by Browser.evaluate()
68
143
  // ---------------------------------------------------------------------------
@@ -93,7 +168,10 @@ function serializeLocalValue(v) {
93
168
  if (typeof v === 'object') {
94
169
  return {
95
170
  type: 'object',
96
- value: Object.entries(v).map(([k, val]) => [k, serializeLocalValue(val)]),
171
+ value: Object.entries(v).map(([k, val]) => [
172
+ k,
173
+ serializeLocalValue(val),
174
+ ]),
97
175
  };
98
176
  }
99
177
  throw new CraftdriverError(ErrorCode.EVAL_BAD_ARG, `evaluate() argument of type "${typeof v}" is not JSON-serializable. ` +
@@ -101,11 +179,16 @@ function serializeLocalValue(v) {
101
179
  }
102
180
  function unwrapRemoteValue(v) {
103
181
  switch (v.type) {
104
- case 'undefined': return undefined;
105
- case 'null': return null;
106
- case 'string': return v.value;
107
- case 'boolean': return v.value;
108
- case 'bigint': return BigInt(v.value);
182
+ case 'undefined':
183
+ return undefined;
184
+ case 'null':
185
+ return null;
186
+ case 'string':
187
+ return v.value;
188
+ case 'boolean':
189
+ return v.value;
190
+ case 'bigint':
191
+ return BigInt(v.value);
109
192
  case 'number': {
110
193
  if (v.value === 'NaN')
111
194
  return NaN;
@@ -117,13 +200,15 @@ function unwrapRemoteValue(v) {
117
200
  return -0;
118
201
  return v.value;
119
202
  }
120
- case 'array': return (v.value ?? []).map(unwrapRemoteValue);
203
+ case 'array':
204
+ return (v.value ?? []).map(unwrapRemoteValue);
121
205
  case 'object':
122
206
  return Object.fromEntries((v.value ?? []).map(([k, val]) => [
123
207
  typeof k === 'string' ? k : String(unwrapRemoteValue(k)),
124
208
  unwrapRemoteValue(val),
125
209
  ]));
126
- case 'date': return new Date(v.value);
210
+ case 'date':
211
+ return new Date(v.value);
127
212
  case 'function':
128
213
  throw new CraftdriverError(ErrorCode.EVAL_BAD_ARG, 'evaluate() returned a function, which is not JSON-serializable. ' +
129
214
  'Return a primitive, array, or plain object instead.', { detail: { returnedType: 'function' } });
@@ -190,11 +275,19 @@ export class Browser {
190
275
  _browserInitScriptIds = new Set();
191
276
  _downloadsDir;
192
277
  _driverService;
278
+ /** Main-process inspector endpoint, set when launched with `electron.mainProcess`. */
279
+ _electronInspect;
280
+ /** The launched Electron app's executable path (for deep-link routing on Windows). */
281
+ _electronAppBinaryPath;
282
+ _electron;
193
283
  _browserName = 'chrome';
194
284
  /** Active emulation overrides, re-applied to new top-level contexts. */
195
285
  _emulation = {};
196
286
  /** Mutable browser-level defaults. Use setDefaultTimeout() / setDefaultNavigationTimeout() to change. */
197
- defaults = { timeout: DEFAULT_ELEMENT_TIMEOUT_MS, navigationTimeout: DEFAULT_NAVIGATION_TIMEOUT_MS };
287
+ defaults = {
288
+ timeout: DEFAULT_ELEMENT_TIMEOUT_MS,
289
+ navigationTimeout: DEFAULT_NAVIGATION_TIMEOUT_MS,
290
+ };
198
291
  constructor(driver) {
199
292
  this.driver = driver;
200
293
  this.keyboard = new Keyboard(this.driver);
@@ -219,102 +312,91 @@ export class Browser {
219
312
  hasBrowserInitScripts = () => this._browserInitScriptIds.size > 0;
220
313
  hasDefaultNavigationInitScripts = () => this._defaultContext?._hasInitScriptsForNavigation() ?? this.hasBrowserInitScripts();
221
314
  static async launch(options = {}) {
222
- const name = options.browserName ?? 'chrome';
223
- const isFirefox = name === 'firefox';
315
+ // Normalize first: callers may be JavaScript or JSON-driven, so reject
316
+ // malformed/mixed targets before touching disk or starting a driver.
317
+ const target = resolveLaunchTarget(options);
318
+ const isElectron = target.kind === 'electron';
319
+ const name = target.browserName;
224
320
  const isChromeFamily = name === 'chrome' || name === 'chromium';
225
- if (options.mobileEmulation && !isChromeFamily) {
321
+ if (target.kind === 'browser' && options.mobileEmulation && !isChromeFamily) {
226
322
  throw new Error(`mobileEmulation is only supported on Chrome/Chromium (got "${name}"). ` +
227
323
  `Firefox does not expose a device-emulation API equivalent to goog:chromeOptions.mobileEmulation.`);
228
324
  }
229
- // Handle headless mode via env var
325
+ const bidiRequested = target.bidiRequested;
326
+ // Handle headless mode via env var (never applied to Electron — see capabilities.ts).
230
327
  const headlessEnv = process.env.HEADLESS;
231
328
  const isHeadless = headlessEnv === 'true' || headlessEnv === '1';
329
+ // Resolve Electron before creating session directories. A malformed or
330
+ // missing executable must never degrade into a regular Chrome launch.
331
+ const electronBinary = target.kind === 'electron'
332
+ ? await resolveElectronAppBinaryPath(target.appBinaryPath)
333
+ : undefined;
334
+ if (options.electronService !== undefined &&
335
+ !(options.electronService instanceof ElectronService)) {
336
+ throw new Error('electronService must be an ElectronService instance.');
337
+ }
338
+ if (target.kind === 'electron') {
339
+ writeElectronDebug({
340
+ appPath: electronBinary,
341
+ protocol: bidiRequested ? 'bidi' : 'classic',
342
+ platform: `${os.platform()}-${os.arch()}`,
343
+ });
344
+ }
232
345
  // Set up downloads directory
233
- const downloadsDir = options.downloadsDir ?? path.join(os.tmpdir(), 'craftdriver-downloads', `session-${Date.now()}-${Math.random().toString(36).slice(2)}`);
346
+ const downloadsDir = options.downloadsDir ??
347
+ path.join(os.tmpdir(), 'craftdriver-downloads', `session-${Date.now()}-${Math.random().toString(36).slice(2)}`);
234
348
  await fs.mkdir(downloadsDir, { recursive: true });
235
- // Custom browser binary (Chrome/Chromium/Firefox). When unresolved,
236
- // nothing is forwarded and the driver uses its own built-in browser
237
- // discovery but see resolveBrowserBinaryPath's doc: some of the env
238
- // vars in this chain aren't craftdriver-opt-in, they're ambient
239
- // conventions other tools may already have set.
240
- const browserPath = resolveBrowserBinaryPath(isChromeFamily ? 'chrome' : 'firefox', options.browserPath);
241
- let caps = {};
242
- if (isChromeFamily) {
243
- const chromeOptions = {
244
- args: [...(isHeadless ? ['--headless=new'] : []), ...(options.args ?? [])],
245
- prefs: {
246
- 'download.default_directory': downloadsDir,
247
- 'download.prompt_for_download': false,
248
- 'safebrowsing.enabled': true,
249
- },
250
- };
251
- if (browserPath)
252
- chromeOptions.binary = browserPath;
253
- // Add mobile emulation if specified
254
- if (options.mobileEmulation) {
255
- const emulation = typeof options.mobileEmulation === 'string'
256
- ? devices[options.mobileEmulation]
257
- : options.mobileEmulation;
258
- if ('deviceName' in emulation && emulation.deviceName) {
259
- // Use Chrome's built-in device name
260
- chromeOptions.mobileEmulation = { deviceName: emulation.deviceName };
261
- }
262
- else {
263
- // Build custom mobile emulation config
264
- const mobileConfig = {};
265
- if (emulation.deviceMetrics) {
266
- mobileConfig.deviceMetrics = {
267
- width: emulation.deviceMetrics.width,
268
- height: emulation.deviceMetrics.height,
269
- pixelRatio: emulation.deviceMetrics.pixelRatio,
270
- mobile: emulation.deviceMetrics.mobile ?? true,
271
- touch: emulation.deviceMetrics.touch ?? true,
272
- };
273
- }
274
- if (emulation.userAgent) {
275
- mobileConfig.userAgent = emulation.userAgent;
276
- }
277
- chromeOptions.mobileEmulation = mobileConfig;
278
- }
279
- }
280
- caps['goog:chromeOptions'] = chromeOptions;
281
- }
282
- else if (isFirefox) {
283
- const firefoxArgs = [];
284
- if (isHeadless)
285
- firefoxArgs.push('-headless');
286
- if (options.args?.length)
287
- firefoxArgs.push(...options.args);
288
- caps['moz:firefoxOptions'] = {
289
- args: firefoxArgs,
290
- prefs: {
291
- 'browser.download.folderList': 2,
292
- 'browser.download.dir': downloadsDir,
293
- 'browser.download.useDownloadDir': true,
294
- 'browser.helperApps.neverAsk.saveToDisk': 'application/octet-stream,application/pdf,text/plain,text/csv,application/zip',
295
- 'pdfjs.disabled': true,
296
- },
297
- ...(browserPath ? { binary: browserPath } : {}),
298
- };
299
- }
300
- if (options.enableBiDi !== false) {
301
- // Request BiDi WebSocket URL
302
- caps.webSocketUrl = true;
303
- // Set all prompt types to 'ignore' so BiDi events fire and we can handle them
304
- caps.unhandledPromptBehavior = {
305
- alert: 'ignore',
306
- confirm: 'ignore',
307
- prompt: 'ignore',
308
- beforeUnload: 'ignore',
309
- };
310
- }
349
+ // The binary that becomes goog:chromeOptions.binary / moz:firefoxOptions.binary.
350
+ // For Electron it's the app executable (explicit, required). For browsers
351
+ // it's the optional custom-binary resolution chain but see
352
+ // resolveBrowserBinaryPath's doc: some of the env vars in that chain aren't
353
+ // craftdriver-opt-in, they're ambient conventions other tools may have set.
354
+ const browserBinary = target.kind === 'electron'
355
+ ? electronBinary
356
+ : resolveBrowserBinaryPath(isChromeFamily ? 'chrome' : 'firefox', target.browserPath);
357
+ // Main-process access (opt-in): launch the Electron app with a Node inspector
358
+ // on a free local port. chromedriver forwards this arg to the app, enabling
359
+ // its main-process inspector; browser.electron connects below for log capture
360
+ // and retries on first executeMain() if that eager connection was too early.
361
+ let electronInspect;
362
+ let launchArgs = target.args;
363
+ if (target.kind === 'electron' && target.mainProcess) {
364
+ const host = '127.0.0.1';
365
+ const port = await findFreePort();
366
+ electronInspect = { host, port };
367
+ launchArgs = [...(target.args ?? []), `--inspect=${host}:${port}`];
368
+ }
369
+ const caps = buildLaunchCapabilities({
370
+ browserName: name,
371
+ isElectron,
372
+ isHeadless,
373
+ bidiRequested,
374
+ browserBinary,
375
+ downloadsDir,
376
+ args: launchArgs,
377
+ mobileEmulation: target.kind === 'browser' && isChromeFamily && options.mobileEmulation
378
+ ? resolveMobileEmulationConfig(options.mobileEmulation)
379
+ : undefined,
380
+ });
311
381
  const builder = new Builder().forBrowser(name);
312
382
  let driverService;
313
- if (isChromeFamily) {
383
+ if (target.kind === 'electron') {
384
+ driverService =
385
+ options.electronService ??
386
+ new ElectronService({
387
+ chromedriverPath: target.chromedriverPath,
388
+ version: target.version,
389
+ // Lets the driver resolver read the Electron version from a packaged app's
390
+ // bundle when no explicit version/driver is given (production apps).
391
+ appBinaryPath: electronBinary,
392
+ });
393
+ builder.setChromeService(driverService);
394
+ }
395
+ else if (isChromeFamily) {
314
396
  // Only thread browserPath into a default ChromeService — a
315
397
  // caller-supplied one is expected to already pin what it needs (same
316
398
  // reasoning as explicit config always winning over auto-detection).
317
- driverService = options.chromeService ?? new ChromeService({ browserPath });
399
+ driverService = options.chromeService ?? new ChromeService({ browserPath: browserBinary });
318
400
  builder.setChromeService(driverService);
319
401
  }
320
402
  else {
@@ -322,14 +404,50 @@ export class Browser {
322
404
  builder.setFirefoxService(driverService);
323
405
  }
324
406
  builder.withCapabilities(caps);
325
- const driver = await builder.build();
407
+ let driver;
408
+ if (target.kind === 'electron') {
409
+ try {
410
+ driver = await builder.build();
411
+ }
412
+ catch (err) {
413
+ // Turn an opaque "Chrome instance exited" into a diagnosed, actionable
414
+ // error (macOS signing/Gatekeeper, Linux sandbox) with the chromedriver
415
+ // output tail attached. Non-app-exit failures pass through unchanged.
416
+ throw diagnoseElectronLaunchFailure(err, {
417
+ appBinaryPath: electronBinary,
418
+ args: target.args,
419
+ driverOutputTail: driverService.getOutputTail?.(),
420
+ });
421
+ }
422
+ }
423
+ else {
424
+ driver = await builder.build();
425
+ }
326
426
  const browser = new Browser(driver);
327
427
  browser._downloadsDir = downloadsDir;
328
428
  browser._driverService = driverService;
329
429
  browser._browserName = name;
430
+ browser._electronInspect = electronInspect;
431
+ browser._electronAppBinaryPath = electronBinary;
432
+ // With main-process access enabled, open the inspector bridge now so
433
+ // main-process console/error capture (browser.electron.mainLogs) starts at
434
+ // launch, mirroring always-on renderer log capture. Best-effort: a disabled
435
+ // EnableNodeCliInspectArguments fuse or an unreachable inspector must never
436
+ // fail renderer automation — executeMain still surfaces the actionable error
437
+ // on first use.
438
+ if (electronInspect) {
439
+ const remote = new ElectronRemote(electronInspect, { appBinaryPath: electronBinary });
440
+ browser._electron = remote;
441
+ await remote.connect().catch((err) => {
442
+ writeElectronDebug({
443
+ event: 'main-bridge-eager-connect-failed',
444
+ error: err instanceof Error ? err.message : String(err),
445
+ });
446
+ });
447
+ }
330
448
  // Initialize BiDi session if WebSocket URL available
331
449
  const wsUrl = driver.__wsUrl;
332
- if (wsUrl && options.enableBiDi !== false) {
450
+ if (wsUrl && bidiRequested) {
333
451
  await browser.initBiDi(wsUrl);
334
452
  }
335
453
  // Load session state if provided
@@ -448,6 +566,26 @@ export class Browser {
448
566
  }
449
567
  return this._a11y;
450
568
  }
569
+ /**
570
+ * Electron **main-process** access. `browser.electron.executeMain(fn, ...args)`
571
+ * runs `fn(electron, ...args)` in the app's main process (full `electron`
572
+ * module), complementing the renderer automation on `Browser` itself.
573
+ *
574
+ * Requires launching with `electron: { mainProcess: true }`; otherwise
575
+ * `executeMain` throws `ELECTRON_MAIN_UNAVAILABLE`.
576
+ *
577
+ * ```ts
578
+ * const version = await browser.electron.executeMain((electron) => electron.app.getVersion());
579
+ * ```
580
+ */
581
+ get electron() {
582
+ if (!this._electron) {
583
+ this._electron = new ElectronRemote(this._electronInspect, {
584
+ appBinaryPath: this._electronAppBinaryPath,
585
+ });
586
+ }
587
+ return this._electron;
588
+ }
451
589
  /**
452
590
  * Save current session state (cookies + localStorage) to file
453
591
  */
@@ -660,8 +798,10 @@ export class Browser {
660
798
  else {
661
799
  if (typeof coords.latitude !== 'number' ||
662
800
  typeof coords.longitude !== 'number' ||
663
- coords.latitude < -90 || coords.latitude > 90 ||
664
- coords.longitude < -180 || coords.longitude > 180) {
801
+ coords.latitude < -90 ||
802
+ coords.latitude > 90 ||
803
+ coords.longitude < -180 ||
804
+ coords.longitude > 180) {
665
805
  throw new Error(`setGeolocation: invalid coordinates ${JSON.stringify(coords)}. ` +
666
806
  'latitude must be in [-90, 90] and longitude in [-180, 180].');
667
807
  }
@@ -836,7 +976,7 @@ export class Browser {
836
976
  }
837
977
  // Classic fallback: best-effort 500ms settle
838
978
  else {
839
- await new Promise(r => setTimeout(r, NETWORK_IDLE_SETTLE_MS));
979
+ await new Promise((r) => setTimeout(r, NETWORK_IDLE_SETTLE_MS));
840
980
  }
841
981
  return;
842
982
  }
@@ -881,7 +1021,7 @@ export class Browser {
881
1021
  const readyState = await this.driver.executeScript('return document.readyState', []);
882
1022
  if (readyState === 'complete' || (target === 'interactive' && readyState === 'interactive'))
883
1023
  return;
884
- await new Promise(r => setTimeout(r, STATE_POLL_INTERVAL_MS));
1024
+ await new Promise((r) => setTimeout(r, STATE_POLL_INTERVAL_MS));
885
1025
  }
886
1026
  throw new CraftdriverError(ErrorCode.TIMEOUT_WAITING_LOAD, `waitForLoadState('${state}') timed out after ${timeout}ms`, { detail: { state, timeout } });
887
1027
  }
@@ -901,6 +1041,10 @@ export class Browser {
901
1041
  // Whether this session had a live BiDi WebSocket. Only the Firefox+BiDi
902
1042
  // combination needs the port-release pause below; capture it before close().
903
1043
  const bidiWasActive = !!this.bidiSession;
1044
+ // Close the Electron main-process inspector bridge, if one was opened.
1045
+ if (this._electron) {
1046
+ await this._electron.close().catch(() => { });
1047
+ }
904
1048
  // Close BiDi connection first
905
1049
  if (this.bidiSession) {
906
1050
  await this.bidiSession.close().catch(() => { });
@@ -1015,7 +1159,7 @@ export class Browser {
1015
1159
  catch (err) {
1016
1160
  if (attempt < EVAL_REALM_RETRY_ATTEMPTS &&
1017
1161
  String(err?.message).includes('execution contexts cleared')) {
1018
- await new Promise(r => setTimeout(r, EVAL_REALM_RETRY_DELAY_MS));
1162
+ await new Promise((r) => setTimeout(r, EVAL_REALM_RETRY_DELAY_MS));
1019
1163
  continue;
1020
1164
  }
1021
1165
  throw err;
@@ -1054,9 +1198,7 @@ export class Browser {
1054
1198
  'BiDi is enabled by default — check that your browser supports it.');
1055
1199
  }
1056
1200
  const conn = this.bidiSession.getConnection();
1057
- const fnSrc = typeof fnOrSrc === 'function'
1058
- ? fnOrSrc.toString()
1059
- : `() => { ${fnOrSrc} }`;
1201
+ const fnSrc = typeof fnOrSrc === 'function' ? fnOrSrc.toString() : `() => { ${fnOrSrc} }`;
1060
1202
  const result = await conn.send('script.addPreloadScript', {
1061
1203
  functionDeclaration: fnSrc,
1062
1204
  });
@@ -1079,7 +1221,9 @@ export class Browser {
1079
1221
  if (!this.bidiSession?.isConnected()) {
1080
1222
  throw new Error('waitForRequest() requires BiDi. BiDi is enabled by default — check your browser supports it.');
1081
1223
  }
1082
- return this.network.waitForRequest(pattern, { timeout: opts?.timeout ?? this.defaults.navigationTimeout });
1224
+ return this.network.waitForRequest(pattern, {
1225
+ timeout: opts?.timeout ?? this.defaults.navigationTimeout,
1226
+ });
1083
1227
  }
1084
1228
  /**
1085
1229
  * Wait for the first completed response matching a URL glob or predicate.
@@ -1098,7 +1242,9 @@ export class Browser {
1098
1242
  if (!this.bidiSession?.isConnected()) {
1099
1243
  throw new Error('waitForResponse() requires BiDi. BiDi is enabled by default \u2014 check your browser supports it.');
1100
1244
  }
1101
- return this.network.waitForResponse(pattern, { timeout: opts?.timeout ?? this.defaults.navigationTimeout });
1245
+ return this.network.waitForResponse(pattern, {
1246
+ timeout: opts?.timeout ?? this.defaults.navigationTimeout,
1247
+ });
1102
1248
  }
1103
1249
  on(event, listener) {
1104
1250
  if (!this.bidiSession?.isConnected()) {
@@ -1132,7 +1278,7 @@ export class Browser {
1132
1278
  const deadline = Date.now() + timeout;
1133
1279
  while (Date.now() < deadline) {
1134
1280
  const after = fsSync.readdirSync(dir);
1135
- const newFiles = after.filter(f => !before.has(f) && !f.endsWith('.crdownload'));
1281
+ const newFiles = after.filter((f) => !before.has(f) && !f.endsWith('.crdownload'));
1136
1282
  if (newFiles.length > 0) {
1137
1283
  const filename = newFiles[0];
1138
1284
  const filePath = path.join(dir, filename);
@@ -1144,7 +1290,7 @@ export class Browser {
1144
1290
  },
1145
1291
  };
1146
1292
  }
1147
- await new Promise(r => setTimeout(r, STATE_POLL_INTERVAL_MS));
1293
+ await new Promise((r) => setTimeout(r, STATE_POLL_INTERVAL_MS));
1148
1294
  }
1149
1295
  throw new Error(`waitForDownload() timed out after ${timeout}ms — no file appeared in ${dir}`);
1150
1296
  }
@@ -1173,7 +1319,9 @@ export class Browser {
1173
1319
  }
1174
1320
  // Classic: no push events — callers must use the imperative API below
1175
1321
  // Return a no-op unsubscribe
1176
- return () => { };
1322
+ return () => {
1323
+ /* no-op */
1324
+ };
1177
1325
  }
1178
1326
  /**
1179
1327
  * Accept the currently open dialog (OK / confirm).
@@ -1231,7 +1379,10 @@ export class Browser {
1231
1379
  const timeout = opts?.timeout ?? this.defaults.timeout;
1232
1380
  return new Promise((resolve, reject) => {
1233
1381
  const tid = timeout > 0
1234
- ? setTimeout(() => { off(); reject(new Error(`waitForDialog timed out after ${timeout}ms`)); }, timeout)
1382
+ ? setTimeout(() => {
1383
+ off();
1384
+ reject(new Error(`waitForDialog timed out after ${timeout}ms`));
1385
+ }, timeout)
1235
1386
  : undefined;
1236
1387
  const off = this.onDialog((dialog) => {
1237
1388
  if (tid !== undefined)
@@ -1249,8 +1400,12 @@ export class Browser {
1249
1400
  type: () => _type,
1250
1401
  message: () => _message,
1251
1402
  defaultValue: () => _defaultValue,
1252
- accept: async (text) => { await this.acceptDialog(text); },
1253
- dismiss: async () => { await this.dismissDialog(); },
1403
+ accept: async (text) => {
1404
+ await this.acceptDialog(text);
1405
+ },
1406
+ dismiss: async () => {
1407
+ await this.dismissDialog();
1408
+ },
1254
1409
  };
1255
1410
  }
1256
1411
  // ─── Frames ───────────────────────────────────────────────────────────────
@@ -1276,7 +1431,9 @@ export class Browser {
1276
1431
  const topContextId = this.bidiSession.getContext();
1277
1432
  if (topContextId) {
1278
1433
  try {
1279
- const tree = await conn.send('browsingContext.getTree', { root: topContextId });
1434
+ const tree = await conn.send('browsingContext.getTree', {
1435
+ root: topContextId,
1436
+ });
1280
1437
  // The BiDi context for the iframe has the top-level context as ancestor.
1281
1438
  // We find the first child context (iframes appear as children).
1282
1439
  const iframeSrc = await el.getAttribute('src');
@@ -1304,7 +1461,9 @@ export class Browser {
1304
1461
  let tree;
1305
1462
  if (conn && topContextId) {
1306
1463
  try {
1307
- tree = await conn.send('browsingContext.getTree', { root: topContextId });
1464
+ tree = await conn.send('browsingContext.getTree', {
1465
+ root: topContextId,
1466
+ });
1308
1467
  }
1309
1468
  catch {
1310
1469
  tree = undefined;
@@ -1328,7 +1487,9 @@ export class Browser {
1328
1487
  async pages() {
1329
1488
  if (this.bidiSession?.isConnected()) {
1330
1489
  const conn = this.bidiSession.getConnection();
1331
- const tree = await conn.send('browsingContext.getTree', { maxDepth: 0 });
1490
+ const tree = await conn.send('browsingContext.getTree', {
1491
+ maxDepth: 0,
1492
+ });
1332
1493
  return (tree.contexts ?? []).map((ctx) => new Page(this.driver, ctx.context, this.getDefaultTimeout, conn, this._wrapContext(ctx.userContext ?? 'default')));
1333
1494
  }
1334
1495
  // Classic fallback: use window handles
@@ -1365,16 +1526,11 @@ export class Browser {
1365
1526
  }
1366
1527
  return page;
1367
1528
  }
1368
- /**
1369
- * Run `action` and wait for a new top-level browsing context (tab / popup)
1370
- * to open. Returns a `Page` bound to the new context.
1371
- *
1372
- * @example
1373
- * const popup = await browser.waitForPage(() => browser.click('#open-popup'));
1374
- * await popup.waitForLoadState();
1375
- * const title = await popup.title();
1376
- */
1377
- async waitForPage(action, opts) {
1529
+ async waitForPage(actionOrMatcher, opts) {
1530
+ if (typeof actionOrMatcher !== 'function') {
1531
+ return this._waitForMatchingPage(actionOrMatcher, opts);
1532
+ }
1533
+ const action = actionOrMatcher;
1378
1534
  const timeout = opts?.timeout ?? this.defaults.navigationTimeout;
1379
1535
  if (this.bidiSession?.isConnected()) {
1380
1536
  const conn = this.bidiSession.getConnection();
@@ -1407,15 +1563,60 @@ export class Browser {
1407
1563
  const deadline = Date.now() + timeout;
1408
1564
  while (Date.now() < deadline) {
1409
1565
  const after = await this.driver.getWindowHandles();
1410
- const newHandles = after.filter(h => !before.has(h));
1566
+ const newHandles = after.filter((h) => !before.has(h));
1411
1567
  if (newHandles.length > 0) {
1412
1568
  const handle = newHandles[0];
1413
1569
  return new Page(this.driver, handle, this.getDefaultTimeout);
1414
1570
  }
1415
- await new Promise(r => setTimeout(r, STATE_POLL_INTERVAL_MS));
1571
+ await new Promise((r) => setTimeout(r, STATE_POLL_INTERVAL_MS));
1416
1572
  }
1417
1573
  throw new Error(`waitForPage() timed out after ${timeout}ms — no new tab or popup appeared`);
1418
1574
  }
1575
+ /** Poll open top-level pages until one matches `matcher`; see {@link waitForPage}. */
1576
+ async _waitForMatchingPage(matcher, opts) {
1577
+ if (matcher.url === undefined && matcher.title === undefined) {
1578
+ throw new Error('waitForPage(matcher): provide at least one of { url, title }.');
1579
+ }
1580
+ const timeout = opts?.timeout ?? this.defaults.navigationTimeout;
1581
+ const deadline = Date.now() + timeout;
1582
+ const seen = new Set();
1583
+ for (;;) {
1584
+ for (const page of await this.pages()) {
1585
+ try {
1586
+ if (await this._pageMatches(page, matcher, seen)) {
1587
+ // Classic: make the matched window current so browser.find(...) targets
1588
+ // it too (BiDi drives through the returned Page's own context).
1589
+ if (!this.bidiSession?.isConnected()) {
1590
+ await this.driver.switchToWindow(page.id()).catch(() => { });
1591
+ }
1592
+ return page;
1593
+ }
1594
+ }
1595
+ catch (err) {
1596
+ // A window that closed mid-check (e.g. the splash) — skip it, keep looking.
1597
+ if (!isNoSuchWindowError(err))
1598
+ throw err;
1599
+ }
1600
+ }
1601
+ if (Date.now() >= deadline) {
1602
+ const label = [
1603
+ matcher.title !== undefined ? `title ${String(matcher.title)}` : undefined,
1604
+ matcher.url !== undefined ? `url ${String(matcher.url)}` : undefined,
1605
+ ]
1606
+ .filter(Boolean)
1607
+ .join(' and ');
1608
+ throw new Error(`waitForPage(matcher) timed out after ${timeout}ms — no window matched ${label}. ` +
1609
+ `Saw: ${[...seen].join(' | ') || '(no readable windows)'}.`);
1610
+ }
1611
+ await new Promise((r) => setTimeout(r, STATE_POLL_INTERVAL_MS));
1612
+ }
1613
+ }
1614
+ async _pageMatches(page, matcher, seen) {
1615
+ const url = matcher.url !== undefined ? await page.url() : undefined;
1616
+ const title = matcher.title !== undefined ? await page.title() : undefined;
1617
+ seen.add([title !== undefined ? `"${title}"` : '', url ?? ''].filter(Boolean).join(' '));
1618
+ return matchPageFields({ url, title }, matcher);
1619
+ }
1419
1620
  // ─── User contexts (isolated profiles, BiDi-only) ────────────────────────
1420
1621
  /**
1421
1622
  * Create a new isolated user context (an "incognito profile"). Each
@@ -1472,7 +1673,9 @@ export class Browser {
1472
1673
  this._contextsById.set(id, ctx);
1473
1674
  // Evict on close so long-running suites that create many contexts don't
1474
1675
  // accumulate dead wrappers.
1475
- ctx.on('close', () => { this._contextsById.delete(id); });
1676
+ ctx.on('close', () => {
1677
+ this._contextsById.delete(id);
1678
+ });
1476
1679
  return ctx;
1477
1680
  }
1478
1681
  async newContext(opts) {
@@ -1561,7 +1764,9 @@ export class Browser {
1561
1764
  // would break that contract if a future refactor made the branch reachable.
1562
1765
  await conn
1563
1766
  .subscribe(['browsingContext.contextCreated', 'browsingContext.contextDestroyed'])
1564
- .catch(() => { });
1767
+ .catch(() => {
1768
+ /* already subscribed or unsupported; fallback sync will cover us */
1769
+ });
1565
1770
  }
1566
1771
  const onCreated = (params) => {
1567
1772
  if (params.parent)
@@ -1596,7 +1801,9 @@ export class Browser {
1596
1801
  }
1597
1802
  async _refreshTopLevelContextCache(conn) {
1598
1803
  const version = this._topLevelContextCacheVersion;
1599
- const tree = await conn.send('browsingContext.getTree', { maxDepth: 0 });
1804
+ const tree = await conn.send('browsingContext.getTree', {
1805
+ maxDepth: 0,
1806
+ });
1600
1807
  if (this._topLevelContextCacheVersion === version) {
1601
1808
  this._topLevelContextUserContexts.clear();
1602
1809
  }
@@ -1653,8 +1860,30 @@ export class Browser {
1653
1860
  }
1654
1861
  return new Page(this.driver, fallback, this.getDefaultTimeout, conn, this.defaultContext);
1655
1862
  }
1656
- // Classic mode: no user-context concept; just wrap the current window.
1657
- const handle = await this.driver.getCurrentWindowHandle();
1863
+ // Classic mode: wrap the current window. If it was closed (e.g. a splash that
1864
+ // replaced itself), re-point to the sole remaining top-level window when there's
1865
+ // exactly one — unambiguous — and otherwise ask the caller to select explicitly,
1866
+ // rather than guessing among several or throwing an opaque "no such window".
1867
+ let handle;
1868
+ try {
1869
+ handle = await this.driver.getCurrentWindowHandle();
1870
+ }
1871
+ catch (err) {
1872
+ if (!isNoSuchWindowError(err))
1873
+ throw err;
1874
+ }
1875
+ const handles = await this.driver.getWindowHandles();
1876
+ if (handle === undefined || !handles.includes(handle)) {
1877
+ if (handles.length === 0) {
1878
+ throw new Error('activePage(): all windows are closed.');
1879
+ }
1880
+ if (handles.length > 1) {
1881
+ throw new Error('activePage(): the current window was closed and several windows are open. ' +
1882
+ 'Select one with browser.waitForPage({ title } | { url }) or browser.pages().');
1883
+ }
1884
+ handle = handles[0];
1885
+ await this.driver.switchToWindow(handle).catch(() => { });
1886
+ }
1658
1887
  return new Page(this.driver, handle, this.getDefaultTimeout);
1659
1888
  }
1660
1889
  async click(selector, opts) {
@@ -1807,19 +2036,27 @@ export class Browser {
1807
2036
  static Key = Key;
1808
2037
  async waitForVisible(selector, opts) {
1809
2038
  const by = typeof selector === 'string' ? By.css(selector) : selector;
1810
- await this.driver.wait(until.elementIsVisible(by), { timeout: opts?.timeout ?? this.defaults.timeout });
2039
+ await this.driver.wait(until.elementIsVisible(by), {
2040
+ timeout: opts?.timeout ?? this.defaults.timeout,
2041
+ });
1811
2042
  }
1812
2043
  async waitForHidden(selector, opts) {
1813
2044
  const by = typeof selector === 'string' ? By.css(selector) : selector;
1814
- await this.driver.wait(until.elementIsNotVisible(by), { timeout: opts?.timeout ?? this.defaults.timeout });
2045
+ await this.driver.wait(until.elementIsNotVisible(by), {
2046
+ timeout: opts?.timeout ?? this.defaults.timeout,
2047
+ });
1815
2048
  }
1816
2049
  async waitForAttached(selector, opts) {
1817
2050
  const by = typeof selector === 'string' ? By.css(selector) : selector;
1818
- await this.driver.wait(until.elementExists(by), { timeout: opts?.timeout ?? this.defaults.timeout });
2051
+ await this.driver.wait(until.elementExists(by), {
2052
+ timeout: opts?.timeout ?? this.defaults.timeout,
2053
+ });
1819
2054
  }
1820
2055
  async waitForDetached(selector, opts) {
1821
2056
  const by = typeof selector === 'string' ? By.css(selector) : selector;
1822
- await this.driver.wait(until.elementNotExists(by), { timeout: opts?.timeout ?? this.defaults.timeout });
2057
+ await this.driver.wait(until.elementNotExists(by), {
2058
+ timeout: opts?.timeout ?? this.defaults.timeout,
2059
+ });
1823
2060
  }
1824
2061
  /**
1825
2062
  * Wait for an element to reach the given state.
@@ -1833,10 +2070,14 @@ export class Browser {
1833
2070
  */
1834
2071
  async waitFor(selector, opts) {
1835
2072
  switch (opts.state) {
1836
- case 'visible': return this.waitForVisible(selector, opts);
1837
- case 'hidden': return this.waitForHidden(selector, opts);
1838
- case 'attached': return this.waitForAttached(selector, opts);
1839
- case 'detached': return this.waitForDetached(selector, opts);
2073
+ case 'visible':
2074
+ return this.waitForVisible(selector, opts);
2075
+ case 'hidden':
2076
+ return this.waitForHidden(selector, opts);
2077
+ case 'attached':
2078
+ return this.waitForAttached(selector, opts);
2079
+ case 'detached':
2080
+ return this.waitForDetached(selector, opts);
1840
2081
  }
1841
2082
  }
1842
2083
  actions() {