craftdriver 1.4.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 (66) hide show
  1. package/CHANGELOG.md +10 -3
  2. package/README.md +4 -2
  3. package/dist/cli/parseArgs.d.ts +2 -2
  4. package/dist/cli/parseArgs.d.ts.map +1 -1
  5. package/dist/cli/parseArgs.js +1 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/lib/bidi/storage.d.ts +13 -0
  11. package/dist/lib/bidi/storage.d.ts.map +1 -1
  12. package/dist/lib/bidi/storage.js +81 -32
  13. package/dist/lib/bidi/storage.js.map +1 -1
  14. package/dist/lib/browser.d.ts +27 -1
  15. package/dist/lib/browser.d.ts.map +1 -1
  16. package/dist/lib/browser.js +142 -74
  17. package/dist/lib/browser.js.map +1 -1
  18. package/dist/lib/browserContext.d.ts +2 -2
  19. package/dist/lib/browserContext.d.ts.map +1 -1
  20. package/dist/lib/browserContext.js +1 -1
  21. package/dist/lib/builder.d.ts +4 -1
  22. package/dist/lib/builder.d.ts.map +1 -1
  23. package/dist/lib/builder.js +20 -2
  24. package/dist/lib/builder.js.map +1 -1
  25. package/dist/lib/capabilities.d.ts +12 -2
  26. package/dist/lib/capabilities.d.ts.map +1 -1
  27. package/dist/lib/capabilities.js +84 -39
  28. package/dist/lib/capabilities.js.map +1 -1
  29. package/dist/lib/driver.d.ts +16 -1
  30. package/dist/lib/driver.d.ts.map +1 -1
  31. package/dist/lib/driver.js +64 -0
  32. package/dist/lib/driver.js.map +1 -1
  33. package/dist/lib/driverManager.d.ts +28 -0
  34. package/dist/lib/driverManager.d.ts.map +1 -1
  35. package/dist/lib/driverManager.js +60 -0
  36. package/dist/lib/driverManager.js.map +1 -1
  37. package/dist/lib/launchTarget.d.ts +1 -1
  38. package/dist/lib/launchTarget.d.ts.map +1 -1
  39. package/dist/lib/launchTarget.js +65 -3
  40. package/dist/lib/launchTarget.js.map +1 -1
  41. package/dist/lib/safari.d.ts +90 -0
  42. package/dist/lib/safari.d.ts.map +1 -0
  43. package/dist/lib/safari.js +136 -0
  44. package/dist/lib/safari.js.map +1 -0
  45. package/dist/lib/tracing.d.ts +1 -1
  46. package/dist/lib/tracing.d.ts.map +1 -1
  47. package/dist/lib/tracing.js.map +1 -1
  48. package/dist/lib/types.d.ts +41 -0
  49. package/dist/lib/types.d.ts.map +1 -1
  50. package/dist/lib/vibiumTrace.d.ts +1 -1
  51. package/dist/lib/vibiumTrace.d.ts.map +1 -1
  52. package/dist/lib/webelement.d.ts.map +1 -1
  53. package/dist/lib/webelement.js +118 -2
  54. package/dist/lib/webelement.js.map +1 -1
  55. package/docs/api-reference.md +3 -1
  56. package/docs/driver-configuration.md +54 -0
  57. package/docs/getting-started.md +30 -10
  58. package/docs/index.md +1 -1
  59. package/docs/public/examples/displayed.html +86 -0
  60. package/docs/safari.md +118 -0
  61. package/docs/standards.md +13 -0
  62. package/docs/why-craftdriver.md +1 -1
  63. package/docs/zero-config-drivers.md +12 -0
  64. package/package.json +3 -1
  65. package/skills/craftdriver/SKILL.md +4 -1
  66. package/skills/craftdriver/cheatsheet.md +4 -1
@@ -5,6 +5,7 @@ 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';
@@ -351,9 +352,18 @@ export class Browser {
351
352
  // it's the optional custom-binary resolution chain — but see
352
353
  // resolveBrowserBinaryPath's doc: some of the env vars in that chain aren't
353
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.
354
362
  const browserBinary = target.kind === 'electron'
355
363
  ? electronBinary
356
- : resolveBrowserBinaryPath(isChromeFamily ? 'chrome' : 'firefox', target.browserPath);
364
+ : name === 'safari'
365
+ ? undefined
366
+ : resolveBrowserBinaryPath(isChromeFamily ? 'chrome' : 'firefox', target.browserPath);
357
367
  // Main-process access (opt-in): launch the Electron app with a Node inspector
358
368
  // on a free local port. chromedriver forwards this arg to the app, enabling
359
369
  // its main-process inspector; browser.electron connects below for log capture
@@ -399,6 +409,13 @@ export class Browser {
399
409
  driverService = options.chromeService ?? new ChromeService({ browserPath: browserBinary });
400
410
  builder.setChromeService(driverService);
401
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
+ }
402
419
  else {
403
420
  driverService = options.firefoxService ?? new FirefoxService();
404
421
  builder.setFirefoxService(driverService);
@@ -498,6 +515,26 @@ export class Browser {
498
515
  isBiDiEnabled() {
499
516
  return this.bidiSession?.isConnected() ?? false;
500
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
+ }
501
538
  // === BiDi Feature Accessors ===
502
539
  /**
503
540
  * Network interception API (BiDi)
@@ -505,10 +542,8 @@ export class Browser {
505
542
  */
506
543
  get network() {
507
544
  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
- }
545
+ this.requireBiDi('network', 'Network interception requires BiDi. ' +
546
+ 'BiDi negotiation may have failed at launch — check browser logs for WebSocket errors.');
512
547
  this._network = this.bidiSession.network;
513
548
  }
514
549
  return this._network;
@@ -518,10 +553,8 @@ export class Browser {
518
553
  */
519
554
  get logs() {
520
555
  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
- }
556
+ this.requireBiDi('logs', 'Log monitoring requires BiDi. ' +
557
+ 'BiDi negotiation may have failed at launch — check browser logs for WebSocket errors.');
525
558
  this._logs = this.bidiSession.logs;
526
559
  }
527
560
  return this._logs;
@@ -738,11 +771,9 @@ export class Browser {
738
771
  * await browser.grantPermissions(['clipboard-read'], { origin: 'https://example.com' });
739
772
  */
740
773
  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
- }
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.');
746
777
  if (!Array.isArray(permissions) || permissions.length === 0) {
747
778
  throw new Error('grantPermissions: pass a non-empty array of permission names.');
748
779
  }
@@ -781,11 +812,9 @@ export class Browser {
781
812
  * await browser.setGeolocation(null); // clear
782
813
  */
783
814
  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
- }
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.');
789
818
  const conn = this.bidiSession.getConnection();
790
819
  // Apply across all top-level contexts in the default user context.
791
820
  const tree = await conn.send('browsingContext.getTree', { maxDepth: 0 });
@@ -851,12 +880,10 @@ export class Browser {
851
880
  async emulate(options) {
852
881
  if (!options || Object.keys(options).length === 0)
853
882
  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
- }
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.');
860
887
  // Validate Chromium-only fields up front so we fail before mutating state.
861
888
  const chromiumOnly = ['colorScheme', 'reducedMotion', 'forcedColors', 'offline'];
862
889
  const isFirefox = this._browserName === 'firefox';
@@ -1096,7 +1123,7 @@ export class Browser {
1096
1123
  async startTrace(opts) {
1097
1124
  if (!this.bidiSession?.isConnected()) {
1098
1125
  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' } });
1126
+ 'Tracing relies on BiDi events; Classic WebDriver does not expose them.', { detail: { browserName: this._browserName, feature: 'startTrace' } });
1100
1127
  }
1101
1128
  if (!this._tracer) {
1102
1129
  this._tracer = new Tracer(this, this.bidiSession.getConnection(), this._browserName);
@@ -1193,10 +1220,8 @@ export class Browser {
1193
1220
  * ```
1194
1221
  */
1195
1222
  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
- }
1223
+ this.requireBiDi('addInitScript()', 'addInitScript() requires BiDi. ' +
1224
+ 'BiDi is enabled by default — check that your browser supports it.');
1200
1225
  const conn = this.bidiSession.getConnection();
1201
1226
  const fnSrc = typeof fnOrSrc === 'function' ? fnOrSrc.toString() : `() => { ${fnOrSrc} }`;
1202
1227
  const result = await conn.send('script.addPreloadScript', {
@@ -1218,9 +1243,7 @@ export class Browser {
1218
1243
  * Register **before** the action that triggers the request.
1219
1244
  */
1220
1245
  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
- }
1246
+ this.requireBiDi('waitForRequest()', 'waitForRequest() requires BiDi. BiDi is enabled by default — check your browser supports it.');
1224
1247
  return this.network.waitForRequest(pattern, {
1225
1248
  timeout: opts?.timeout ?? this.defaults.navigationTimeout,
1226
1249
  });
@@ -1239,18 +1262,14 @@ export class Browser {
1239
1262
  * ```
1240
1263
  */
1241
1264
  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
- }
1265
+ this.requireBiDi('waitForResponse()', 'waitForResponse() requires BiDi. BiDi is enabled by default \u2014 check your browser supports it.');
1245
1266
  return this.network.waitForResponse(pattern, {
1246
1267
  timeout: opts?.timeout ?? this.defaults.navigationTimeout,
1247
1268
  });
1248
1269
  }
1249
1270
  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
- }
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.');
1254
1273
  if (event === 'request') {
1255
1274
  return this.network.on('request', listener);
1256
1275
  }
@@ -1266,6 +1285,17 @@ export class Browser {
1266
1285
  * await dl.saveAs('/tmp/report.csv');
1267
1286
  */
1268
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
+ }
1269
1299
  const dir = this._downloadsDir;
1270
1300
  if (!dir) {
1271
1301
  throw new Error('waitForDownload() requires a downloads directory. Browser was not launched correctly.');
@@ -1317,7 +1347,23 @@ export class Browser {
1317
1347
  await handler(dialog);
1318
1348
  });
1319
1349
  }
1320
- // 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
1321
1367
  // Return a no-op unsubscribe
1322
1368
  return () => {
1323
1369
  /* no-op */
@@ -1378,18 +1424,31 @@ export class Browser {
1378
1424
  waitForDialog(opts) {
1379
1425
  const timeout = opts?.timeout ?? this.defaults.timeout;
1380
1426
  return new Promise((resolve, reject) => {
1381
- const tid = timeout > 0
1382
- ? setTimeout(() => {
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(() => {
1383
1448
  off();
1384
1449
  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
- });
1450
+ }, timeout);
1451
+ }
1393
1452
  });
1394
1453
  }
1395
1454
  _buildDialog(params) {
@@ -1512,10 +1571,8 @@ export class Browser {
1512
1571
  * await page.waitForLoadState();
1513
1572
  */
1514
1573
  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
- }
1574
+ this.requireBiDi('openPage()', 'openPage() requires BiDi (enableBiDi: true). ' +
1575
+ 'WebDriver Classic cannot create top-level browsing contexts.');
1519
1576
  const conn = this.bidiSession.getConnection();
1520
1577
  const created = await conn.send('browsingContext.create', {
1521
1578
  type: opts?.type ?? 'tab',
@@ -1679,10 +1736,8 @@ export class Browser {
1679
1736
  return ctx;
1680
1737
  }
1681
1738
  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
- }
1739
+ this.requireBiDi('newContext()', 'newContext() requires BiDi (enableBiDi: true). ' +
1740
+ 'WebDriver Classic has no concept of user contexts.');
1686
1741
  const conn = this.bidiSession.getConnection();
1687
1742
  const created = await conn.send('browser.createUserContext', {});
1688
1743
  const ctx = this._wrapContext(created.userContext, {
@@ -1692,7 +1747,7 @@ export class Browser {
1692
1747
  if (opts?.storageState !== undefined) {
1693
1748
  await ctx.loadStorageState(opts.storageState);
1694
1749
  }
1695
- // Apply Milestone-C emulation options. Each setter is `userContexts`-scoped,
1750
+ // Apply identity & device emulation options. Each setter is `userContexts`-scoped,
1696
1751
  // so future pages in this context inherit automatically.
1697
1752
  if (opts?.locale !== undefined)
1698
1753
  await ctx.setLocale(opts.locale);
@@ -1708,11 +1763,9 @@ export class Browser {
1708
1763
  * Maps to BiDi `browser.getUserContexts`. **BiDi-only.**
1709
1764
  */
1710
1765
  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
- }
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.');
1716
1769
  const conn = this.bidiSession.getConnection();
1717
1770
  const result = await conn.send('browser.getUserContexts', {});
1718
1771
  return (result.userContexts ?? []).map((uc) => this._wrapContext(uc.userContext));
@@ -1723,10 +1776,8 @@ export class Browser {
1723
1776
  * to this context. **BiDi-only.**
1724
1777
  */
1725
1778
  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
- }
1779
+ this.requireBiDi('defaultContext', 'defaultContext requires BiDi (enableBiDi: true). ' +
1780
+ 'WebDriver Classic has no concept of user contexts.');
1730
1781
  if (this._defaultContext)
1731
1782
  return this._defaultContext;
1732
1783
  this._defaultContext = this._wrapContext('default');
@@ -1924,12 +1975,31 @@ export class Browser {
1924
1975
  }
1925
1976
  gesture = {
1926
1977
  swipe: async ({ from, to, durationMs = 300, }) => {
1978
+ this.rejectTouchActionsOnSafari('gesture.swipe()');
1927
1979
  await this.driver.performTouchSwipe(from, to, durationMs);
1928
1980
  },
1929
1981
  pinch: async ({ center, scale = 0.5, distance = 100, durationMs = 250, }) => {
1982
+ this.rejectTouchActionsOnSafari('gesture.pinch()');
1930
1983
  await this.driver.performTouchPinch(center, scale, distance, durationMs);
1931
1984
  },
1932
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
+ }
1933
2003
  /**
1934
2004
  * Capture a screenshot of the active page (viewport by default), the
1935
2005
  * full scrollable document (`fullPage: true`), or an element matching
@@ -1962,11 +2032,9 @@ export class Browser {
1962
2032
  buf = Buffer.from(b64, 'base64');
1963
2033
  }
1964
2034
  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
- }
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.');
1970
2038
  const conn = this.bidiSession.getConnection();
1971
2039
  const page = await this.activePage();
1972
2040
  const result = await conn.send('browsingContext.captureScreenshot', {