craftdriver 1.3.0 → 1.5.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 (132) hide show
  1. package/CHANGELOG.md +17 -3
  2. package/README.md +4 -1
  3. package/dist/cli/dispatcher.js +1 -1
  4. package/dist/cli/dispatcher.js.map +1 -1
  5. package/dist/cli/parseArgs.d.ts +2 -2
  6. package/dist/cli/parseArgs.d.ts.map +1 -1
  7. package/dist/cli/parseArgs.js +1 -1
  8. package/dist/index.d.ts +7 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +5 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/lib/bidi/storage.d.ts +13 -0
  13. package/dist/lib/bidi/storage.d.ts.map +1 -1
  14. package/dist/lib/bidi/storage.js +81 -32
  15. package/dist/lib/bidi/storage.js.map +1 -1
  16. package/dist/lib/browser.d.ts +173 -21
  17. package/dist/lib/browser.d.ts.map +1 -1
  18. package/dist/lib/browser.js +519 -210
  19. package/dist/lib/browser.js.map +1 -1
  20. package/dist/lib/browserContext.d.ts +2 -2
  21. package/dist/lib/browserContext.d.ts.map +1 -1
  22. package/dist/lib/browserContext.js +1 -1
  23. package/dist/lib/builder.d.ts +4 -1
  24. package/dist/lib/builder.d.ts.map +1 -1
  25. package/dist/lib/builder.js +20 -2
  26. package/dist/lib/builder.js.map +1 -1
  27. package/dist/lib/capabilities.d.ts +45 -0
  28. package/dist/lib/capabilities.d.ts.map +1 -0
  29. package/dist/lib/capabilities.js +103 -0
  30. package/dist/lib/capabilities.js.map +1 -0
  31. package/dist/lib/chrome.d.ts +7 -2
  32. package/dist/lib/chrome.d.ts.map +1 -1
  33. package/dist/lib/chrome.js +10 -1
  34. package/dist/lib/chrome.js.map +1 -1
  35. package/dist/lib/driver.d.ts +16 -1
  36. package/dist/lib/driver.d.ts.map +1 -1
  37. package/dist/lib/driver.js +64 -0
  38. package/dist/lib/driver.js.map +1 -1
  39. package/dist/lib/driverManager.d.ts +120 -0
  40. package/dist/lib/driverManager.d.ts.map +1 -1
  41. package/dist/lib/driverManager.js +289 -10
  42. package/dist/lib/driverManager.js.map +1 -1
  43. package/dist/lib/electron.d.ts +60 -0
  44. package/dist/lib/electron.d.ts.map +1 -0
  45. package/dist/lib/electron.js +85 -0
  46. package/dist/lib/electron.js.map +1 -0
  47. package/dist/lib/electronDeeplink.d.ts +22 -0
  48. package/dist/lib/electronDeeplink.d.ts.map +1 -0
  49. package/dist/lib/electronDeeplink.js +118 -0
  50. package/dist/lib/electronDeeplink.js.map +1 -0
  51. package/dist/lib/electronDiagnostics.d.ts +49 -0
  52. package/dist/lib/electronDiagnostics.d.ts.map +1 -0
  53. package/dist/lib/electronDiagnostics.js +195 -0
  54. package/dist/lib/electronDiagnostics.js.map +1 -0
  55. package/dist/lib/electronDialogMock.d.ts +51 -0
  56. package/dist/lib/electronDialogMock.d.ts.map +1 -0
  57. package/dist/lib/electronDialogMock.js +106 -0
  58. package/dist/lib/electronDialogMock.js.map +1 -0
  59. package/dist/lib/electronFuses.d.ts +13 -0
  60. package/dist/lib/electronFuses.d.ts.map +1 -0
  61. package/dist/lib/electronFuses.js +118 -0
  62. package/dist/lib/electronFuses.js.map +1 -0
  63. package/dist/lib/electronMainBridge.d.ts +28 -0
  64. package/dist/lib/electronMainBridge.d.ts.map +1 -0
  65. package/dist/lib/electronMainBridge.js +270 -0
  66. package/dist/lib/electronMainBridge.js.map +1 -0
  67. package/dist/lib/electronMainLogs.d.ts +81 -0
  68. package/dist/lib/electronMainLogs.d.ts.map +1 -0
  69. package/dist/lib/electronMainLogs.js +176 -0
  70. package/dist/lib/electronMainLogs.js.map +1 -0
  71. package/dist/lib/electronMock.d.ts +52 -0
  72. package/dist/lib/electronMock.d.ts.map +1 -0
  73. package/dist/lib/electronMock.js +96 -0
  74. package/dist/lib/electronMock.js.map +1 -0
  75. package/dist/lib/electronRemote.d.ts +106 -0
  76. package/dist/lib/electronRemote.d.ts.map +1 -0
  77. package/dist/lib/electronRemote.js +406 -0
  78. package/dist/lib/electronRemote.js.map +1 -0
  79. package/dist/lib/electronVersions.d.ts +34 -0
  80. package/dist/lib/electronVersions.d.ts.map +1 -0
  81. package/dist/lib/electronVersions.js +90 -0
  82. package/dist/lib/electronVersions.js.map +1 -0
  83. package/dist/lib/errors.d.ts +27 -0
  84. package/dist/lib/errors.d.ts.map +1 -1
  85. package/dist/lib/errors.js +27 -0
  86. package/dist/lib/errors.js.map +1 -1
  87. package/dist/lib/launchTarget.d.ts +30 -0
  88. package/dist/lib/launchTarget.d.ts.map +1 -0
  89. package/dist/lib/launchTarget.js +174 -0
  90. package/dist/lib/launchTarget.js.map +1 -0
  91. package/dist/lib/page.d.ts +1 -0
  92. package/dist/lib/page.d.ts.map +1 -1
  93. package/dist/lib/page.js +1 -1
  94. package/dist/lib/page.js.map +1 -1
  95. package/dist/lib/safari.d.ts +90 -0
  96. package/dist/lib/safari.d.ts.map +1 -0
  97. package/dist/lib/safari.js +136 -0
  98. package/dist/lib/safari.js.map +1 -0
  99. package/dist/lib/service.d.ts +21 -0
  100. package/dist/lib/service.d.ts.map +1 -1
  101. package/dist/lib/service.js +94 -6
  102. package/dist/lib/service.js.map +1 -1
  103. package/dist/lib/tracing.d.ts +1 -1
  104. package/dist/lib/tracing.d.ts.map +1 -1
  105. package/dist/lib/tracing.js.map +1 -1
  106. package/dist/lib/types.d.ts +41 -0
  107. package/dist/lib/types.d.ts.map +1 -1
  108. package/dist/lib/vibiumTrace.d.ts +1 -1
  109. package/dist/lib/vibiumTrace.d.ts.map +1 -1
  110. package/dist/lib/webelement.d.ts.map +1 -1
  111. package/dist/lib/webelement.js +118 -2
  112. package/dist/lib/webelement.js.map +1 -1
  113. package/docs/api-reference.md +39 -2
  114. package/docs/browser-api.md +7 -1
  115. package/docs/driver-configuration.md +54 -0
  116. package/docs/electron.md +419 -0
  117. package/docs/error-codes.md +5 -0
  118. package/docs/getting-started.md +30 -10
  119. package/docs/index.md +5 -1
  120. package/docs/public/examples/displayed.html +86 -0
  121. package/docs/recipes/electron-app-from-another-repo.md +136 -0
  122. package/docs/recipes/electron-deep-link.md +117 -0
  123. package/docs/recipes/electron-mock-apis.md +88 -0
  124. package/docs/recipes/electron-native-dialog.md +105 -0
  125. package/docs/recipes.md +28 -9
  126. package/docs/safari.md +118 -0
  127. package/docs/standards.md +13 -0
  128. package/docs/why-craftdriver.md +1 -1
  129. package/docs/zero-config-drivers.md +12 -0
  130. package/package.json +7 -1
  131. package/skills/craftdriver/SKILL.md +4 -1
  132. package/skills/craftdriver/cheatsheet.md +4 -1
@@ -1,7 +1,14 @@
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';
8
+ import { SafariService } from './safari.js';
4
9
  import { resolveBrowserBinaryPath } from './driverManager.js';
10
+ import { buildLaunchCapabilities } from './capabilities.js';
11
+ import { resolveLaunchTarget } from './launchTarget.js';
5
12
  import { By } from './by.js';
6
13
  import { until } from './wait.js';
7
14
  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 +25,7 @@ import { Mouse } from './mouse.js';
18
25
  import { ActionsBuilder } from './actions.js';
19
26
  import { BiDiSession, SessionStateManager, } from './bidi/index.js';
20
27
  import { Frame } from './frame.js';
21
- import { Page } from './page.js';
28
+ import { Page, isNoSuchWindowError } from './page.js';
22
29
  import { bidiWaitFor } from './loadState.js';
23
30
  import { BrowserContext } from './browserContext.js';
24
31
  import { Tracer } from './tracing.js';
@@ -63,6 +70,75 @@ export const devices = {
63
70
  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
71
  },
65
72
  };
73
+ /**
74
+ * Resolve a `goog:chromeOptions.mobileEmulation` payload from the public
75
+ * `MobileEmulation | DeviceName` option — a built-in device name maps to
76
+ * Chrome's `deviceName`, otherwise a custom `deviceMetrics`/`userAgent` config
77
+ * is built. Kept next to `devices`; `buildLaunchCapabilities` takes the result.
78
+ */
79
+ function resolveMobileEmulationConfig(mobileEmulation) {
80
+ const emulation = typeof mobileEmulation === 'string' ? devices[mobileEmulation] : mobileEmulation;
81
+ if ('deviceName' in emulation && emulation.deviceName) {
82
+ // Use Chrome's built-in device name
83
+ return { deviceName: emulation.deviceName };
84
+ }
85
+ // Build custom mobile emulation config
86
+ const mobileConfig = {};
87
+ if (emulation.deviceMetrics) {
88
+ mobileConfig.deviceMetrics = {
89
+ width: emulation.deviceMetrics.width,
90
+ height: emulation.deviceMetrics.height,
91
+ pixelRatio: emulation.deviceMetrics.pixelRatio,
92
+ mobile: emulation.deviceMetrics.mobile ?? true,
93
+ touch: emulation.deviceMetrics.touch ?? true,
94
+ };
95
+ }
96
+ if (emulation.userAgent) {
97
+ mobileConfig.userAgent = emulation.userAgent;
98
+ }
99
+ return mobileConfig;
100
+ }
101
+ async function resolveElectronAppBinaryPath(appBinaryPath) {
102
+ const resolved = path.resolve(appBinaryPath);
103
+ let stat;
104
+ try {
105
+ stat = await fs.stat(resolved);
106
+ }
107
+ catch (error) {
108
+ const code = error.code;
109
+ if (code === 'ENOENT') {
110
+ throw new Error(`electron.appBinaryPath "${appBinaryPath}" does not exist.`);
111
+ }
112
+ if (code === 'EACCES') {
113
+ throw new Error(`electron.appBinaryPath "${appBinaryPath}" is not accessible.`);
114
+ }
115
+ throw error;
116
+ }
117
+ if (!stat.isFile()) {
118
+ throw new Error(`electron.appBinaryPath "${appBinaryPath}" is not a file.`);
119
+ }
120
+ if (process.platform !== 'win32') {
121
+ try {
122
+ await fs.access(resolved, fsSync.constants.X_OK);
123
+ }
124
+ catch {
125
+ throw new Error(`electron.appBinaryPath "${appBinaryPath}" is not executable.`);
126
+ }
127
+ }
128
+ return resolved;
129
+ }
130
+ /**
131
+ * Pure predicate behind {@link Browser.waitForPage}'s matcher form: do a page's
132
+ * `url`/`title` satisfy the matcher? Only the fields named in `matcher` are checked,
133
+ * and every named field must match (string = substring, RegExp = test). Exported for
134
+ * unit testing.
135
+ */
136
+ export function matchPageFields(fields, matcher) {
137
+ const hit = (value, pattern) => pattern === undefined ||
138
+ (value !== undefined &&
139
+ (typeof pattern === 'string' ? value.includes(pattern) : pattern.test(value)));
140
+ return hit(fields.url, matcher.url) && hit(fields.title, matcher.title);
141
+ }
66
142
  // ---------------------------------------------------------------------------
67
143
  // BiDi value helpers — used only by Browser.evaluate()
68
144
  // ---------------------------------------------------------------------------
@@ -93,7 +169,10 @@ function serializeLocalValue(v) {
93
169
  if (typeof v === 'object') {
94
170
  return {
95
171
  type: 'object',
96
- value: Object.entries(v).map(([k, val]) => [k, serializeLocalValue(val)]),
172
+ value: Object.entries(v).map(([k, val]) => [
173
+ k,
174
+ serializeLocalValue(val),
175
+ ]),
97
176
  };
98
177
  }
99
178
  throw new CraftdriverError(ErrorCode.EVAL_BAD_ARG, `evaluate() argument of type "${typeof v}" is not JSON-serializable. ` +
@@ -101,11 +180,16 @@ function serializeLocalValue(v) {
101
180
  }
102
181
  function unwrapRemoteValue(v) {
103
182
  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);
183
+ case 'undefined':
184
+ return undefined;
185
+ case 'null':
186
+ return null;
187
+ case 'string':
188
+ return v.value;
189
+ case 'boolean':
190
+ return v.value;
191
+ case 'bigint':
192
+ return BigInt(v.value);
109
193
  case 'number': {
110
194
  if (v.value === 'NaN')
111
195
  return NaN;
@@ -117,13 +201,15 @@ function unwrapRemoteValue(v) {
117
201
  return -0;
118
202
  return v.value;
119
203
  }
120
- case 'array': return (v.value ?? []).map(unwrapRemoteValue);
204
+ case 'array':
205
+ return (v.value ?? []).map(unwrapRemoteValue);
121
206
  case 'object':
122
207
  return Object.fromEntries((v.value ?? []).map(([k, val]) => [
123
208
  typeof k === 'string' ? k : String(unwrapRemoteValue(k)),
124
209
  unwrapRemoteValue(val),
125
210
  ]));
126
- case 'date': return new Date(v.value);
211
+ case 'date':
212
+ return new Date(v.value);
127
213
  case 'function':
128
214
  throw new CraftdriverError(ErrorCode.EVAL_BAD_ARG, 'evaluate() returned a function, which is not JSON-serializable. ' +
129
215
  'Return a primitive, array, or plain object instead.', { detail: { returnedType: 'function' } });
@@ -190,11 +276,19 @@ export class Browser {
190
276
  _browserInitScriptIds = new Set();
191
277
  _downloadsDir;
192
278
  _driverService;
279
+ /** Main-process inspector endpoint, set when launched with `electron.mainProcess`. */
280
+ _electronInspect;
281
+ /** The launched Electron app's executable path (for deep-link routing on Windows). */
282
+ _electronAppBinaryPath;
283
+ _electron;
193
284
  _browserName = 'chrome';
194
285
  /** Active emulation overrides, re-applied to new top-level contexts. */
195
286
  _emulation = {};
196
287
  /** Mutable browser-level defaults. Use setDefaultTimeout() / setDefaultNavigationTimeout() to change. */
197
- defaults = { timeout: DEFAULT_ELEMENT_TIMEOUT_MS, navigationTimeout: DEFAULT_NAVIGATION_TIMEOUT_MS };
288
+ defaults = {
289
+ timeout: DEFAULT_ELEMENT_TIMEOUT_MS,
290
+ navigationTimeout: DEFAULT_NAVIGATION_TIMEOUT_MS,
291
+ };
198
292
  constructor(driver) {
199
293
  this.driver = driver;
200
294
  this.keyboard = new Keyboard(this.driver);
@@ -219,117 +313,158 @@ export class Browser {
219
313
  hasBrowserInitScripts = () => this._browserInitScriptIds.size > 0;
220
314
  hasDefaultNavigationInitScripts = () => this._defaultContext?._hasInitScriptsForNavigation() ?? this.hasBrowserInitScripts();
221
315
  static async launch(options = {}) {
222
- const name = options.browserName ?? 'chrome';
223
- const isFirefox = name === 'firefox';
316
+ // Normalize first: callers may be JavaScript or JSON-driven, so reject
317
+ // malformed/mixed targets before touching disk or starting a driver.
318
+ const target = resolveLaunchTarget(options);
319
+ const isElectron = target.kind === 'electron';
320
+ const name = target.browserName;
224
321
  const isChromeFamily = name === 'chrome' || name === 'chromium';
225
- if (options.mobileEmulation && !isChromeFamily) {
322
+ if (target.kind === 'browser' && options.mobileEmulation && !isChromeFamily) {
226
323
  throw new Error(`mobileEmulation is only supported on Chrome/Chromium (got "${name}"). ` +
227
324
  `Firefox does not expose a device-emulation API equivalent to goog:chromeOptions.mobileEmulation.`);
228
325
  }
229
- // Handle headless mode via env var
326
+ const bidiRequested = target.bidiRequested;
327
+ // Handle headless mode via env var (never applied to Electron — see capabilities.ts).
230
328
  const headlessEnv = process.env.HEADLESS;
231
329
  const isHeadless = headlessEnv === 'true' || headlessEnv === '1';
330
+ // Resolve Electron before creating session directories. A malformed or
331
+ // missing executable must never degrade into a regular Chrome launch.
332
+ const electronBinary = target.kind === 'electron'
333
+ ? await resolveElectronAppBinaryPath(target.appBinaryPath)
334
+ : undefined;
335
+ if (options.electronService !== undefined &&
336
+ !(options.electronService instanceof ElectronService)) {
337
+ throw new Error('electronService must be an ElectronService instance.');
338
+ }
339
+ if (target.kind === 'electron') {
340
+ writeElectronDebug({
341
+ appPath: electronBinary,
342
+ protocol: bidiRequested ? 'bidi' : 'classic',
343
+ platform: `${os.platform()}-${os.arch()}`,
344
+ });
345
+ }
232
346
  // Set up downloads directory
233
- const downloadsDir = options.downloadsDir ?? path.join(os.tmpdir(), 'craftdriver-downloads', `session-${Date.now()}-${Math.random().toString(36).slice(2)}`);
347
+ const downloadsDir = options.downloadsDir ??
348
+ path.join(os.tmpdir(), 'craftdriver-downloads', `session-${Date.now()}-${Math.random().toString(36).slice(2)}`);
234
349
  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
- }
350
+ // The binary that becomes goog:chromeOptions.binary / moz:firefoxOptions.binary.
351
+ // For Electron it's the app executable (explicit, required). For browsers
352
+ // it's the optional custom-binary resolution chain but see
353
+ // resolveBrowserBinaryPath's doc: some of the env vars in that chain aren't
354
+ // craftdriver-opt-in, they're ambient conventions other tools may have set.
355
+ // Safari has no custom-binary concept: browserPath/args are rejected
356
+ // upstream in resolveLaunchTarget(), so target.browserPath is
357
+ // always undefined here. Skip resolveBrowserBinaryPath() for Safari
358
+ // entirely rather than calling it with 'firefox' as a stand-in — it would
359
+ // still probe FIREFOX_BIN/SE_FIREFOX_PATH-style env vars that have
360
+ // nothing to do with Safari, which is misleading even though it happens
361
+ // to return undefined/harmless today.
362
+ const browserBinary = target.kind === 'electron'
363
+ ? electronBinary
364
+ : name === 'safari'
365
+ ? undefined
366
+ : resolveBrowserBinaryPath(isChromeFamily ? 'chrome' : 'firefox', target.browserPath);
367
+ // Main-process access (opt-in): launch the Electron app with a Node inspector
368
+ // on a free local port. chromedriver forwards this arg to the app, enabling
369
+ // its main-process inspector; browser.electron connects below for log capture
370
+ // and retries on first executeMain() if that eager connection was too early.
371
+ let electronInspect;
372
+ let launchArgs = target.args;
373
+ if (target.kind === 'electron' && target.mainProcess) {
374
+ const host = '127.0.0.1';
375
+ const port = await findFreePort();
376
+ electronInspect = { host, port };
377
+ launchArgs = [...(target.args ?? []), `--inspect=${host}:${port}`];
378
+ }
379
+ const caps = buildLaunchCapabilities({
380
+ browserName: name,
381
+ isElectron,
382
+ isHeadless,
383
+ bidiRequested,
384
+ browserBinary,
385
+ downloadsDir,
386
+ args: launchArgs,
387
+ mobileEmulation: target.kind === 'browser' && isChromeFamily && options.mobileEmulation
388
+ ? resolveMobileEmulationConfig(options.mobileEmulation)
389
+ : undefined,
390
+ });
311
391
  const builder = new Builder().forBrowser(name);
312
392
  let driverService;
313
- if (isChromeFamily) {
393
+ if (target.kind === 'electron') {
394
+ driverService =
395
+ options.electronService ??
396
+ new ElectronService({
397
+ chromedriverPath: target.chromedriverPath,
398
+ version: target.version,
399
+ // Lets the driver resolver read the Electron version from a packaged app's
400
+ // bundle when no explicit version/driver is given (production apps).
401
+ appBinaryPath: electronBinary,
402
+ });
403
+ builder.setChromeService(driverService);
404
+ }
405
+ else if (isChromeFamily) {
314
406
  // Only thread browserPath into a default ChromeService — a
315
407
  // caller-supplied one is expected to already pin what it needs (same
316
408
  // reasoning as explicit config always winning over auto-detection).
317
- driverService = options.chromeService ?? new ChromeService({ browserPath });
409
+ driverService = options.chromeService ?? new ChromeService({ browserPath: browserBinary });
318
410
  builder.setChromeService(driverService);
319
411
  }
412
+ else if (name === 'safari') {
413
+ // No browserPath threading (Safari has none — see the browserBinary
414
+ // resolution above) and no headless/args to pass through; SafariService
415
+ // resolves its own driver binary (never auto-downloaded).
416
+ driverService = options.safariService ?? new SafariService();
417
+ builder.setSafariService(driverService);
418
+ }
320
419
  else {
321
420
  driverService = options.firefoxService ?? new FirefoxService();
322
421
  builder.setFirefoxService(driverService);
323
422
  }
324
423
  builder.withCapabilities(caps);
325
- const driver = await builder.build();
424
+ let driver;
425
+ if (target.kind === 'electron') {
426
+ try {
427
+ driver = await builder.build();
428
+ }
429
+ catch (err) {
430
+ // Turn an opaque "Chrome instance exited" into a diagnosed, actionable
431
+ // error (macOS signing/Gatekeeper, Linux sandbox) with the chromedriver
432
+ // output tail attached. Non-app-exit failures pass through unchanged.
433
+ throw diagnoseElectronLaunchFailure(err, {
434
+ appBinaryPath: electronBinary,
435
+ args: target.args,
436
+ driverOutputTail: driverService.getOutputTail?.(),
437
+ });
438
+ }
439
+ }
440
+ else {
441
+ driver = await builder.build();
442
+ }
326
443
  const browser = new Browser(driver);
327
444
  browser._downloadsDir = downloadsDir;
328
445
  browser._driverService = driverService;
329
446
  browser._browserName = name;
447
+ browser._electronInspect = electronInspect;
448
+ browser._electronAppBinaryPath = electronBinary;
449
+ // With main-process access enabled, open the inspector bridge now so
450
+ // main-process console/error capture (browser.electron.mainLogs) starts at
451
+ // launch, mirroring always-on renderer log capture. Best-effort: a disabled
452
+ // EnableNodeCliInspectArguments fuse or an unreachable inspector must never
453
+ // fail renderer automation — executeMain still surfaces the actionable error
454
+ // on first use.
455
+ if (electronInspect) {
456
+ const remote = new ElectronRemote(electronInspect, { appBinaryPath: electronBinary });
457
+ browser._electron = remote;
458
+ await remote.connect().catch((err) => {
459
+ writeElectronDebug({
460
+ event: 'main-bridge-eager-connect-failed',
461
+ error: err instanceof Error ? err.message : String(err),
462
+ });
463
+ });
464
+ }
330
465
  // Initialize BiDi session if WebSocket URL available
331
466
  const wsUrl = driver.__wsUrl;
332
- if (wsUrl && options.enableBiDi !== false) {
467
+ if (wsUrl && bidiRequested) {
333
468
  await browser.initBiDi(wsUrl);
334
469
  }
335
470
  // Load session state if provided
@@ -380,6 +515,26 @@ export class Browser {
380
515
  isBiDiEnabled() {
381
516
  return this.bidiSession?.isConnected() ?? false;
382
517
  }
518
+ /**
519
+ * Guard for the BiDi-only surface below. On Safari (which never
520
+ * negotiates BiDi) this throws `CraftdriverError`/`UNSUPPORTED` with a
521
+ * stable `{ browserName, feature }` detail so callers can branch on `code`
522
+ * instead of parsing prose. On every other browser the behavior is
523
+ * unchanged: the exact plain-`Error` message passed by the call site.
524
+ *
525
+ * Deliberately scoped to Safari's guards only — the pre-existing
526
+ * plain-`Error` guards elsewhere are a separate follow-up, not silently
527
+ * folded into this helper.
528
+ */
529
+ requireBiDi(feature, chromeMessage) {
530
+ if (this.bidiSession?.isConnected())
531
+ return;
532
+ if (this._browserName === 'safari') {
533
+ throw new CraftdriverError(ErrorCode.UNSUPPORTED, `${feature} is not supported on Safari (no WebDriver BiDi). ` +
534
+ 'Apple does not document a WebDriver BiDi endpoint for Safari.', { detail: { browserName: 'safari', feature } });
535
+ }
536
+ throw new Error(chromeMessage);
537
+ }
383
538
  // === BiDi Feature Accessors ===
384
539
  /**
385
540
  * Network interception API (BiDi)
@@ -387,10 +542,8 @@ export class Browser {
387
542
  */
388
543
  get network() {
389
544
  if (!this._network) {
390
- if (!this.bidiSession?.isConnected()) {
391
- throw new Error('Network interception requires BiDi. ' +
392
- 'BiDi negotiation may have failed at launch — check browser logs for WebSocket errors.');
393
- }
545
+ this.requireBiDi('network', 'Network interception requires BiDi. ' +
546
+ 'BiDi negotiation may have failed at launch — check browser logs for WebSocket errors.');
394
547
  this._network = this.bidiSession.network;
395
548
  }
396
549
  return this._network;
@@ -400,10 +553,8 @@ export class Browser {
400
553
  */
401
554
  get logs() {
402
555
  if (!this._logs) {
403
- if (!this.bidiSession?.isConnected()) {
404
- throw new Error('Log monitoring requires BiDi. ' +
405
- 'BiDi negotiation may have failed at launch — check browser logs for WebSocket errors.');
406
- }
556
+ this.requireBiDi('logs', 'Log monitoring requires BiDi. ' +
557
+ 'BiDi negotiation may have failed at launch — check browser logs for WebSocket errors.');
407
558
  this._logs = this.bidiSession.logs;
408
559
  }
409
560
  return this._logs;
@@ -448,6 +599,26 @@ export class Browser {
448
599
  }
449
600
  return this._a11y;
450
601
  }
602
+ /**
603
+ * Electron **main-process** access. `browser.electron.executeMain(fn, ...args)`
604
+ * runs `fn(electron, ...args)` in the app's main process (full `electron`
605
+ * module), complementing the renderer automation on `Browser` itself.
606
+ *
607
+ * Requires launching with `electron: { mainProcess: true }`; otherwise
608
+ * `executeMain` throws `ELECTRON_MAIN_UNAVAILABLE`.
609
+ *
610
+ * ```ts
611
+ * const version = await browser.electron.executeMain((electron) => electron.app.getVersion());
612
+ * ```
613
+ */
614
+ get electron() {
615
+ if (!this._electron) {
616
+ this._electron = new ElectronRemote(this._electronInspect, {
617
+ appBinaryPath: this._electronAppBinaryPath,
618
+ });
619
+ }
620
+ return this._electron;
621
+ }
451
622
  /**
452
623
  * Save current session state (cookies + localStorage) to file
453
624
  */
@@ -600,11 +771,9 @@ export class Browser {
600
771
  * await browser.grantPermissions(['clipboard-read'], { origin: 'https://example.com' });
601
772
  */
602
773
  async grantPermissions(permissions, opts) {
603
- if (!this.bidiSession?.isConnected()) {
604
- throw new Error('grantPermissions() requires BiDi (enableBiDi: true). ' +
605
- 'Permission overrides use the W3C BiDi `permissions.setPermission` command, ' +
606
- 'which has no Classic-WebDriver equivalent.');
607
- }
774
+ this.requireBiDi('grantPermissions()', 'grantPermissions() requires BiDi (enableBiDi: true). ' +
775
+ 'Permission overrides use the W3C BiDi `permissions.setPermission` command, ' +
776
+ 'which has no Classic-WebDriver equivalent.');
608
777
  if (!Array.isArray(permissions) || permissions.length === 0) {
609
778
  throw new Error('grantPermissions: pass a non-empty array of permission names.');
610
779
  }
@@ -643,11 +812,9 @@ export class Browser {
643
812
  * await browser.setGeolocation(null); // clear
644
813
  */
645
814
  async setGeolocation(coords) {
646
- if (!this.bidiSession?.isConnected()) {
647
- throw new Error('setGeolocation() requires BiDi (enableBiDi: true). ' +
648
- 'Geolocation overrides use the W3C BiDi `emulation.setGeolocationOverride` ' +
649
- 'command, which has no Classic-WebDriver equivalent.');
650
- }
815
+ this.requireBiDi('setGeolocation()', 'setGeolocation() requires BiDi (enableBiDi: true). ' +
816
+ 'Geolocation overrides use the W3C BiDi `emulation.setGeolocationOverride` ' +
817
+ 'command, which has no Classic-WebDriver equivalent.');
651
818
  const conn = this.bidiSession.getConnection();
652
819
  // Apply across all top-level contexts in the default user context.
653
820
  const tree = await conn.send('browsingContext.getTree', { maxDepth: 0 });
@@ -660,8 +827,10 @@ export class Browser {
660
827
  else {
661
828
  if (typeof coords.latitude !== 'number' ||
662
829
  typeof coords.longitude !== 'number' ||
663
- coords.latitude < -90 || coords.latitude > 90 ||
664
- coords.longitude < -180 || coords.longitude > 180) {
830
+ coords.latitude < -90 ||
831
+ coords.latitude > 90 ||
832
+ coords.longitude < -180 ||
833
+ coords.longitude > 180) {
665
834
  throw new Error(`setGeolocation: invalid coordinates ${JSON.stringify(coords)}. ` +
666
835
  'latitude must be in [-90, 90] and longitude in [-180, 180].');
667
836
  }
@@ -711,12 +880,10 @@ export class Browser {
711
880
  async emulate(options) {
712
881
  if (!options || Object.keys(options).length === 0)
713
882
  return;
714
- if (!this.bidiSession?.isConnected()) {
715
- throw new Error('emulate() requires BiDi (enableBiDi: true). ' +
716
- 'Emulation overrides use the W3C BiDi `emulation.*` commands ' +
717
- '(and the BiDi+CDP bridge for media features and offline), ' +
718
- 'which have no Classic-WebDriver equivalent.');
719
- }
883
+ this.requireBiDi('emulate()', 'emulate() requires BiDi (enableBiDi: true). ' +
884
+ 'Emulation overrides use the W3C BiDi `emulation.*` commands ' +
885
+ '(and the BiDi+CDP bridge for media features and offline), ' +
886
+ 'which have no Classic-WebDriver equivalent.');
720
887
  // Validate Chromium-only fields up front so we fail before mutating state.
721
888
  const chromiumOnly = ['colorScheme', 'reducedMotion', 'forcedColors', 'offline'];
722
889
  const isFirefox = this._browserName === 'firefox';
@@ -836,7 +1003,7 @@ export class Browser {
836
1003
  }
837
1004
  // Classic fallback: best-effort 500ms settle
838
1005
  else {
839
- await new Promise(r => setTimeout(r, NETWORK_IDLE_SETTLE_MS));
1006
+ await new Promise((r) => setTimeout(r, NETWORK_IDLE_SETTLE_MS));
840
1007
  }
841
1008
  return;
842
1009
  }
@@ -881,7 +1048,7 @@ export class Browser {
881
1048
  const readyState = await this.driver.executeScript('return document.readyState', []);
882
1049
  if (readyState === 'complete' || (target === 'interactive' && readyState === 'interactive'))
883
1050
  return;
884
- await new Promise(r => setTimeout(r, STATE_POLL_INTERVAL_MS));
1051
+ await new Promise((r) => setTimeout(r, STATE_POLL_INTERVAL_MS));
885
1052
  }
886
1053
  throw new CraftdriverError(ErrorCode.TIMEOUT_WAITING_LOAD, `waitForLoadState('${state}') timed out after ${timeout}ms`, { detail: { state, timeout } });
887
1054
  }
@@ -901,6 +1068,10 @@ export class Browser {
901
1068
  // Whether this session had a live BiDi WebSocket. Only the Firefox+BiDi
902
1069
  // combination needs the port-release pause below; capture it before close().
903
1070
  const bidiWasActive = !!this.bidiSession;
1071
+ // Close the Electron main-process inspector bridge, if one was opened.
1072
+ if (this._electron) {
1073
+ await this._electron.close().catch(() => { });
1074
+ }
904
1075
  // Close BiDi connection first
905
1076
  if (this.bidiSession) {
906
1077
  await this.bidiSession.close().catch(() => { });
@@ -952,7 +1123,7 @@ export class Browser {
952
1123
  async startTrace(opts) {
953
1124
  if (!this.bidiSession?.isConnected()) {
954
1125
  throw new CraftdriverError(ErrorCode.UNSUPPORTED, 'startTrace() requires BiDi (enableBiDi: true). ' +
955
- 'Tracing relies on BiDi events; Classic WebDriver does not expose them.', { detail: { feature: 'startTrace' } });
1126
+ 'Tracing relies on BiDi events; Classic WebDriver does not expose them.', { detail: { browserName: this._browserName, feature: 'startTrace' } });
956
1127
  }
957
1128
  if (!this._tracer) {
958
1129
  this._tracer = new Tracer(this, this.bidiSession.getConnection(), this._browserName);
@@ -1015,7 +1186,7 @@ export class Browser {
1015
1186
  catch (err) {
1016
1187
  if (attempt < EVAL_REALM_RETRY_ATTEMPTS &&
1017
1188
  String(err?.message).includes('execution contexts cleared')) {
1018
- await new Promise(r => setTimeout(r, EVAL_REALM_RETRY_DELAY_MS));
1189
+ await new Promise((r) => setTimeout(r, EVAL_REALM_RETRY_DELAY_MS));
1019
1190
  continue;
1020
1191
  }
1021
1192
  throw err;
@@ -1049,14 +1220,10 @@ export class Browser {
1049
1220
  * ```
1050
1221
  */
1051
1222
  async addInitScript(fnOrSrc) {
1052
- if (!this.bidiSession?.isConnected()) {
1053
- throw new Error('addInitScript() requires BiDi. ' +
1054
- 'BiDi is enabled by default — check that your browser supports it.');
1055
- }
1223
+ this.requireBiDi('addInitScript()', 'addInitScript() requires BiDi. ' +
1224
+ 'BiDi is enabled by default — check that your browser supports it.');
1056
1225
  const conn = this.bidiSession.getConnection();
1057
- const fnSrc = typeof fnOrSrc === 'function'
1058
- ? fnOrSrc.toString()
1059
- : `() => { ${fnOrSrc} }`;
1226
+ const fnSrc = typeof fnOrSrc === 'function' ? fnOrSrc.toString() : `() => { ${fnOrSrc} }`;
1060
1227
  const result = await conn.send('script.addPreloadScript', {
1061
1228
  functionDeclaration: fnSrc,
1062
1229
  });
@@ -1076,10 +1243,10 @@ export class Browser {
1076
1243
  * Register **before** the action that triggers the request.
1077
1244
  */
1078
1245
  waitForRequest(pattern, opts) {
1079
- if (!this.bidiSession?.isConnected()) {
1080
- throw new Error('waitForRequest() requires BiDi. BiDi is enabled by default — check your browser supports it.');
1081
- }
1082
- return this.network.waitForRequest(pattern, { timeout: opts?.timeout ?? this.defaults.navigationTimeout });
1246
+ this.requireBiDi('waitForRequest()', 'waitForRequest() requires BiDi. BiDi is enabled by default — check your browser supports it.');
1247
+ return this.network.waitForRequest(pattern, {
1248
+ timeout: opts?.timeout ?? this.defaults.navigationTimeout,
1249
+ });
1083
1250
  }
1084
1251
  /**
1085
1252
  * Wait for the first completed response matching a URL glob or predicate.
@@ -1095,16 +1262,14 @@ export class Browser {
1095
1262
  * ```
1096
1263
  */
1097
1264
  waitForResponse(pattern, opts) {
1098
- if (!this.bidiSession?.isConnected()) {
1099
- throw new Error('waitForResponse() requires BiDi. BiDi is enabled by default \u2014 check your browser supports it.');
1100
- }
1101
- return this.network.waitForResponse(pattern, { timeout: opts?.timeout ?? this.defaults.navigationTimeout });
1265
+ this.requireBiDi('waitForResponse()', 'waitForResponse() requires BiDi. BiDi is enabled by default \u2014 check your browser supports it.');
1266
+ return this.network.waitForResponse(pattern, {
1267
+ timeout: opts?.timeout ?? this.defaults.navigationTimeout,
1268
+ });
1102
1269
  }
1103
1270
  on(event, listener) {
1104
- if (!this.bidiSession?.isConnected()) {
1105
- throw new Error(`browser.on('${event}') requires BiDi (enableBiDi: true). ` +
1106
- 'Network event listeners use the W3C BiDi `network` module, which has no Classic-WebDriver equivalent.');
1107
- }
1271
+ this.requireBiDi(`browser.on('${event}')`, `browser.on('${event}') requires BiDi (enableBiDi: true). ` +
1272
+ 'Network event listeners use the W3C BiDi `network` module, which has no Classic-WebDriver equivalent.');
1108
1273
  if (event === 'request') {
1109
1274
  return this.network.on('request', listener);
1110
1275
  }
@@ -1120,6 +1285,17 @@ export class Browser {
1120
1285
  * await dl.saveAs('/tmp/report.csv');
1121
1286
  */
1122
1287
  async waitForDownload(action, opts) {
1288
+ if (this._browserName === 'safari') {
1289
+ // safaridriver exposes no download-directory configuration craftdriver
1290
+ // can manage, so a browser download never lands in _downloadsDir — it
1291
+ // goes to the user's ~/Downloads. Without this guard, waitForDownload()
1292
+ // would watch an empty temp dir and fail with an opaque timeout instead
1293
+ // of a clear "unsupported" error. Craftdriver-managed downloads are out
1294
+ // of scope for Safari.
1295
+ throw new CraftdriverError(ErrorCode.UNSUPPORTED, 'waitForDownload() is not supported on Safari: safaridriver exposes no ' +
1296
+ 'download-directory configuration craftdriver can manage, so downloads ' +
1297
+ 'cannot be routed to or observed in a controlled directory.', { detail: { browserName: 'safari', feature: 'waitForDownload()' } });
1298
+ }
1123
1299
  const dir = this._downloadsDir;
1124
1300
  if (!dir) {
1125
1301
  throw new Error('waitForDownload() requires a downloads directory. Browser was not launched correctly.');
@@ -1132,7 +1308,7 @@ export class Browser {
1132
1308
  const deadline = Date.now() + timeout;
1133
1309
  while (Date.now() < deadline) {
1134
1310
  const after = fsSync.readdirSync(dir);
1135
- const newFiles = after.filter(f => !before.has(f) && !f.endsWith('.crdownload'));
1311
+ const newFiles = after.filter((f) => !before.has(f) && !f.endsWith('.crdownload'));
1136
1312
  if (newFiles.length > 0) {
1137
1313
  const filename = newFiles[0];
1138
1314
  const filePath = path.join(dir, filename);
@@ -1144,7 +1320,7 @@ export class Browser {
1144
1320
  },
1145
1321
  };
1146
1322
  }
1147
- await new Promise(r => setTimeout(r, STATE_POLL_INTERVAL_MS));
1323
+ await new Promise((r) => setTimeout(r, STATE_POLL_INTERVAL_MS));
1148
1324
  }
1149
1325
  throw new Error(`waitForDownload() timed out after ${timeout}ms — no file appeared in ${dir}`);
1150
1326
  }
@@ -1171,9 +1347,27 @@ export class Browser {
1171
1347
  await handler(dialog);
1172
1348
  });
1173
1349
  }
1174
- // Classic: no push events — callers must use the imperative API below
1350
+ if (this._browserName === 'safari') {
1351
+ // Safari is Classic-only and has no WebDriver BiDi push events, so there
1352
+ // is no event-driven way to detect a dialog opening. A polling fallback
1353
+ // (repeatedly calling getAlertText()/driver "no such alert" errors to
1354
+ // detect a dialog) was considered, but would need to be *proven*
1355
+ // reliable on real Safari before shipping. Rather than silently no-op (today's
1356
+ // Classic behavior for other browsers) or let waitForDialog() hang
1357
+ // until its own generic timeout — which would look like a missed
1358
+ // dialog rather than "this API isn't supported here" — fail
1359
+ // immediately with a clear, actionable error. Revisit if a polling
1360
+ // form is later measured reliable on real Safari.
1361
+ throw new CraftdriverError(ErrorCode.UNSUPPORTED, 'onDialog() / waitForDialog() are not supported on Safari: Safari has no ' +
1362
+ 'WebDriver BiDi, so there is no push-event mechanism for dialogs. ' +
1363
+ 'Use the imperative dialog API instead — acceptDialog(), dismissDialog(), ' +
1364
+ 'getDialogMessage() — which work in Classic mode.', { detail: { browserName: 'safari', feature: 'onDialog()' } });
1365
+ }
1366
+ // Classic (non-Safari): no push events — callers must use the imperative API below
1175
1367
  // Return a no-op unsubscribe
1176
- return () => { };
1368
+ return () => {
1369
+ /* no-op */
1370
+ };
1177
1371
  }
1178
1372
  /**
1179
1373
  * Accept the currently open dialog (OK / confirm).
@@ -1230,15 +1424,31 @@ export class Browser {
1230
1424
  waitForDialog(opts) {
1231
1425
  const timeout = opts?.timeout ?? this.defaults.timeout;
1232
1426
  return new Promise((resolve, reject) => {
1233
- const tid = timeout > 0
1234
- ? setTimeout(() => { off(); reject(new Error(`waitForDialog timed out after ${timeout}ms`)); }, timeout)
1235
- : undefined;
1236
- const off = this.onDialog((dialog) => {
1237
- if (tid !== undefined)
1238
- clearTimeout(tid);
1239
- off();
1240
- resolve(dialog);
1241
- });
1427
+ let tid;
1428
+ let off;
1429
+ // onDialog() throws synchronously on Safari (no BiDi push events for
1430
+ // dialogs see onDialog()'s comment). Register the handler before
1431
+ // scheduling the timeout so that throw rejects this promise
1432
+ // immediately instead of leaving a dangling timer whose callback would
1433
+ // later reference `off` before it was ever assigned.
1434
+ try {
1435
+ off = this.onDialog((dialog) => {
1436
+ if (tid !== undefined)
1437
+ clearTimeout(tid);
1438
+ off();
1439
+ resolve(dialog);
1440
+ });
1441
+ }
1442
+ catch (err) {
1443
+ reject(err);
1444
+ return;
1445
+ }
1446
+ if (timeout > 0) {
1447
+ tid = setTimeout(() => {
1448
+ off();
1449
+ reject(new Error(`waitForDialog timed out after ${timeout}ms`));
1450
+ }, timeout);
1451
+ }
1242
1452
  });
1243
1453
  }
1244
1454
  _buildDialog(params) {
@@ -1249,8 +1459,12 @@ export class Browser {
1249
1459
  type: () => _type,
1250
1460
  message: () => _message,
1251
1461
  defaultValue: () => _defaultValue,
1252
- accept: async (text) => { await this.acceptDialog(text); },
1253
- dismiss: async () => { await this.dismissDialog(); },
1462
+ accept: async (text) => {
1463
+ await this.acceptDialog(text);
1464
+ },
1465
+ dismiss: async () => {
1466
+ await this.dismissDialog();
1467
+ },
1254
1468
  };
1255
1469
  }
1256
1470
  // ─── Frames ───────────────────────────────────────────────────────────────
@@ -1276,7 +1490,9 @@ export class Browser {
1276
1490
  const topContextId = this.bidiSession.getContext();
1277
1491
  if (topContextId) {
1278
1492
  try {
1279
- const tree = await conn.send('browsingContext.getTree', { root: topContextId });
1493
+ const tree = await conn.send('browsingContext.getTree', {
1494
+ root: topContextId,
1495
+ });
1280
1496
  // The BiDi context for the iframe has the top-level context as ancestor.
1281
1497
  // We find the first child context (iframes appear as children).
1282
1498
  const iframeSrc = await el.getAttribute('src');
@@ -1304,7 +1520,9 @@ export class Browser {
1304
1520
  let tree;
1305
1521
  if (conn && topContextId) {
1306
1522
  try {
1307
- tree = await conn.send('browsingContext.getTree', { root: topContextId });
1523
+ tree = await conn.send('browsingContext.getTree', {
1524
+ root: topContextId,
1525
+ });
1308
1526
  }
1309
1527
  catch {
1310
1528
  tree = undefined;
@@ -1328,7 +1546,9 @@ export class Browser {
1328
1546
  async pages() {
1329
1547
  if (this.bidiSession?.isConnected()) {
1330
1548
  const conn = this.bidiSession.getConnection();
1331
- const tree = await conn.send('browsingContext.getTree', { maxDepth: 0 });
1549
+ const tree = await conn.send('browsingContext.getTree', {
1550
+ maxDepth: 0,
1551
+ });
1332
1552
  return (tree.contexts ?? []).map((ctx) => new Page(this.driver, ctx.context, this.getDefaultTimeout, conn, this._wrapContext(ctx.userContext ?? 'default')));
1333
1553
  }
1334
1554
  // Classic fallback: use window handles
@@ -1351,10 +1571,8 @@ export class Browser {
1351
1571
  * await page.waitForLoadState();
1352
1572
  */
1353
1573
  async openPage(opts) {
1354
- if (!this.bidiSession?.isConnected()) {
1355
- throw new Error('openPage() requires BiDi (enableBiDi: true). ' +
1356
- 'WebDriver Classic cannot create top-level browsing contexts.');
1357
- }
1574
+ this.requireBiDi('openPage()', 'openPage() requires BiDi (enableBiDi: true). ' +
1575
+ 'WebDriver Classic cannot create top-level browsing contexts.');
1358
1576
  const conn = this.bidiSession.getConnection();
1359
1577
  const created = await conn.send('browsingContext.create', {
1360
1578
  type: opts?.type ?? 'tab',
@@ -1365,16 +1583,11 @@ export class Browser {
1365
1583
  }
1366
1584
  return page;
1367
1585
  }
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) {
1586
+ async waitForPage(actionOrMatcher, opts) {
1587
+ if (typeof actionOrMatcher !== 'function') {
1588
+ return this._waitForMatchingPage(actionOrMatcher, opts);
1589
+ }
1590
+ const action = actionOrMatcher;
1378
1591
  const timeout = opts?.timeout ?? this.defaults.navigationTimeout;
1379
1592
  if (this.bidiSession?.isConnected()) {
1380
1593
  const conn = this.bidiSession.getConnection();
@@ -1407,15 +1620,60 @@ export class Browser {
1407
1620
  const deadline = Date.now() + timeout;
1408
1621
  while (Date.now() < deadline) {
1409
1622
  const after = await this.driver.getWindowHandles();
1410
- const newHandles = after.filter(h => !before.has(h));
1623
+ const newHandles = after.filter((h) => !before.has(h));
1411
1624
  if (newHandles.length > 0) {
1412
1625
  const handle = newHandles[0];
1413
1626
  return new Page(this.driver, handle, this.getDefaultTimeout);
1414
1627
  }
1415
- await new Promise(r => setTimeout(r, STATE_POLL_INTERVAL_MS));
1628
+ await new Promise((r) => setTimeout(r, STATE_POLL_INTERVAL_MS));
1416
1629
  }
1417
1630
  throw new Error(`waitForPage() timed out after ${timeout}ms — no new tab or popup appeared`);
1418
1631
  }
1632
+ /** Poll open top-level pages until one matches `matcher`; see {@link waitForPage}. */
1633
+ async _waitForMatchingPage(matcher, opts) {
1634
+ if (matcher.url === undefined && matcher.title === undefined) {
1635
+ throw new Error('waitForPage(matcher): provide at least one of { url, title }.');
1636
+ }
1637
+ const timeout = opts?.timeout ?? this.defaults.navigationTimeout;
1638
+ const deadline = Date.now() + timeout;
1639
+ const seen = new Set();
1640
+ for (;;) {
1641
+ for (const page of await this.pages()) {
1642
+ try {
1643
+ if (await this._pageMatches(page, matcher, seen)) {
1644
+ // Classic: make the matched window current so browser.find(...) targets
1645
+ // it too (BiDi drives through the returned Page's own context).
1646
+ if (!this.bidiSession?.isConnected()) {
1647
+ await this.driver.switchToWindow(page.id()).catch(() => { });
1648
+ }
1649
+ return page;
1650
+ }
1651
+ }
1652
+ catch (err) {
1653
+ // A window that closed mid-check (e.g. the splash) — skip it, keep looking.
1654
+ if (!isNoSuchWindowError(err))
1655
+ throw err;
1656
+ }
1657
+ }
1658
+ if (Date.now() >= deadline) {
1659
+ const label = [
1660
+ matcher.title !== undefined ? `title ${String(matcher.title)}` : undefined,
1661
+ matcher.url !== undefined ? `url ${String(matcher.url)}` : undefined,
1662
+ ]
1663
+ .filter(Boolean)
1664
+ .join(' and ');
1665
+ throw new Error(`waitForPage(matcher) timed out after ${timeout}ms — no window matched ${label}. ` +
1666
+ `Saw: ${[...seen].join(' | ') || '(no readable windows)'}.`);
1667
+ }
1668
+ await new Promise((r) => setTimeout(r, STATE_POLL_INTERVAL_MS));
1669
+ }
1670
+ }
1671
+ async _pageMatches(page, matcher, seen) {
1672
+ const url = matcher.url !== undefined ? await page.url() : undefined;
1673
+ const title = matcher.title !== undefined ? await page.title() : undefined;
1674
+ seen.add([title !== undefined ? `"${title}"` : '', url ?? ''].filter(Boolean).join(' '));
1675
+ return matchPageFields({ url, title }, matcher);
1676
+ }
1419
1677
  // ─── User contexts (isolated profiles, BiDi-only) ────────────────────────
1420
1678
  /**
1421
1679
  * Create a new isolated user context (an "incognito profile"). Each
@@ -1472,14 +1730,14 @@ export class Browser {
1472
1730
  this._contextsById.set(id, ctx);
1473
1731
  // Evict on close so long-running suites that create many contexts don't
1474
1732
  // accumulate dead wrappers.
1475
- ctx.on('close', () => { this._contextsById.delete(id); });
1733
+ ctx.on('close', () => {
1734
+ this._contextsById.delete(id);
1735
+ });
1476
1736
  return ctx;
1477
1737
  }
1478
1738
  async newContext(opts) {
1479
- if (!this.bidiSession?.isConnected()) {
1480
- throw new Error('newContext() requires BiDi (enableBiDi: true). ' +
1481
- 'WebDriver Classic has no concept of user contexts.');
1482
- }
1739
+ this.requireBiDi('newContext()', 'newContext() requires BiDi (enableBiDi: true). ' +
1740
+ 'WebDriver Classic has no concept of user contexts.');
1483
1741
  const conn = this.bidiSession.getConnection();
1484
1742
  const created = await conn.send('browser.createUserContext', {});
1485
1743
  const ctx = this._wrapContext(created.userContext, {
@@ -1489,7 +1747,7 @@ export class Browser {
1489
1747
  if (opts?.storageState !== undefined) {
1490
1748
  await ctx.loadStorageState(opts.storageState);
1491
1749
  }
1492
- // Apply Milestone-C emulation options. Each setter is `userContexts`-scoped,
1750
+ // Apply identity & device emulation options. Each setter is `userContexts`-scoped,
1493
1751
  // so future pages in this context inherit automatically.
1494
1752
  if (opts?.locale !== undefined)
1495
1753
  await ctx.setLocale(opts.locale);
@@ -1505,11 +1763,9 @@ export class Browser {
1505
1763
  * Maps to BiDi `browser.getUserContexts`. **BiDi-only.**
1506
1764
  */
1507
1765
  async contexts() {
1508
- if (!this.bidiSession?.isConnected()) {
1509
- throw new Error('contexts() requires BiDi (enableBiDi: true). ' +
1510
- 'WebDriver Classic has no concept of user contexts. ' +
1511
- 'Use browser.pages() to list tabs instead.');
1512
- }
1766
+ this.requireBiDi('contexts()', 'contexts() requires BiDi (enableBiDi: true). ' +
1767
+ 'WebDriver Classic has no concept of user contexts. ' +
1768
+ 'Use browser.pages() to list tabs instead.');
1513
1769
  const conn = this.bidiSession.getConnection();
1514
1770
  const result = await conn.send('browser.getUserContexts', {});
1515
1771
  return (result.userContexts ?? []).map((uc) => this._wrapContext(uc.userContext));
@@ -1520,10 +1776,8 @@ export class Browser {
1520
1776
  * to this context. **BiDi-only.**
1521
1777
  */
1522
1778
  get defaultContext() {
1523
- if (!this.bidiSession?.isConnected()) {
1524
- throw new Error('defaultContext requires BiDi (enableBiDi: true). ' +
1525
- 'WebDriver Classic has no concept of user contexts.');
1526
- }
1779
+ this.requireBiDi('defaultContext', 'defaultContext requires BiDi (enableBiDi: true). ' +
1780
+ 'WebDriver Classic has no concept of user contexts.');
1527
1781
  if (this._defaultContext)
1528
1782
  return this._defaultContext;
1529
1783
  this._defaultContext = this._wrapContext('default');
@@ -1561,7 +1815,9 @@ export class Browser {
1561
1815
  // would break that contract if a future refactor made the branch reachable.
1562
1816
  await conn
1563
1817
  .subscribe(['browsingContext.contextCreated', 'browsingContext.contextDestroyed'])
1564
- .catch(() => { });
1818
+ .catch(() => {
1819
+ /* already subscribed or unsupported; fallback sync will cover us */
1820
+ });
1565
1821
  }
1566
1822
  const onCreated = (params) => {
1567
1823
  if (params.parent)
@@ -1596,7 +1852,9 @@ export class Browser {
1596
1852
  }
1597
1853
  async _refreshTopLevelContextCache(conn) {
1598
1854
  const version = this._topLevelContextCacheVersion;
1599
- const tree = await conn.send('browsingContext.getTree', { maxDepth: 0 });
1855
+ const tree = await conn.send('browsingContext.getTree', {
1856
+ maxDepth: 0,
1857
+ });
1600
1858
  if (this._topLevelContextCacheVersion === version) {
1601
1859
  this._topLevelContextUserContexts.clear();
1602
1860
  }
@@ -1653,8 +1911,30 @@ export class Browser {
1653
1911
  }
1654
1912
  return new Page(this.driver, fallback, this.getDefaultTimeout, conn, this.defaultContext);
1655
1913
  }
1656
- // Classic mode: no user-context concept; just wrap the current window.
1657
- const handle = await this.driver.getCurrentWindowHandle();
1914
+ // Classic mode: wrap the current window. If it was closed (e.g. a splash that
1915
+ // replaced itself), re-point to the sole remaining top-level window when there's
1916
+ // exactly one — unambiguous — and otherwise ask the caller to select explicitly,
1917
+ // rather than guessing among several or throwing an opaque "no such window".
1918
+ let handle;
1919
+ try {
1920
+ handle = await this.driver.getCurrentWindowHandle();
1921
+ }
1922
+ catch (err) {
1923
+ if (!isNoSuchWindowError(err))
1924
+ throw err;
1925
+ }
1926
+ const handles = await this.driver.getWindowHandles();
1927
+ if (handle === undefined || !handles.includes(handle)) {
1928
+ if (handles.length === 0) {
1929
+ throw new Error('activePage(): all windows are closed.');
1930
+ }
1931
+ if (handles.length > 1) {
1932
+ throw new Error('activePage(): the current window was closed and several windows are open. ' +
1933
+ 'Select one with browser.waitForPage({ title } | { url }) or browser.pages().');
1934
+ }
1935
+ handle = handles[0];
1936
+ await this.driver.switchToWindow(handle).catch(() => { });
1937
+ }
1658
1938
  return new Page(this.driver, handle, this.getDefaultTimeout);
1659
1939
  }
1660
1940
  async click(selector, opts) {
@@ -1695,12 +1975,31 @@ export class Browser {
1695
1975
  }
1696
1976
  gesture = {
1697
1977
  swipe: async ({ from, to, durationMs = 300, }) => {
1978
+ this.rejectTouchActionsOnSafari('gesture.swipe()');
1698
1979
  await this.driver.performTouchSwipe(from, to, durationMs);
1699
1980
  },
1700
1981
  pinch: async ({ center, scale = 0.5, distance = 100, durationMs = 250, }) => {
1982
+ this.rejectTouchActionsOnSafari('gesture.pinch()');
1701
1983
  await this.driver.performTouchPinch(center, scale, distance, durationMs);
1702
1984
  },
1703
1985
  };
1986
+ /**
1987
+ * `gesture.swipe()`/`gesture.pinch()` send W3C actions with a `pointer`
1988
+ * input source whose `parameters.pointerType` is `'touch'`. Desktop Safari
1989
+ * has no touchscreen and Apple's Safari 12+ WebDriver command table gives
1990
+ * no indication `safaridriver` synthesizes touch-type pointer input —
1991
+ * unlike `pointerType: 'mouse'`, which is exercised by every other pointer
1992
+ * helper and is squarely in scope. Rather than let the action silently
1993
+ * no-op (or behave unpredictably) on real Safari, fail loudly before
1994
+ * sending it.
1995
+ */
1996
+ rejectTouchActionsOnSafari(feature) {
1997
+ if (this._browserName !== 'safari')
1998
+ return;
1999
+ throw new CraftdriverError(ErrorCode.UNSUPPORTED, `${feature} sends a touch-type pointer action, which desktop Safari does not support. ` +
2000
+ 'Safari has no touchscreen and no documented touch-pointer WebDriver behavior; ' +
2001
+ 'this is desktop browser automation, not iPhone/iPad Safari coverage.', { detail: { browserName: 'safari', feature } });
2002
+ }
1704
2003
  /**
1705
2004
  * Capture a screenshot of the active page (viewport by default), the
1706
2005
  * full scrollable document (`fullPage: true`), or an element matching
@@ -1733,11 +2032,9 @@ export class Browser {
1733
2032
  buf = Buffer.from(b64, 'base64');
1734
2033
  }
1735
2034
  else if (opts?.fullPage) {
1736
- if (!this.bidiSession?.isConnected()) {
1737
- throw new Error('screenshot({ fullPage: true }) requires BiDi (enableBiDi: true). ' +
1738
- 'Full-page screenshots use the W3C BiDi `browsingContext.captureScreenshot` ' +
1739
- 'command with `origin: "document"`, which has no Classic-WebDriver equivalent.');
1740
- }
2035
+ this.requireBiDi('screenshot({ fullPage: true })', 'screenshot({ fullPage: true }) requires BiDi (enableBiDi: true). ' +
2036
+ 'Full-page screenshots use the W3C BiDi `browsingContext.captureScreenshot` ' +
2037
+ 'command with `origin: "document"`, which has no Classic-WebDriver equivalent.');
1741
2038
  const conn = this.bidiSession.getConnection();
1742
2039
  const page = await this.activePage();
1743
2040
  const result = await conn.send('browsingContext.captureScreenshot', {
@@ -1807,19 +2104,27 @@ export class Browser {
1807
2104
  static Key = Key;
1808
2105
  async waitForVisible(selector, opts) {
1809
2106
  const by = typeof selector === 'string' ? By.css(selector) : selector;
1810
- await this.driver.wait(until.elementIsVisible(by), { timeout: opts?.timeout ?? this.defaults.timeout });
2107
+ await this.driver.wait(until.elementIsVisible(by), {
2108
+ timeout: opts?.timeout ?? this.defaults.timeout,
2109
+ });
1811
2110
  }
1812
2111
  async waitForHidden(selector, opts) {
1813
2112
  const by = typeof selector === 'string' ? By.css(selector) : selector;
1814
- await this.driver.wait(until.elementIsNotVisible(by), { timeout: opts?.timeout ?? this.defaults.timeout });
2113
+ await this.driver.wait(until.elementIsNotVisible(by), {
2114
+ timeout: opts?.timeout ?? this.defaults.timeout,
2115
+ });
1815
2116
  }
1816
2117
  async waitForAttached(selector, opts) {
1817
2118
  const by = typeof selector === 'string' ? By.css(selector) : selector;
1818
- await this.driver.wait(until.elementExists(by), { timeout: opts?.timeout ?? this.defaults.timeout });
2119
+ await this.driver.wait(until.elementExists(by), {
2120
+ timeout: opts?.timeout ?? this.defaults.timeout,
2121
+ });
1819
2122
  }
1820
2123
  async waitForDetached(selector, opts) {
1821
2124
  const by = typeof selector === 'string' ? By.css(selector) : selector;
1822
- await this.driver.wait(until.elementNotExists(by), { timeout: opts?.timeout ?? this.defaults.timeout });
2125
+ await this.driver.wait(until.elementNotExists(by), {
2126
+ timeout: opts?.timeout ?? this.defaults.timeout,
2127
+ });
1823
2128
  }
1824
2129
  /**
1825
2130
  * Wait for an element to reach the given state.
@@ -1833,10 +2138,14 @@ export class Browser {
1833
2138
  */
1834
2139
  async waitFor(selector, opts) {
1835
2140
  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);
2141
+ case 'visible':
2142
+ return this.waitForVisible(selector, opts);
2143
+ case 'hidden':
2144
+ return this.waitForHidden(selector, opts);
2145
+ case 'attached':
2146
+ return this.waitForAttached(selector, opts);
2147
+ case 'detached':
2148
+ return this.waitForDetached(selector, opts);
1840
2149
  }
1841
2150
  }
1842
2151
  actions() {