craftdriver 1.2.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 (113) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +2 -1
  3. package/dist/cli/dispatcher.d.ts.map +1 -1
  4. package/dist/cli/dispatcher.js +13 -1
  5. package/dist/cli/dispatcher.js.map +1 -1
  6. package/dist/cli/mcp/tools.d.ts +1 -2
  7. package/dist/cli/mcp/tools.d.ts.map +1 -1
  8. package/dist/cli/mcp/tools.js +18 -0
  9. package/dist/cli/mcp/tools.js.map +1 -1
  10. package/dist/index.d.ts +7 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +4 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/lib/browser.d.ts +156 -27
  15. package/dist/lib/browser.d.ts.map +1 -1
  16. package/dist/lib/browser.js +474 -212
  17. package/dist/lib/browser.js.map +1 -1
  18. package/dist/lib/capabilities.d.ts +35 -0
  19. package/dist/lib/capabilities.d.ts.map +1 -0
  20. package/dist/lib/capabilities.js +58 -0
  21. package/dist/lib/capabilities.js.map +1 -0
  22. package/dist/lib/chrome.d.ts +7 -2
  23. package/dist/lib/chrome.d.ts.map +1 -1
  24. package/dist/lib/chrome.js +10 -1
  25. package/dist/lib/chrome.js.map +1 -1
  26. package/dist/lib/driverManager.d.ts +92 -0
  27. package/dist/lib/driverManager.d.ts.map +1 -1
  28. package/dist/lib/driverManager.js +229 -10
  29. package/dist/lib/driverManager.js.map +1 -1
  30. package/dist/lib/electron.d.ts +60 -0
  31. package/dist/lib/electron.d.ts.map +1 -0
  32. package/dist/lib/electron.js +85 -0
  33. package/dist/lib/electron.js.map +1 -0
  34. package/dist/lib/electronDeeplink.d.ts +22 -0
  35. package/dist/lib/electronDeeplink.d.ts.map +1 -0
  36. package/dist/lib/electronDeeplink.js +118 -0
  37. package/dist/lib/electronDeeplink.js.map +1 -0
  38. package/dist/lib/electronDiagnostics.d.ts +49 -0
  39. package/dist/lib/electronDiagnostics.d.ts.map +1 -0
  40. package/dist/lib/electronDiagnostics.js +195 -0
  41. package/dist/lib/electronDiagnostics.js.map +1 -0
  42. package/dist/lib/electronDialogMock.d.ts +51 -0
  43. package/dist/lib/electronDialogMock.d.ts.map +1 -0
  44. package/dist/lib/electronDialogMock.js +106 -0
  45. package/dist/lib/electronDialogMock.js.map +1 -0
  46. package/dist/lib/electronFuses.d.ts +13 -0
  47. package/dist/lib/electronFuses.d.ts.map +1 -0
  48. package/dist/lib/electronFuses.js +118 -0
  49. package/dist/lib/electronFuses.js.map +1 -0
  50. package/dist/lib/electronMainBridge.d.ts +28 -0
  51. package/dist/lib/electronMainBridge.d.ts.map +1 -0
  52. package/dist/lib/electronMainBridge.js +270 -0
  53. package/dist/lib/electronMainBridge.js.map +1 -0
  54. package/dist/lib/electronMainLogs.d.ts +81 -0
  55. package/dist/lib/electronMainLogs.d.ts.map +1 -0
  56. package/dist/lib/electronMainLogs.js +176 -0
  57. package/dist/lib/electronMainLogs.js.map +1 -0
  58. package/dist/lib/electronMock.d.ts +52 -0
  59. package/dist/lib/electronMock.d.ts.map +1 -0
  60. package/dist/lib/electronMock.js +96 -0
  61. package/dist/lib/electronMock.js.map +1 -0
  62. package/dist/lib/electronRemote.d.ts +106 -0
  63. package/dist/lib/electronRemote.d.ts.map +1 -0
  64. package/dist/lib/electronRemote.js +406 -0
  65. package/dist/lib/electronRemote.js.map +1 -0
  66. package/dist/lib/electronVersions.d.ts +34 -0
  67. package/dist/lib/electronVersions.d.ts.map +1 -0
  68. package/dist/lib/electronVersions.js +90 -0
  69. package/dist/lib/electronVersions.js.map +1 -0
  70. package/dist/lib/errors.d.ts +27 -0
  71. package/dist/lib/errors.d.ts.map +1 -1
  72. package/dist/lib/errors.js +27 -0
  73. package/dist/lib/errors.js.map +1 -1
  74. package/dist/lib/launchTarget.d.ts +30 -0
  75. package/dist/lib/launchTarget.d.ts.map +1 -0
  76. package/dist/lib/launchTarget.js +112 -0
  77. package/dist/lib/launchTarget.js.map +1 -0
  78. package/dist/lib/page.d.ts +1 -0
  79. package/dist/lib/page.d.ts.map +1 -1
  80. package/dist/lib/page.js +1 -1
  81. package/dist/lib/page.js.map +1 -1
  82. package/dist/lib/service.d.ts +21 -0
  83. package/dist/lib/service.d.ts.map +1 -1
  84. package/dist/lib/service.js +94 -6
  85. package/dist/lib/service.js.map +1 -1
  86. package/dist/lib/tracing.d.ts +20 -4
  87. package/dist/lib/tracing.d.ts.map +1 -1
  88. package/dist/lib/tracing.js +38 -3
  89. package/dist/lib/tracing.js.map +1 -1
  90. package/dist/lib/vibiumTrace.d.ts +14 -0
  91. package/dist/lib/vibiumTrace.d.ts.map +1 -0
  92. package/dist/lib/vibiumTrace.js +372 -0
  93. package/dist/lib/vibiumTrace.js.map +1 -0
  94. package/docs/api-reference.md +38 -2
  95. package/docs/browser-api.md +7 -1
  96. package/docs/electron.md +419 -0
  97. package/docs/error-codes.md +5 -0
  98. package/docs/index.md +4 -0
  99. package/docs/mcp.md +17 -3
  100. package/docs/public/recipes/trace-failing-test.html +16 -0
  101. package/docs/public/recipes/vitest-vibium-trace.html +16 -0
  102. package/docs/public/traces/vitest-login.zip +0 -0
  103. package/docs/recipes/debug-failing-tests-with-traces.md +117 -0
  104. package/docs/recipes/electron-app-from-another-repo.md +136 -0
  105. package/docs/recipes/electron-deep-link.md +117 -0
  106. package/docs/recipes/electron-mock-apis.md +88 -0
  107. package/docs/recipes/electron-native-dialog.md +105 -0
  108. package/docs/recipes.md +29 -10
  109. package/docs/tracing.md +102 -24
  110. package/package.json +8 -8
  111. package/skills/craftdriver/cheatsheet.md +4 -2
  112. package/skills/craftdriver/patterns.md +2 -1
  113. package/docs/recipes/trace-failing-test.md +0 -51
@@ -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
@@ -434,13 +552,13 @@ export class Browser {
434
552
  return this._clock;
435
553
  }
436
554
  /**
437
- * Accessibility audits via axe-core (optional peer dependency).
555
+ * Accessibility audits via axe-core.
438
556
  *
439
557
  * ```ts
440
558
  * await browser.a11y.check({ disableRules: ['color-contrast'] });
441
559
  * ```
442
560
  *
443
- * Requires `axe-core` to be installed: `npm install --save-dev axe-core`.
561
+ * axe-core ships as a direct dependency of craftdriver.
444
562
  */
445
563
  get a11y() {
446
564
  if (!this._a11y) {
@@ -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
  */
@@ -460,61 +598,77 @@ export class Browser {
460
598
  async loadState(path) {
461
599
  return this.storage.loadState(path);
462
600
  }
601
+ async _runTracedAction(name, args, selector, run) {
602
+ const actionIndex = this._tracer?.recordAction(name, args, selector);
603
+ try {
604
+ const result = await run();
605
+ this._tracer?.recordActionEnd(actionIndex);
606
+ return result;
607
+ }
608
+ catch (error) {
609
+ this._tracer?.recordActionEnd(actionIndex, error);
610
+ throw error;
611
+ }
612
+ }
463
613
  async navigateTo(url, opts) {
464
- this._tracer?.recordAction('navigateTo', [url, opts], url);
465
- const waitUntil = opts?.waitUntil ?? 'load';
466
- // Classic-first for ordinary `waitUntil: 'load'` navigations: Classic
467
- // already blocks until `document.readyState === 'complete'`, so the common
468
- // case avoids a BiDi round trip. Preload-backed sessions stay on BiDi
469
- // because the next operation is often a BiDi script evaluation into the new
470
- // document; mixing Classic navigate + immediate BiDi evaluate can race with
471
- // the browser clearing old execution contexts on loaded-but-settling pages.
472
- const needsBiDi = waitUntil !== 'load' || this.hasDefaultNavigationInitScripts();
473
- const context = needsBiDi ? this.bidiSession?.getContext() : undefined;
474
- if (context && this.bidiSession?.isConnected()) {
475
- const conn = this.bidiSession.getConnection();
476
- const bidiWait = bidiWaitFor(waitUntil);
477
- await conn.send('browsingContext.navigate', { context, url, wait: bidiWait });
614
+ return this._runTracedAction('navigateTo', [url, opts], url, async () => {
615
+ const waitUntil = opts?.waitUntil ?? 'load';
616
+ // Classic-first for ordinary `waitUntil: 'load'` navigations: Classic
617
+ // already blocks until `document.readyState === 'complete'`, so the common
618
+ // case avoids a BiDi round trip. Preload-backed sessions stay on BiDi
619
+ // because the next operation is often a BiDi script evaluation into the new
620
+ // document; mixing Classic navigate + immediate BiDi evaluate can race with
621
+ // the browser clearing old execution contexts on loaded-but-settling pages.
622
+ const needsBiDi = waitUntil !== 'load' || this.hasDefaultNavigationInitScripts();
623
+ const context = needsBiDi ? this.bidiSession?.getContext() : undefined;
624
+ if (context && this.bidiSession?.isConnected()) {
625
+ const conn = this.bidiSession.getConnection();
626
+ const bidiWait = bidiWaitFor(waitUntil);
627
+ await conn.send('browsingContext.navigate', { context, url, wait: bidiWait });
628
+ if (waitUntil === 'networkidle') {
629
+ await this.bidiSession.network.waitForNetworkIdle({
630
+ timeout: this.defaults.navigationTimeout,
631
+ });
632
+ }
633
+ return;
634
+ }
635
+ // Classic path — default 'load', or BiDi unavailable/no context yet.
636
+ await this.driver.navigateTo(url);
478
637
  if (waitUntil === 'networkidle') {
479
- await this.bidiSession.network.waitForNetworkIdle({
480
- timeout: this.defaults.navigationTimeout,
481
- });
638
+ // Best-effort: Classic can't track network events; give it a short settle time
639
+ await new Promise(r => setTimeout(r, NETWORK_IDLE_SETTLE_MS));
482
640
  }
483
- return;
484
- }
485
- // Classic path — default 'load', or BiDi unavailable/no context yet.
486
- await this.driver.navigateTo(url);
487
- if (waitUntil === 'networkidle') {
488
- // Best-effort: Classic can't track network events; give it a short settle time
489
- await new Promise(r => setTimeout(r, NETWORK_IDLE_SETTLE_MS));
490
- }
641
+ });
491
642
  }
492
643
  /**
493
644
  * Navigate the active page back one step in its session history.
494
645
  * Proxies to {@link Page.goBack} on {@link activePage}.
495
646
  */
496
647
  async goBack() {
497
- this._tracer?.recordAction('goBack');
498
- const page = await this.activePage();
499
- await page.goBack();
648
+ return this._runTracedAction('goBack', undefined, undefined, async () => {
649
+ const page = await this.activePage();
650
+ await page.goBack();
651
+ });
500
652
  }
501
653
  /**
502
654
  * Navigate the active page forward one step in its session history.
503
655
  * Proxies to {@link Page.goForward} on {@link activePage}.
504
656
  */
505
657
  async goForward() {
506
- this._tracer?.recordAction('goForward');
507
- const page = await this.activePage();
508
- await page.goForward();
658
+ return this._runTracedAction('goForward', undefined, undefined, async () => {
659
+ const page = await this.activePage();
660
+ await page.goForward();
661
+ });
509
662
  }
510
663
  /**
511
664
  * Reload the active page. Proxies to {@link Page.reload} on
512
665
  * {@link activePage}.
513
666
  */
514
667
  async reload() {
515
- this._tracer?.recordAction('reload');
516
- const page = await this.activePage();
517
- await page.reload();
668
+ return this._runTracedAction('reload', undefined, undefined, async () => {
669
+ const page = await this.activePage();
670
+ await page.reload();
671
+ });
518
672
  }
519
673
  /**
520
674
  * Return the full HTML serialization of the active page.
@@ -529,9 +683,10 @@ export class Browser {
529
683
  * Proxies to {@link Page.setContent}.
530
684
  */
531
685
  async setContent(html, opts) {
532
- this._tracer?.recordAction('setContent', [opts]);
533
- const page = await this.activePage();
534
- await page.setContent(html, opts);
686
+ return this._runTracedAction('setContent', [opts], undefined, async () => {
687
+ const page = await this.activePage();
688
+ await page.setContent(html, opts);
689
+ });
535
690
  }
536
691
  /**
537
692
  * Resize the viewport at runtime.
@@ -643,8 +798,10 @@ export class Browser {
643
798
  else {
644
799
  if (typeof coords.latitude !== 'number' ||
645
800
  typeof coords.longitude !== 'number' ||
646
- coords.latitude < -90 || coords.latitude > 90 ||
647
- coords.longitude < -180 || coords.longitude > 180) {
801
+ coords.latitude < -90 ||
802
+ coords.latitude > 90 ||
803
+ coords.longitude < -180 ||
804
+ coords.longitude > 180) {
648
805
  throw new Error(`setGeolocation: invalid coordinates ${JSON.stringify(coords)}. ` +
649
806
  'latitude must be in [-90, 90] and longitude in [-180, 180].');
650
807
  }
@@ -819,7 +976,7 @@ export class Browser {
819
976
  }
820
977
  // Classic fallback: best-effort 500ms settle
821
978
  else {
822
- await new Promise(r => setTimeout(r, NETWORK_IDLE_SETTLE_MS));
979
+ await new Promise((r) => setTimeout(r, NETWORK_IDLE_SETTLE_MS));
823
980
  }
824
981
  return;
825
982
  }
@@ -864,7 +1021,7 @@ export class Browser {
864
1021
  const readyState = await this.driver.executeScript('return document.readyState', []);
865
1022
  if (readyState === 'complete' || (target === 'interactive' && readyState === 'interactive'))
866
1023
  return;
867
- await new Promise(r => setTimeout(r, STATE_POLL_INTERVAL_MS));
1024
+ await new Promise((r) => setTimeout(r, STATE_POLL_INTERVAL_MS));
868
1025
  }
869
1026
  throw new CraftdriverError(ErrorCode.TIMEOUT_WAITING_LOAD, `waitForLoadState('${state}') timed out after ${timeout}ms`, { detail: { state, timeout } });
870
1027
  }
@@ -884,6 +1041,10 @@ export class Browser {
884
1041
  // Whether this session had a live BiDi WebSocket. Only the Firefox+BiDi
885
1042
  // combination needs the port-release pause below; capture it before close().
886
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
+ }
887
1048
  // Close BiDi connection first
888
1049
  if (this.bidiSession) {
889
1050
  await this.bidiSession.close().catch(() => { });
@@ -938,20 +1099,22 @@ export class Browser {
938
1099
  'Tracing relies on BiDi events; Classic WebDriver does not expose them.', { detail: { feature: 'startTrace' } });
939
1100
  }
940
1101
  if (!this._tracer) {
941
- this._tracer = new Tracer(this, this.bidiSession.getConnection());
1102
+ this._tracer = new Tracer(this, this.bidiSession.getConnection(), this._browserName);
942
1103
  }
943
1104
  await this._tracer.start(opts);
944
1105
  }
945
1106
  /**
946
1107
  * Stop the active trace: drain in-flight screenshots, write the closing
947
- * `meta` line, and close the file. If your test threw and never reached
948
- * here, the file is still valid NDJSON just without the closing line.
1108
+ * `meta` line, and close the file. Pass `{ path: './trace.zip' }` to also
1109
+ * export a Vibium/Playwright-compatible archive. If your test threw and
1110
+ * never reached here, the raw file is still valid NDJSON — just without
1111
+ * the closing line.
949
1112
  */
950
- async stopTrace() {
1113
+ async stopTrace(opts) {
951
1114
  if (!this._tracer || !this._tracer.isRunning) {
952
1115
  throw new CraftdriverError(ErrorCode.STATE_INVALID, 'stopTrace(): no trace is running. Call startTrace() first.', { detail: { feature: 'stopTrace' } });
953
1116
  }
954
- await this._tracer.stop();
1117
+ await this._tracer.stop(opts);
955
1118
  }
956
1119
  /**
957
1120
  * Execute JavaScript in the page and return the result.
@@ -996,7 +1159,7 @@ export class Browser {
996
1159
  catch (err) {
997
1160
  if (attempt < EVAL_REALM_RETRY_ATTEMPTS &&
998
1161
  String(err?.message).includes('execution contexts cleared')) {
999
- await new Promise(r => setTimeout(r, EVAL_REALM_RETRY_DELAY_MS));
1162
+ await new Promise((r) => setTimeout(r, EVAL_REALM_RETRY_DELAY_MS));
1000
1163
  continue;
1001
1164
  }
1002
1165
  throw err;
@@ -1035,9 +1198,7 @@ export class Browser {
1035
1198
  'BiDi is enabled by default — check that your browser supports it.');
1036
1199
  }
1037
1200
  const conn = this.bidiSession.getConnection();
1038
- const fnSrc = typeof fnOrSrc === 'function'
1039
- ? fnOrSrc.toString()
1040
- : `() => { ${fnOrSrc} }`;
1201
+ const fnSrc = typeof fnOrSrc === 'function' ? fnOrSrc.toString() : `() => { ${fnOrSrc} }`;
1041
1202
  const result = await conn.send('script.addPreloadScript', {
1042
1203
  functionDeclaration: fnSrc,
1043
1204
  });
@@ -1060,7 +1221,9 @@ export class Browser {
1060
1221
  if (!this.bidiSession?.isConnected()) {
1061
1222
  throw new Error('waitForRequest() requires BiDi. BiDi is enabled by default — check your browser supports it.');
1062
1223
  }
1063
- 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
+ });
1064
1227
  }
1065
1228
  /**
1066
1229
  * Wait for the first completed response matching a URL glob or predicate.
@@ -1079,7 +1242,9 @@ export class Browser {
1079
1242
  if (!this.bidiSession?.isConnected()) {
1080
1243
  throw new Error('waitForResponse() requires BiDi. BiDi is enabled by default \u2014 check your browser supports it.');
1081
1244
  }
1082
- 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
+ });
1083
1248
  }
1084
1249
  on(event, listener) {
1085
1250
  if (!this.bidiSession?.isConnected()) {
@@ -1113,7 +1278,7 @@ export class Browser {
1113
1278
  const deadline = Date.now() + timeout;
1114
1279
  while (Date.now() < deadline) {
1115
1280
  const after = fsSync.readdirSync(dir);
1116
- const newFiles = after.filter(f => !before.has(f) && !f.endsWith('.crdownload'));
1281
+ const newFiles = after.filter((f) => !before.has(f) && !f.endsWith('.crdownload'));
1117
1282
  if (newFiles.length > 0) {
1118
1283
  const filename = newFiles[0];
1119
1284
  const filePath = path.join(dir, filename);
@@ -1125,7 +1290,7 @@ export class Browser {
1125
1290
  },
1126
1291
  };
1127
1292
  }
1128
- await new Promise(r => setTimeout(r, STATE_POLL_INTERVAL_MS));
1293
+ await new Promise((r) => setTimeout(r, STATE_POLL_INTERVAL_MS));
1129
1294
  }
1130
1295
  throw new Error(`waitForDownload() timed out after ${timeout}ms — no file appeared in ${dir}`);
1131
1296
  }
@@ -1154,7 +1319,9 @@ export class Browser {
1154
1319
  }
1155
1320
  // Classic: no push events — callers must use the imperative API below
1156
1321
  // Return a no-op unsubscribe
1157
- return () => { };
1322
+ return () => {
1323
+ /* no-op */
1324
+ };
1158
1325
  }
1159
1326
  /**
1160
1327
  * Accept the currently open dialog (OK / confirm).
@@ -1162,26 +1329,28 @@ export class Browser {
1162
1329
  * Uses BiDi when available, Classic WebDriver otherwise.
1163
1330
  */
1164
1331
  async acceptDialog(text) {
1165
- this._tracer?.recordAction('acceptDialog', text !== undefined ? [text] : undefined);
1166
- if (this.bidiSession?.isConnected()) {
1167
- await this.bidiSession.handleUserPrompt(true, text);
1168
- return;
1169
- }
1170
- if (text !== undefined)
1171
- await this.driver.sendAlertText(text);
1172
- await this.driver.acceptAlert();
1332
+ return this._runTracedAction('acceptDialog', text !== undefined ? [text] : undefined, undefined, async () => {
1333
+ if (this.bidiSession?.isConnected()) {
1334
+ await this.bidiSession.handleUserPrompt(true, text);
1335
+ return;
1336
+ }
1337
+ if (text !== undefined)
1338
+ await this.driver.sendAlertText(text);
1339
+ await this.driver.acceptAlert();
1340
+ });
1173
1341
  }
1174
1342
  /**
1175
1343
  * Dismiss the currently open dialog (Cancel / close).
1176
1344
  * Uses BiDi when available, Classic WebDriver otherwise.
1177
1345
  */
1178
1346
  async dismissDialog() {
1179
- this._tracer?.recordAction('dismissDialog');
1180
- if (this.bidiSession?.isConnected()) {
1181
- await this.bidiSession.handleUserPrompt(false);
1182
- return;
1183
- }
1184
- await this.driver.dismissAlert();
1347
+ return this._runTracedAction('dismissDialog', undefined, undefined, async () => {
1348
+ if (this.bidiSession?.isConnected()) {
1349
+ await this.bidiSession.handleUserPrompt(false);
1350
+ return;
1351
+ }
1352
+ await this.driver.dismissAlert();
1353
+ });
1185
1354
  }
1186
1355
  /**
1187
1356
  * Get the message text of the currently open dialog.
@@ -1210,7 +1379,10 @@ export class Browser {
1210
1379
  const timeout = opts?.timeout ?? this.defaults.timeout;
1211
1380
  return new Promise((resolve, reject) => {
1212
1381
  const tid = timeout > 0
1213
- ? 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)
1214
1386
  : undefined;
1215
1387
  const off = this.onDialog((dialog) => {
1216
1388
  if (tid !== undefined)
@@ -1228,8 +1400,12 @@ export class Browser {
1228
1400
  type: () => _type,
1229
1401
  message: () => _message,
1230
1402
  defaultValue: () => _defaultValue,
1231
- accept: async (text) => { await this.acceptDialog(text); },
1232
- dismiss: async () => { await this.dismissDialog(); },
1403
+ accept: async (text) => {
1404
+ await this.acceptDialog(text);
1405
+ },
1406
+ dismiss: async () => {
1407
+ await this.dismissDialog();
1408
+ },
1233
1409
  };
1234
1410
  }
1235
1411
  // ─── Frames ───────────────────────────────────────────────────────────────
@@ -1255,7 +1431,9 @@ export class Browser {
1255
1431
  const topContextId = this.bidiSession.getContext();
1256
1432
  if (topContextId) {
1257
1433
  try {
1258
- const tree = await conn.send('browsingContext.getTree', { root: topContextId });
1434
+ const tree = await conn.send('browsingContext.getTree', {
1435
+ root: topContextId,
1436
+ });
1259
1437
  // The BiDi context for the iframe has the top-level context as ancestor.
1260
1438
  // We find the first child context (iframes appear as children).
1261
1439
  const iframeSrc = await el.getAttribute('src');
@@ -1283,7 +1461,9 @@ export class Browser {
1283
1461
  let tree;
1284
1462
  if (conn && topContextId) {
1285
1463
  try {
1286
- tree = await conn.send('browsingContext.getTree', { root: topContextId });
1464
+ tree = await conn.send('browsingContext.getTree', {
1465
+ root: topContextId,
1466
+ });
1287
1467
  }
1288
1468
  catch {
1289
1469
  tree = undefined;
@@ -1307,7 +1487,9 @@ export class Browser {
1307
1487
  async pages() {
1308
1488
  if (this.bidiSession?.isConnected()) {
1309
1489
  const conn = this.bidiSession.getConnection();
1310
- const tree = await conn.send('browsingContext.getTree', { maxDepth: 0 });
1490
+ const tree = await conn.send('browsingContext.getTree', {
1491
+ maxDepth: 0,
1492
+ });
1311
1493
  return (tree.contexts ?? []).map((ctx) => new Page(this.driver, ctx.context, this.getDefaultTimeout, conn, this._wrapContext(ctx.userContext ?? 'default')));
1312
1494
  }
1313
1495
  // Classic fallback: use window handles
@@ -1344,16 +1526,11 @@ export class Browser {
1344
1526
  }
1345
1527
  return page;
1346
1528
  }
1347
- /**
1348
- * Run `action` and wait for a new top-level browsing context (tab / popup)
1349
- * to open. Returns a `Page` bound to the new context.
1350
- *
1351
- * @example
1352
- * const popup = await browser.waitForPage(() => browser.click('#open-popup'));
1353
- * await popup.waitForLoadState();
1354
- * const title = await popup.title();
1355
- */
1356
- 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;
1357
1534
  const timeout = opts?.timeout ?? this.defaults.navigationTimeout;
1358
1535
  if (this.bidiSession?.isConnected()) {
1359
1536
  const conn = this.bidiSession.getConnection();
@@ -1386,15 +1563,60 @@ export class Browser {
1386
1563
  const deadline = Date.now() + timeout;
1387
1564
  while (Date.now() < deadline) {
1388
1565
  const after = await this.driver.getWindowHandles();
1389
- const newHandles = after.filter(h => !before.has(h));
1566
+ const newHandles = after.filter((h) => !before.has(h));
1390
1567
  if (newHandles.length > 0) {
1391
1568
  const handle = newHandles[0];
1392
1569
  return new Page(this.driver, handle, this.getDefaultTimeout);
1393
1570
  }
1394
- await new Promise(r => setTimeout(r, STATE_POLL_INTERVAL_MS));
1571
+ await new Promise((r) => setTimeout(r, STATE_POLL_INTERVAL_MS));
1395
1572
  }
1396
1573
  throw new Error(`waitForPage() timed out after ${timeout}ms — no new tab or popup appeared`);
1397
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
+ }
1398
1620
  // ─── User contexts (isolated profiles, BiDi-only) ────────────────────────
1399
1621
  /**
1400
1622
  * Create a new isolated user context (an "incognito profile"). Each
@@ -1451,7 +1673,9 @@ export class Browser {
1451
1673
  this._contextsById.set(id, ctx);
1452
1674
  // Evict on close so long-running suites that create many contexts don't
1453
1675
  // accumulate dead wrappers.
1454
- ctx.on('close', () => { this._contextsById.delete(id); });
1676
+ ctx.on('close', () => {
1677
+ this._contextsById.delete(id);
1678
+ });
1455
1679
  return ctx;
1456
1680
  }
1457
1681
  async newContext(opts) {
@@ -1540,7 +1764,9 @@ export class Browser {
1540
1764
  // would break that contract if a future refactor made the branch reachable.
1541
1765
  await conn
1542
1766
  .subscribe(['browsingContext.contextCreated', 'browsingContext.contextDestroyed'])
1543
- .catch(() => { });
1767
+ .catch(() => {
1768
+ /* already subscribed or unsupported; fallback sync will cover us */
1769
+ });
1544
1770
  }
1545
1771
  const onCreated = (params) => {
1546
1772
  if (params.parent)
@@ -1575,7 +1801,9 @@ export class Browser {
1575
1801
  }
1576
1802
  async _refreshTopLevelContextCache(conn) {
1577
1803
  const version = this._topLevelContextCacheVersion;
1578
- const tree = await conn.send('browsingContext.getTree', { maxDepth: 0 });
1804
+ const tree = await conn.send('browsingContext.getTree', {
1805
+ maxDepth: 0,
1806
+ });
1579
1807
  if (this._topLevelContextCacheVersion === version) {
1580
1808
  this._topLevelContextUserContexts.clear();
1581
1809
  }
@@ -1632,30 +1860,52 @@ export class Browser {
1632
1860
  }
1633
1861
  return new Page(this.driver, fallback, this.getDefaultTimeout, conn, this.defaultContext);
1634
1862
  }
1635
- // Classic mode: no user-context concept; just wrap the current window.
1636
- 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
+ }
1637
1887
  return new Page(this.driver, handle, this.getDefaultTimeout);
1638
1888
  }
1639
1889
  async click(selector, opts) {
1640
- if (this._tracer?.isRunning)
1641
- this._tracer.recordAction('click', undefined, selectorToString(selector));
1642
- const by = typeof selector === 'string' ? By.css(selector) : selector;
1643
- const timeout = opts?.timeout ?? this.defaults.timeout;
1644
- await clickWithFastPath(() => this.driver.findElement(by), (remaining) => this.driver.wait(until.elementIsVisible(by), { timeout: remaining }), timeout);
1890
+ return this._runTracedAction('click', undefined, selectorToString(selector), async () => {
1891
+ const by = typeof selector === 'string' ? By.css(selector) : selector;
1892
+ const timeout = opts?.timeout ?? this.defaults.timeout;
1893
+ await clickWithFastPath(() => this.driver.findElement(by), (remaining) => this.driver.wait(until.elementIsVisible(by), { timeout: remaining }), timeout);
1894
+ });
1645
1895
  }
1646
1896
  async fill(selector, text, opts) {
1647
- if (this._tracer?.isRunning)
1648
- this._tracer.recordAction('fill', [text], selectorToString(selector));
1649
- const by = typeof selector === 'string' ? By.css(selector) : selector;
1650
- const timeout = opts?.timeout ?? this.defaults.timeout;
1651
- await fillWithFastPath(() => this.driver.findElement(by), (remaining) => this.driver.wait(until.elementIsVisible(by), { timeout: remaining }), text, timeout);
1897
+ return this._runTracedAction('fill', [text], selectorToString(selector), async () => {
1898
+ const by = typeof selector === 'string' ? By.css(selector) : selector;
1899
+ const timeout = opts?.timeout ?? this.defaults.timeout;
1900
+ await fillWithFastPath(() => this.driver.findElement(by), (remaining) => this.driver.wait(until.elementIsVisible(by), { timeout: remaining }), text, timeout);
1901
+ });
1652
1902
  }
1653
1903
  async clear(selector, opts) {
1654
- if (this._tracer?.isRunning)
1655
- this._tracer.recordAction('clear', undefined, selectorToString(selector));
1656
- const by = typeof selector === 'string' ? By.css(selector) : selector;
1657
- const timeout = opts?.timeout ?? this.defaults.timeout;
1658
- await clearWithFastPath(() => this.driver.findElement(by), (remaining) => this.driver.wait(until.elementIsVisible(by), { timeout: remaining }), timeout);
1904
+ return this._runTracedAction('clear', undefined, selectorToString(selector), async () => {
1905
+ const by = typeof selector === 'string' ? By.css(selector) : selector;
1906
+ const timeout = opts?.timeout ?? this.defaults.timeout;
1907
+ await clearWithFastPath(() => this.driver.findElement(by), (remaining) => this.driver.wait(until.elementIsVisible(by), { timeout: remaining }), timeout);
1908
+ });
1659
1909
  }
1660
1910
  async getValue(selector, opts) {
1661
1911
  const by = typeof selector === 'string' ? By.css(selector) : selector;
@@ -1786,19 +2036,27 @@ export class Browser {
1786
2036
  static Key = Key;
1787
2037
  async waitForVisible(selector, opts) {
1788
2038
  const by = typeof selector === 'string' ? By.css(selector) : selector;
1789
- 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
+ });
1790
2042
  }
1791
2043
  async waitForHidden(selector, opts) {
1792
2044
  const by = typeof selector === 'string' ? By.css(selector) : selector;
1793
- 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
+ });
1794
2048
  }
1795
2049
  async waitForAttached(selector, opts) {
1796
2050
  const by = typeof selector === 'string' ? By.css(selector) : selector;
1797
- 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
+ });
1798
2054
  }
1799
2055
  async waitForDetached(selector, opts) {
1800
2056
  const by = typeof selector === 'string' ? By.css(selector) : selector;
1801
- 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
+ });
1802
2060
  }
1803
2061
  /**
1804
2062
  * Wait for an element to reach the given state.
@@ -1812,10 +2070,14 @@ export class Browser {
1812
2070
  */
1813
2071
  async waitFor(selector, opts) {
1814
2072
  switch (opts.state) {
1815
- case 'visible': return this.waitForVisible(selector, opts);
1816
- case 'hidden': return this.waitForHidden(selector, opts);
1817
- case 'attached': return this.waitForAttached(selector, opts);
1818
- 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);
1819
2081
  }
1820
2082
  }
1821
2083
  actions() {