craftdriver 1.4.0 → 1.6.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 (87) hide show
  1. package/CHANGELOG.md +17 -3
  2. package/README.md +6 -2
  3. package/dist/cli/daemon.d.ts.map +1 -1
  4. package/dist/cli/daemon.js +4 -0
  5. package/dist/cli/daemon.js.map +1 -1
  6. package/dist/cli/mcp/server.d.ts.map +1 -1
  7. package/dist/cli/mcp/server.js +4 -0
  8. package/dist/cli/mcp/server.js.map +1 -1
  9. package/dist/cli/parseArgs.d.ts +2 -2
  10. package/dist/cli/parseArgs.d.ts.map +1 -1
  11. package/dist/cli/parseArgs.js +1 -1
  12. package/dist/index.d.ts +2 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +1 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/lib/bidi/storage.d.ts +13 -0
  17. package/dist/lib/bidi/storage.d.ts.map +1 -1
  18. package/dist/lib/bidi/storage.js +81 -32
  19. package/dist/lib/bidi/storage.js.map +1 -1
  20. package/dist/lib/browser.d.ts +48 -3
  21. package/dist/lib/browser.d.ts.map +1 -1
  22. package/dist/lib/browser.js +212 -86
  23. package/dist/lib/browser.js.map +1 -1
  24. package/dist/lib/browserContext.d.ts +2 -2
  25. package/dist/lib/browserContext.d.ts.map +1 -1
  26. package/dist/lib/browserContext.js +1 -1
  27. package/dist/lib/builder.d.ts +16 -2
  28. package/dist/lib/builder.d.ts.map +1 -1
  29. package/dist/lib/builder.js +46 -2
  30. package/dist/lib/builder.js.map +1 -1
  31. package/dist/lib/capabilities.d.ts +20 -2
  32. package/dist/lib/capabilities.d.ts.map +1 -1
  33. package/dist/lib/capabilities.js +109 -48
  34. package/dist/lib/capabilities.js.map +1 -1
  35. package/dist/lib/driver.d.ts +33 -2
  36. package/dist/lib/driver.d.ts.map +1 -1
  37. package/dist/lib/driver.js +91 -2
  38. package/dist/lib/driver.js.map +1 -1
  39. package/dist/lib/driverManager.d.ts +28 -0
  40. package/dist/lib/driverManager.d.ts.map +1 -1
  41. package/dist/lib/driverManager.js +60 -0
  42. package/dist/lib/driverManager.js.map +1 -1
  43. package/dist/lib/elementHandle.d.ts.map +1 -1
  44. package/dist/lib/elementHandle.js +34 -0
  45. package/dist/lib/elementHandle.js.map +1 -1
  46. package/dist/lib/http.d.ts.map +1 -1
  47. package/dist/lib/http.js +31 -10
  48. package/dist/lib/http.js.map +1 -1
  49. package/dist/lib/launchTarget.d.ts +22 -3
  50. package/dist/lib/launchTarget.d.ts.map +1 -1
  51. package/dist/lib/launchTarget.js +159 -9
  52. package/dist/lib/launchTarget.js.map +1 -1
  53. package/dist/lib/remote.d.ts +55 -0
  54. package/dist/lib/remote.d.ts.map +1 -0
  55. package/dist/lib/remote.js +127 -0
  56. package/dist/lib/remote.js.map +1 -0
  57. package/dist/lib/safari.d.ts +90 -0
  58. package/dist/lib/safari.d.ts.map +1 -0
  59. package/dist/lib/safari.js +136 -0
  60. package/dist/lib/safari.js.map +1 -0
  61. package/dist/lib/tracing.d.ts +1 -1
  62. package/dist/lib/tracing.d.ts.map +1 -1
  63. package/dist/lib/tracing.js.map +1 -1
  64. package/dist/lib/types.d.ts +63 -0
  65. package/dist/lib/types.d.ts.map +1 -1
  66. package/dist/lib/vibiumTrace.d.ts +1 -1
  67. package/dist/lib/vibiumTrace.d.ts.map +1 -1
  68. package/dist/lib/webelement.d.ts.map +1 -1
  69. package/dist/lib/webelement.js +118 -2
  70. package/dist/lib/webelement.js.map +1 -1
  71. package/docs/api-reference.md +6 -2
  72. package/docs/browser-api.md +5 -4
  73. package/docs/driver-configuration.md +60 -0
  74. package/docs/getting-started.md +30 -10
  75. package/docs/index.md +2 -1
  76. package/docs/keyboard-mouse.md +7 -5
  77. package/docs/public/examples/displayed.html +86 -0
  78. package/docs/recipes/run-on-browserstack.md +265 -0
  79. package/docs/recipes.md +15 -1
  80. package/docs/remote-webdriver.md +308 -0
  81. package/docs/safari.md +118 -0
  82. package/docs/standards.md +18 -0
  83. package/docs/why-craftdriver.md +3 -1
  84. package/docs/zero-config-drivers.md +12 -0
  85. package/package.json +5 -1
  86. package/skills/craftdriver/SKILL.md +4 -1
  87. package/skills/craftdriver/cheatsheet.md +4 -1
@@ -5,9 +5,11 @@ import { diagnoseElectronLaunchFailure } from './electronDiagnostics.js';
5
5
  import { ElectronRemote } from './electronRemote.js';
6
6
  import { findFreePort } from './service.js';
7
7
  import { FirefoxService } from './firefox.js';
8
+ import { SafariService } from './safari.js';
8
9
  import { resolveBrowserBinaryPath } from './driverManager.js';
9
10
  import { buildLaunchCapabilities } from './capabilities.js';
10
11
  import { resolveLaunchTarget } from './launchTarget.js';
12
+ import { parseRemoteEndpoint, buildRemoteCapabilities, redactUrlForLog } from './remote.js';
11
13
  import { By } from './by.js';
12
14
  import { until } from './wait.js';
13
15
  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';
@@ -280,7 +282,12 @@ export class Browser {
280
282
  /** The launched Electron app's executable path (for deep-link routing on Windows). */
281
283
  _electronAppBinaryPath;
282
284
  _electron;
285
+ /** Requested local name or provider-facing remote name. */
283
286
  _browserName = 'chrome';
287
+ /** Lowercase engine family for engine-specific branches (Edge → `chrome`); see `normalizeEngine`. */
288
+ _engine = 'chrome';
289
+ /** Set for sessions created via `remote` — no local driver process/filesystem ever backs these. */
290
+ _isRemote = false;
284
291
  /** Active emulation overrides, re-applied to new top-level contexts. */
285
292
  _emulation = {};
286
293
  /** Mutable browser-level defaults. Use setDefaultTimeout() / setDefaultNavigationTimeout() to change. */
@@ -315,6 +322,9 @@ export class Browser {
315
322
  // Normalize first: callers may be JavaScript or JSON-driven, so reject
316
323
  // malformed/mixed targets before touching disk or starting a driver.
317
324
  const target = resolveLaunchTarget(options);
325
+ if (target.kind === 'remote') {
326
+ return await Browser.launchRemote(target, options);
327
+ }
318
328
  const isElectron = target.kind === 'electron';
319
329
  const name = target.browserName;
320
330
  const isChromeFamily = name === 'chrome' || name === 'chromium';
@@ -351,9 +361,18 @@ export class Browser {
351
361
  // it's the optional custom-binary resolution chain — but see
352
362
  // resolveBrowserBinaryPath's doc: some of the env vars in that chain aren't
353
363
  // craftdriver-opt-in, they're ambient conventions other tools may have set.
364
+ // Safari has no custom-binary concept: browserPath/args are rejected
365
+ // upstream in resolveLaunchTarget(), so target.browserPath is
366
+ // always undefined here. Skip resolveBrowserBinaryPath() for Safari
367
+ // entirely rather than calling it with 'firefox' as a stand-in — it would
368
+ // still probe FIREFOX_BIN/SE_FIREFOX_PATH-style env vars that have
369
+ // nothing to do with Safari, which is misleading even though it happens
370
+ // to return undefined/harmless today.
354
371
  const browserBinary = target.kind === 'electron'
355
372
  ? electronBinary
356
- : resolveBrowserBinaryPath(isChromeFamily ? 'chrome' : 'firefox', target.browserPath);
373
+ : name === 'safari'
374
+ ? undefined
375
+ : resolveBrowserBinaryPath(isChromeFamily ? 'chrome' : 'firefox', target.browserPath);
357
376
  // Main-process access (opt-in): launch the Electron app with a Node inspector
358
377
  // on a free local port. chromedriver forwards this arg to the app, enabling
359
378
  // its main-process inspector; browser.electron connects below for log capture
@@ -399,6 +418,13 @@ export class Browser {
399
418
  driverService = options.chromeService ?? new ChromeService({ browserPath: browserBinary });
400
419
  builder.setChromeService(driverService);
401
420
  }
421
+ else if (name === 'safari') {
422
+ // No browserPath threading (Safari has none — see the browserBinary
423
+ // resolution above) and no headless/args to pass through; SafariService
424
+ // resolves its own driver binary (never auto-downloaded).
425
+ driverService = options.safariService ?? new SafariService();
426
+ builder.setSafariService(driverService);
427
+ }
402
428
  else {
403
429
  driverService = options.firefoxService ?? new FirefoxService();
404
430
  builder.setFirefoxService(driverService);
@@ -427,6 +453,9 @@ export class Browser {
427
453
  browser._downloadsDir = downloadsDir;
428
454
  browser._driverService = driverService;
429
455
  browser._browserName = name;
456
+ // Local names are already the normalized `SupportedBrowserName`, so engine
457
+ // and provider-facing name coincide here (they diverge only for remote).
458
+ browser._engine = name;
430
459
  browser._electronInspect = electronInspect;
431
460
  browser._electronAppBinaryPath = electronBinary;
432
461
  // With main-process access enabled, open the inspector bridge now so
@@ -445,14 +474,58 @@ export class Browser {
445
474
  });
446
475
  });
447
476
  }
448
- // Initialize BiDi session if WebSocket URL available
449
- const wsUrl = driver.__wsUrl;
450
- if (wsUrl && bidiRequested) {
451
- await browser.initBiDi(wsUrl);
477
+ try {
478
+ // Initialize BiDi session if WebSocket URL available
479
+ const wsUrl = driver.__wsUrl;
480
+ if (wsUrl && bidiRequested) {
481
+ await browser.initBiDi(wsUrl);
482
+ }
483
+ // Load session state if provided
484
+ if (options.storageState) {
485
+ await browser.loadState(options.storageState);
486
+ }
487
+ }
488
+ catch (err) {
489
+ // The driver session (and, for Electron, the app) already exists — don't
490
+ // leak the process if post-launch initialization fails. Tear it down,
491
+ // then surface the original error.
492
+ await browser.quit().catch(() => { });
493
+ throw err;
494
+ }
495
+ return browser;
496
+ }
497
+ /** Create a remote session without local driver or filesystem setup. */
498
+ static async launchRemote(target, options) {
499
+ const { endpoint, sessionTimeoutMs } = parseRemoteEndpoint(target.remote);
500
+ const caps = buildRemoteCapabilities({
501
+ browserName: target.browserName,
502
+ bidiRequested: target.bidiRequested,
503
+ userCapabilities: target.remote.capabilities,
504
+ });
505
+ const builder = new Builder()
506
+ .forBrowser(target.browserName)
507
+ .usingServer(endpoint, { sessionTimeoutMs })
508
+ .withCapabilities(caps);
509
+ const driver = await builder.build();
510
+ const browser = new Browser(driver);
511
+ browser._isRemote = true;
512
+ browser._browserName = target.browserName;
513
+ browser._engine = target.engine;
514
+ try {
515
+ const wsUrl = driver.__wsUrl;
516
+ if (wsUrl && target.bidiRequested) {
517
+ await browser.initBiDi(wsUrl);
518
+ }
519
+ if (options.storageState) {
520
+ await browser.loadState(options.storageState);
521
+ }
452
522
  }
453
- // Load session state if provided
454
- if (options.storageState) {
455
- await browser.loadState(options.storageState);
523
+ catch (err) {
524
+ // POST /session already succeeded — a failure initializing the session
525
+ // must not strand it as a paid, orphaned session on the provider. Send
526
+ // DELETE /session (via quit()) before surfacing the original error.
527
+ await browser.quit().catch(() => { });
528
+ throw err;
456
529
  }
457
530
  return browser;
458
531
  }
@@ -485,8 +558,14 @@ export class Browser {
485
558
  await new Promise((r) => setTimeout(r, BIDI_CONNECT_BACKOFF_STEP_MS * attempt));
486
559
  }
487
560
  else {
488
- // All attempts failed; fall back to Classic-only mode.
489
- console.warn('BiDi connection failed after retries, using Classic WebDriver only:', err);
561
+ // All attempts failed; fall back to Classic-only mode. Redact the
562
+ // WebSocket URL and any occurrence of it inside the error message —
563
+ // some remote providers proxy webSocketUrl through query-string
564
+ // tokens or embedded credentials, which must never reach a log.
565
+ const redactedWsUrl = redactUrlForLog(wsUrl);
566
+ const rawMessage = err instanceof Error ? err.message : String(err);
567
+ const redactedMessage = rawMessage.split(wsUrl).join(redactedWsUrl);
568
+ console.warn(`BiDi connection to ${redactedWsUrl} failed after retries, using Classic WebDriver only: ${redactedMessage}`);
490
569
  this.bidiSession = undefined;
491
570
  }
492
571
  }
@@ -498,6 +577,16 @@ export class Browser {
498
577
  isBiDiEnabled() {
499
578
  return this.bidiSession?.isConnected() ?? false;
500
579
  }
580
+ /** Require a connected BiDi session, with a stable Safari error code. */
581
+ requireBiDi(feature, chromeMessage) {
582
+ if (this.bidiSession?.isConnected())
583
+ return;
584
+ if (this._engine === 'safari') {
585
+ throw new CraftdriverError(ErrorCode.UNSUPPORTED, `${feature} is not supported on Safari (no WebDriver BiDi). ` +
586
+ 'Apple does not document a WebDriver BiDi endpoint for Safari.', { detail: { browserName: 'safari', feature } });
587
+ }
588
+ throw new Error(chromeMessage);
589
+ }
501
590
  // === BiDi Feature Accessors ===
502
591
  /**
503
592
  * Network interception API (BiDi)
@@ -505,10 +594,8 @@ export class Browser {
505
594
  */
506
595
  get network() {
507
596
  if (!this._network) {
508
- if (!this.bidiSession?.isConnected()) {
509
- throw new Error('Network interception requires BiDi. ' +
510
- 'BiDi negotiation may have failed at launch — check browser logs for WebSocket errors.');
511
- }
597
+ this.requireBiDi('network', 'Network interception requires BiDi. ' +
598
+ 'BiDi negotiation may have failed at launch — check browser logs for WebSocket errors.');
512
599
  this._network = this.bidiSession.network;
513
600
  }
514
601
  return this._network;
@@ -518,10 +605,8 @@ export class Browser {
518
605
  */
519
606
  get logs() {
520
607
  if (!this._logs) {
521
- if (!this.bidiSession?.isConnected()) {
522
- throw new Error('Log monitoring requires BiDi. ' +
523
- 'BiDi negotiation may have failed at launch — check browser logs for WebSocket errors.');
524
- }
608
+ this.requireBiDi('logs', 'Log monitoring requires BiDi. ' +
609
+ 'BiDi negotiation may have failed at launch — check browser logs for WebSocket errors.');
525
610
  this._logs = this.bidiSession.logs;
526
611
  }
527
612
  return this._logs;
@@ -738,11 +823,9 @@ export class Browser {
738
823
  * await browser.grantPermissions(['clipboard-read'], { origin: 'https://example.com' });
739
824
  */
740
825
  async grantPermissions(permissions, opts) {
741
- if (!this.bidiSession?.isConnected()) {
742
- throw new Error('grantPermissions() requires BiDi (enableBiDi: true). ' +
743
- 'Permission overrides use the W3C BiDi `permissions.setPermission` command, ' +
744
- 'which has no Classic-WebDriver equivalent.');
745
- }
826
+ this.requireBiDi('grantPermissions()', 'grantPermissions() requires BiDi (enableBiDi: true). ' +
827
+ 'Permission overrides use the W3C BiDi `permissions.setPermission` command, ' +
828
+ 'which has no Classic-WebDriver equivalent.');
746
829
  if (!Array.isArray(permissions) || permissions.length === 0) {
747
830
  throw new Error('grantPermissions: pass a non-empty array of permission names.');
748
831
  }
@@ -781,11 +864,9 @@ export class Browser {
781
864
  * await browser.setGeolocation(null); // clear
782
865
  */
783
866
  async setGeolocation(coords) {
784
- if (!this.bidiSession?.isConnected()) {
785
- throw new Error('setGeolocation() requires BiDi (enableBiDi: true). ' +
786
- 'Geolocation overrides use the W3C BiDi `emulation.setGeolocationOverride` ' +
787
- 'command, which has no Classic-WebDriver equivalent.');
788
- }
867
+ this.requireBiDi('setGeolocation()', 'setGeolocation() requires BiDi (enableBiDi: true). ' +
868
+ 'Geolocation overrides use the W3C BiDi `emulation.setGeolocationOverride` ' +
869
+ 'command, which has no Classic-WebDriver equivalent.');
789
870
  const conn = this.bidiSession.getConnection();
790
871
  // Apply across all top-level contexts in the default user context.
791
872
  const tree = await conn.send('browsingContext.getTree', { maxDepth: 0 });
@@ -851,15 +932,13 @@ export class Browser {
851
932
  async emulate(options) {
852
933
  if (!options || Object.keys(options).length === 0)
853
934
  return;
854
- if (!this.bidiSession?.isConnected()) {
855
- throw new Error('emulate() requires BiDi (enableBiDi: true). ' +
856
- 'Emulation overrides use the W3C BiDi `emulation.*` commands ' +
857
- '(and the BiDi+CDP bridge for media features and offline), ' +
858
- 'which have no Classic-WebDriver equivalent.');
859
- }
935
+ this.requireBiDi('emulate()', 'emulate() requires BiDi (enableBiDi: true). ' +
936
+ 'Emulation overrides use the W3C BiDi `emulation.*` commands ' +
937
+ '(and the BiDi+CDP bridge for media features and offline), ' +
938
+ 'which have no Classic-WebDriver equivalent.');
860
939
  // Validate Chromium-only fields up front so we fail before mutating state.
861
940
  const chromiumOnly = ['colorScheme', 'reducedMotion', 'forcedColors', 'offline'];
862
- const isFirefox = this._browserName === 'firefox';
941
+ const isFirefox = this._engine === 'firefox';
863
942
  if (isFirefox) {
864
943
  for (const k of chromiumOnly) {
865
944
  if (k in options) {
@@ -1065,7 +1144,7 @@ export class Browser {
1065
1144
  // URL comes from the session's own webSocketUrl, not a fixed port. So scope the
1066
1145
  // 500ms sleep to Firefox+BiDi; every other quit (all Chrome, Firefox w/o BiDi)
1067
1146
  // skips straight to stopping the driver.
1068
- if (this._browserName === 'firefox' && bidiWasActive) {
1147
+ if (this._engine === 'firefox' && bidiWasActive) {
1069
1148
  await new Promise((r) => setTimeout(r, PORT_RELEASE_DELAY_MS));
1070
1149
  }
1071
1150
  // Stop the underlying driver service (chromedriver / geckodriver)
@@ -1096,10 +1175,10 @@ export class Browser {
1096
1175
  async startTrace(opts) {
1097
1176
  if (!this.bidiSession?.isConnected()) {
1098
1177
  throw new CraftdriverError(ErrorCode.UNSUPPORTED, 'startTrace() requires BiDi (enableBiDi: true). ' +
1099
- 'Tracing relies on BiDi events; Classic WebDriver does not expose them.', { detail: { feature: 'startTrace' } });
1178
+ 'Tracing relies on BiDi events; Classic WebDriver does not expose them.', { detail: { browserName: this._browserName, feature: 'startTrace' } });
1100
1179
  }
1101
1180
  if (!this._tracer) {
1102
- this._tracer = new Tracer(this, this.bidiSession.getConnection(), this._browserName);
1181
+ this._tracer = new Tracer(this, this.bidiSession.getConnection(), this._engine);
1103
1182
  }
1104
1183
  await this._tracer.start(opts);
1105
1184
  }
@@ -1193,10 +1272,8 @@ export class Browser {
1193
1272
  * ```
1194
1273
  */
1195
1274
  async addInitScript(fnOrSrc) {
1196
- if (!this.bidiSession?.isConnected()) {
1197
- throw new Error('addInitScript() requires BiDi. ' +
1198
- 'BiDi is enabled by default — check that your browser supports it.');
1199
- }
1275
+ this.requireBiDi('addInitScript()', 'addInitScript() requires BiDi. ' +
1276
+ 'BiDi is enabled by default — check that your browser supports it.');
1200
1277
  const conn = this.bidiSession.getConnection();
1201
1278
  const fnSrc = typeof fnOrSrc === 'function' ? fnOrSrc.toString() : `() => { ${fnOrSrc} }`;
1202
1279
  const result = await conn.send('script.addPreloadScript', {
@@ -1218,9 +1295,7 @@ export class Browser {
1218
1295
  * Register **before** the action that triggers the request.
1219
1296
  */
1220
1297
  waitForRequest(pattern, opts) {
1221
- if (!this.bidiSession?.isConnected()) {
1222
- throw new Error('waitForRequest() requires BiDi. BiDi is enabled by default — check your browser supports it.');
1223
- }
1298
+ this.requireBiDi('waitForRequest()', 'waitForRequest() requires BiDi. BiDi is enabled by default — check your browser supports it.');
1224
1299
  return this.network.waitForRequest(pattern, {
1225
1300
  timeout: opts?.timeout ?? this.defaults.navigationTimeout,
1226
1301
  });
@@ -1239,18 +1314,14 @@ export class Browser {
1239
1314
  * ```
1240
1315
  */
1241
1316
  waitForResponse(pattern, opts) {
1242
- if (!this.bidiSession?.isConnected()) {
1243
- throw new Error('waitForResponse() requires BiDi. BiDi is enabled by default \u2014 check your browser supports it.');
1244
- }
1317
+ this.requireBiDi('waitForResponse()', 'waitForResponse() requires BiDi. BiDi is enabled by default \u2014 check your browser supports it.');
1245
1318
  return this.network.waitForResponse(pattern, {
1246
1319
  timeout: opts?.timeout ?? this.defaults.navigationTimeout,
1247
1320
  });
1248
1321
  }
1249
1322
  on(event, listener) {
1250
- if (!this.bidiSession?.isConnected()) {
1251
- throw new Error(`browser.on('${event}') requires BiDi (enableBiDi: true). ` +
1252
- 'Network event listeners use the W3C BiDi `network` module, which has no Classic-WebDriver equivalent.');
1253
- }
1323
+ this.requireBiDi(`browser.on('${event}')`, `browser.on('${event}') requires BiDi (enableBiDi: true). ` +
1324
+ 'Network event listeners use the W3C BiDi `network` module, which has no Classic-WebDriver equivalent.');
1254
1325
  if (event === 'request') {
1255
1326
  return this.network.on('request', listener);
1256
1327
  }
@@ -1266,6 +1337,26 @@ export class Browser {
1266
1337
  * await dl.saveAs('/tmp/report.csv');
1267
1338
  */
1268
1339
  async waitForDownload(action, opts) {
1340
+ if (this._engine === 'safari') {
1341
+ // safaridriver exposes no download-directory configuration craftdriver
1342
+ // can manage, so a browser download never lands in _downloadsDir — it
1343
+ // goes to the user's ~/Downloads. Without this guard, waitForDownload()
1344
+ // would watch an empty temp dir and fail with an opaque timeout instead
1345
+ // of a clear "unsupported" error. Craftdriver-managed downloads are out
1346
+ // of scope for Safari.
1347
+ throw new CraftdriverError(ErrorCode.UNSUPPORTED, 'waitForDownload() is not supported on Safari: safaridriver exposes no ' +
1348
+ 'download-directory configuration craftdriver can manage, so downloads ' +
1349
+ 'cannot be routed to or observed in a controlled directory.', { detail: { browserName: 'safari', feature: 'waitForDownload()' } });
1350
+ }
1351
+ if (this._isRemote) {
1352
+ // Same shape as the Safari guard above: a remote session has no
1353
+ // client-visible downloads directory (downloadsDir is rejected at
1354
+ // remote launch time), so a download never lands anywhere craftdriver
1355
+ // can watch. Fail immediately with a clear error instead of an opaque
1356
+ // timeout on an empty/nonexistent directory.
1357
+ throw new CraftdriverError(ErrorCode.UNSUPPORTED, 'waitForDownload() is not supported on remote sessions: remote sessions have no ' +
1358
+ "client-visible downloads directory; use your provider's download API if one exists.", { detail: { feature: 'waitForDownload()' } });
1359
+ }
1269
1360
  const dir = this._downloadsDir;
1270
1361
  if (!dir) {
1271
1362
  throw new Error('waitForDownload() requires a downloads directory. Browser was not launched correctly.');
@@ -1317,7 +1408,23 @@ export class Browser {
1317
1408
  await handler(dialog);
1318
1409
  });
1319
1410
  }
1320
- // Classic: no push events — callers must use the imperative API below
1411
+ if (this._engine === 'safari') {
1412
+ // Safari is Classic-only and has no WebDriver BiDi push events, so there
1413
+ // is no event-driven way to detect a dialog opening. A polling fallback
1414
+ // (repeatedly calling getAlertText()/driver "no such alert" errors to
1415
+ // detect a dialog) was considered, but would need to be *proven*
1416
+ // reliable on real Safari before shipping. Rather than silently no-op (today's
1417
+ // Classic behavior for other browsers) or let waitForDialog() hang
1418
+ // until its own generic timeout — which would look like a missed
1419
+ // dialog rather than "this API isn't supported here" — fail
1420
+ // immediately with a clear, actionable error. Revisit if a polling
1421
+ // form is later measured reliable on real Safari.
1422
+ throw new CraftdriverError(ErrorCode.UNSUPPORTED, 'onDialog() / waitForDialog() are not supported on Safari: Safari has no ' +
1423
+ 'WebDriver BiDi, so there is no push-event mechanism for dialogs. ' +
1424
+ 'Use the imperative dialog API instead — acceptDialog(), dismissDialog(), ' +
1425
+ 'getDialogMessage() — which work in Classic mode.', { detail: { browserName: 'safari', feature: 'onDialog()' } });
1426
+ }
1427
+ // Classic (non-Safari): no push events — callers must use the imperative API below
1321
1428
  // Return a no-op unsubscribe
1322
1429
  return () => {
1323
1430
  /* no-op */
@@ -1378,18 +1485,31 @@ export class Browser {
1378
1485
  waitForDialog(opts) {
1379
1486
  const timeout = opts?.timeout ?? this.defaults.timeout;
1380
1487
  return new Promise((resolve, reject) => {
1381
- const tid = timeout > 0
1382
- ? setTimeout(() => {
1488
+ let tid;
1489
+ let off;
1490
+ // onDialog() throws synchronously on Safari (no BiDi push events for
1491
+ // dialogs — see onDialog()'s comment). Register the handler before
1492
+ // scheduling the timeout so that throw rejects this promise
1493
+ // immediately instead of leaving a dangling timer whose callback would
1494
+ // later reference `off` before it was ever assigned.
1495
+ try {
1496
+ off = this.onDialog((dialog) => {
1497
+ if (tid !== undefined)
1498
+ clearTimeout(tid);
1499
+ off();
1500
+ resolve(dialog);
1501
+ });
1502
+ }
1503
+ catch (err) {
1504
+ reject(err);
1505
+ return;
1506
+ }
1507
+ if (timeout > 0) {
1508
+ tid = setTimeout(() => {
1383
1509
  off();
1384
1510
  reject(new Error(`waitForDialog timed out after ${timeout}ms`));
1385
- }, timeout)
1386
- : undefined;
1387
- const off = this.onDialog((dialog) => {
1388
- if (tid !== undefined)
1389
- clearTimeout(tid);
1390
- off();
1391
- resolve(dialog);
1392
- });
1511
+ }, timeout);
1512
+ }
1393
1513
  });
1394
1514
  }
1395
1515
  _buildDialog(params) {
@@ -1512,10 +1632,8 @@ export class Browser {
1512
1632
  * await page.waitForLoadState();
1513
1633
  */
1514
1634
  async openPage(opts) {
1515
- if (!this.bidiSession?.isConnected()) {
1516
- throw new Error('openPage() requires BiDi (enableBiDi: true). ' +
1517
- 'WebDriver Classic cannot create top-level browsing contexts.');
1518
- }
1635
+ this.requireBiDi('openPage()', 'openPage() requires BiDi (enableBiDi: true). ' +
1636
+ 'WebDriver Classic cannot create top-level browsing contexts.');
1519
1637
  const conn = this.bidiSession.getConnection();
1520
1638
  const created = await conn.send('browsingContext.create', {
1521
1639
  type: opts?.type ?? 'tab',
@@ -1679,10 +1797,8 @@ export class Browser {
1679
1797
  return ctx;
1680
1798
  }
1681
1799
  async newContext(opts) {
1682
- if (!this.bidiSession?.isConnected()) {
1683
- throw new Error('newContext() requires BiDi (enableBiDi: true). ' +
1684
- 'WebDriver Classic has no concept of user contexts.');
1685
- }
1800
+ this.requireBiDi('newContext()', 'newContext() requires BiDi (enableBiDi: true). ' +
1801
+ 'WebDriver Classic has no concept of user contexts.');
1686
1802
  const conn = this.bidiSession.getConnection();
1687
1803
  const created = await conn.send('browser.createUserContext', {});
1688
1804
  const ctx = this._wrapContext(created.userContext, {
@@ -1692,7 +1808,7 @@ export class Browser {
1692
1808
  if (opts?.storageState !== undefined) {
1693
1809
  await ctx.loadStorageState(opts.storageState);
1694
1810
  }
1695
- // Apply Milestone-C emulation options. Each setter is `userContexts`-scoped,
1811
+ // Apply identity & device emulation options. Each setter is `userContexts`-scoped,
1696
1812
  // so future pages in this context inherit automatically.
1697
1813
  if (opts?.locale !== undefined)
1698
1814
  await ctx.setLocale(opts.locale);
@@ -1708,11 +1824,9 @@ export class Browser {
1708
1824
  * Maps to BiDi `browser.getUserContexts`. **BiDi-only.**
1709
1825
  */
1710
1826
  async contexts() {
1711
- if (!this.bidiSession?.isConnected()) {
1712
- throw new Error('contexts() requires BiDi (enableBiDi: true). ' +
1713
- 'WebDriver Classic has no concept of user contexts. ' +
1714
- 'Use browser.pages() to list tabs instead.');
1715
- }
1827
+ this.requireBiDi('contexts()', 'contexts() requires BiDi (enableBiDi: true). ' +
1828
+ 'WebDriver Classic has no concept of user contexts. ' +
1829
+ 'Use browser.pages() to list tabs instead.');
1716
1830
  const conn = this.bidiSession.getConnection();
1717
1831
  const result = await conn.send('browser.getUserContexts', {});
1718
1832
  return (result.userContexts ?? []).map((uc) => this._wrapContext(uc.userContext));
@@ -1723,10 +1837,8 @@ export class Browser {
1723
1837
  * to this context. **BiDi-only.**
1724
1838
  */
1725
1839
  get defaultContext() {
1726
- if (!this.bidiSession?.isConnected()) {
1727
- throw new Error('defaultContext requires BiDi (enableBiDi: true). ' +
1728
- 'WebDriver Classic has no concept of user contexts.');
1729
- }
1840
+ this.requireBiDi('defaultContext', 'defaultContext requires BiDi (enableBiDi: true). ' +
1841
+ 'WebDriver Classic has no concept of user contexts.');
1730
1842
  if (this._defaultContext)
1731
1843
  return this._defaultContext;
1732
1844
  this._defaultContext = this._wrapContext('default');
@@ -1924,12 +2036,28 @@ export class Browser {
1924
2036
  }
1925
2037
  gesture = {
1926
2038
  swipe: async ({ from, to, durationMs = 300, }) => {
2039
+ this.rejectTouchActionsOnLocalSafari('gesture.swipe()');
1927
2040
  await this.driver.performTouchSwipe(from, to, durationMs);
1928
2041
  },
1929
2042
  pinch: async ({ center, scale = 0.5, distance = 100, durationMs = 250, }) => {
2043
+ this.rejectTouchActionsOnLocalSafari('gesture.pinch()');
1930
2044
  await this.driver.performTouchPinch(center, scale, distance, durationMs);
1931
2045
  },
1932
2046
  };
2047
+ /**
2048
+ * Local desktop Safari has no documented touch-pointer automation surface, so
2049
+ * fail loudly rather than let the action silently no-op. Local only: a remote
2050
+ * `safari` session may be a real iOS device with a touchscreen (BrowserStack
2051
+ * et al.), where these gestures are legitimate — forward those.
2052
+ */
2053
+ rejectTouchActionsOnLocalSafari(feature) {
2054
+ if (this._isRemote)
2055
+ return;
2056
+ if (this._engine !== 'safari')
2057
+ return;
2058
+ throw new CraftdriverError(ErrorCode.UNSUPPORTED, `${feature} sends a touch-pointer action, which local desktop Safari does not support. ` +
2059
+ 'Use a remote real-device session for iPhone/iPad Safari coverage.', { detail: { browserName: 'safari', feature } });
2060
+ }
1933
2061
  /**
1934
2062
  * Capture a screenshot of the active page (viewport by default), the
1935
2063
  * full scrollable document (`fullPage: true`), or an element matching
@@ -1962,11 +2090,9 @@ export class Browser {
1962
2090
  buf = Buffer.from(b64, 'base64');
1963
2091
  }
1964
2092
  else if (opts?.fullPage) {
1965
- if (!this.bidiSession?.isConnected()) {
1966
- throw new Error('screenshot({ fullPage: true }) requires BiDi (enableBiDi: true). ' +
1967
- 'Full-page screenshots use the W3C BiDi `browsingContext.captureScreenshot` ' +
1968
- 'command with `origin: "document"`, which has no Classic-WebDriver equivalent.');
1969
- }
2093
+ this.requireBiDi('screenshot({ fullPage: true })', 'screenshot({ fullPage: true }) requires BiDi (enableBiDi: true). ' +
2094
+ 'Full-page screenshots use the W3C BiDi `browsingContext.captureScreenshot` ' +
2095
+ 'command with `origin: "document"`, which has no Classic-WebDriver equivalent.');
1970
2096
  const conn = this.bidiSession.getConnection();
1971
2097
  const page = await this.activePage();
1972
2098
  const result = await conn.send('browsingContext.captureScreenshot', {