browser-commander 0.25.0 → 0.26.1

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 (52) hide show
  1. package/.jscpd-baseline.json +6 -4
  2. package/CHANGELOG.md +32 -0
  3. package/package.json +2 -2
  4. package/scripts/check-duplication.mjs +176 -0
  5. package/scripts/create-manual-changeset.mjs +3 -1
  6. package/scripts/format-release-notes.mjs +8 -3
  7. package/scripts/merge-changesets.mjs +4 -1
  8. package/scripts/validate-changeset.mjs +23 -10
  9. package/scripts/version-and-commit.mjs +8 -10
  10. package/src/browser/browser-sources.json +10 -2
  11. package/src/browser/launcher.js +14 -0
  12. package/src/browser/real-browser.js +5 -0
  13. package/src/browser/safari-launch.js +42 -0
  14. package/src/browser/safari-support.js +98 -0
  15. package/src/browser/storage-state.js +28 -8
  16. package/src/browser/webdriver.js +132 -21
  17. package/src/cli/sessions.js +5 -0
  18. package/src/core/webdriver-page.js +62 -0
  19. package/src/elements/selectors.js +31 -14
  20. package/src/exports.js +6 -0
  21. package/src/parity/harness.js +4 -1
  22. package/src/tests/index.js +12 -1
  23. package/src/traces/recorder.js +5 -0
  24. package/tests/e2e/engine-matrix.e2e.test.js +2 -1
  25. package/tests/e2e/safari.e2e.test.js +105 -0
  26. package/tests/e2e/webdriver.e2e.test.js +2 -1
  27. package/tests/helpers/fixture-server.js +18 -1
  28. package/tests/unit/browser/real-browser.test.js +1 -1
  29. package/tests/unit/browser/safari.test.js +192 -0
  30. package/tests/unit/browser/webdriver.test.js +148 -3
  31. package/tests/unit/cli/dispatcher.test.js +1 -1
  32. package/tests/unit/elements/selectors.test.js +75 -0
  33. package/tests/unit/helpers/fixture-server.test.js +60 -0
  34. package/tests/unit/playwright-protocol-coverage.test.js +9 -6
  35. package/tests/unit/puppeteer-bindings-coverage.test.js +28 -7
  36. package/tests/unit/scripts/bot-commit-attribution.test.js +70 -0
  37. package/tests/unit/scripts/check-duplication.test.js +139 -0
  38. package/tests/unit/scripts/check-pipeline-status.test.js +264 -52
  39. package/tests/unit/scripts/check-version-modification.test.js +107 -1
  40. package/tests/unit/scripts/ci-timeout-budgets.test.js +15 -2
  41. package/tests/unit/scripts/ci-workflow-policy.test.js +91 -7
  42. package/tests/unit/scripts/command-stream-errexit.test.js +83 -41
  43. package/tests/unit/scripts/e2e-suite-coverage.test.js +174 -0
  44. package/tests/unit/scripts/github-actions-log.test.js +99 -0
  45. package/tests/unit/scripts/links-workflow.test.js +42 -0
  46. package/tests/unit/scripts/preflight-credentials.test.js +527 -0
  47. package/tests/unit/scripts/run-with-budget-warning.test.js +212 -6
  48. package/tests/unit/scripts/use-module-integration.test.js +2 -9
  49. package/tests/unit/scripts/use-module.test.js +20 -0
  50. package/tests/unit/scripts/validate-changeset.test.js +127 -0
  51. package/tests/unit/scripts/zizmor-version.test.js +82 -0
  52. package/tests/unit/tests/index.test.js +43 -0
@@ -0,0 +1,98 @@
1
+ import { findBrowserSource } from './browser-sources.js';
2
+ import { runCommand } from '../utilities/subprocess.js';
3
+
4
+ /** Whether a channel (including aliases) selects an installed Safari. */
5
+ export function isSafariChannel(channel) {
6
+ return findBrowserSource(channel)?.family === 'safari';
7
+ }
8
+
9
+ /** A requested operation unavailable in Safari's classic WebDriver. */
10
+ export class SafariUnsupportedError extends Error {
11
+ constructor(feature) {
12
+ super(`${feature} is unsupported on safari`);
13
+ this.name = 'SafariUnsupportedError';
14
+ this.code = 'SAFARI_UNSUPPORTED';
15
+ this.feature = feature;
16
+ }
17
+ }
18
+
19
+ /** One-time setup failure with actionable instructions and a settings opener. */
20
+ export class SafariSetupError extends Error {
21
+ constructor(cause, channel = 'safari') {
22
+ const driver = findBrowserSource(channel).executables.darwin[0];
23
+ super(
24
+ `Safari automation setup required: ${cause.message}. In Safari → Settings → Advanced enable "Show features for web developers", then Develop → "Allow Remote Automation". Run "${driver}" --enable once (an admin password may be required). Call error.openSettings() to open Safari's Advanced settings.`,
25
+ { cause }
26
+ );
27
+ this.name = 'SafariSetupError';
28
+ this.code = 'SAFARI_SETUP_REQUIRED';
29
+ this.channel = channel;
30
+ }
31
+ openSettings() {
32
+ return openSafariSettings({ channel: this.channel });
33
+ }
34
+ }
35
+
36
+ /** Translate only recognizable authorization failures; preserve other errors. */
37
+ export function safariLaunchError(error, channel) {
38
+ return /allow\s+remote\s+automation|remote automation.*(?:disabled|enable)|(?:safaridriver|webdriver).*(?:--enable|authoriz|not enabled)|not authorized.*(?:safari|automation)/i.test(
39
+ error.message
40
+ )
41
+ ? new SafariSetupError(error, channel)
42
+ : error;
43
+ }
44
+
45
+ /** Open Advanced settings on explicit request; never change authorization. */
46
+ export async function openSafariSettings({
47
+ channel = 'safari',
48
+ platform = process.platform,
49
+ run = runCommand,
50
+ } = {}) {
51
+ if (platform !== 'darwin') {
52
+ throw new SafariUnsupportedError('Safari settings outside macOS');
53
+ }
54
+ const app =
55
+ findBrowserSource(channel)?.id === 'safari-technology-preview'
56
+ ? 'Safari Technology Preview'
57
+ : 'Safari';
58
+ return await run('osascript', [
59
+ '-e',
60
+ `tell application "${app}" to activate`,
61
+ '-e',
62
+ `tell application "${app}" to open location "x-safari-preferences:com.apple.Safari.preferences.Advanced"`,
63
+ ]);
64
+ }
65
+
66
+ /** Reject settings Safari cannot apply before touching disk or starting it. */
67
+ export function validateSafariOptions(options) {
68
+ for (const name of [
69
+ 'headless',
70
+ 'userDataDir',
71
+ 'attach',
72
+ 'migrateFrom',
73
+ 'remoteDebuggingPort',
74
+ 'cdpEndpoint',
75
+ 'wsEndpoint',
76
+ 'fingerprint',
77
+ 'colorScheme',
78
+ 'downloads',
79
+ 'bidi',
80
+ 'preferences',
81
+ 'localState',
82
+ 'defaultBrowserCheck',
83
+ 'firstRun',
84
+ ]) {
85
+ if (
86
+ options[name] !== undefined &&
87
+ options[name] !== null &&
88
+ options[name] !== false
89
+ ) {
90
+ throw new SafariUnsupportedError(name);
91
+ }
92
+ }
93
+ for (const name of ['args', 'extraArgs', 'restrictions']) {
94
+ if (options[name]?.length) {
95
+ throw new SafariUnsupportedError(name);
96
+ }
97
+ }
98
+ }
@@ -31,27 +31,47 @@ function restoreOriginLocalStorage(origins) {
31
31
  }
32
32
 
33
33
  /** Restore classic WebDriver cookies; origin preload scripts require BiDi. */
34
- export async function restoreWebDriverStorageState({ page, storageState }) {
35
- if (!storageState) {
34
+ export async function restoreWebDriverStorageState({
35
+ page,
36
+ storageState,
37
+ seedCookies = [],
38
+ }) {
39
+ storageState = await loadStorageState(storageState);
40
+ if (!storageState && !seedCookies.length) {
36
41
  return;
37
42
  }
38
- const origins = storageState.origins ?? [];
43
+ const origins = storageState?.origins ?? [];
39
44
  if (origins.length) {
40
- await page.evaluateOnNewDocument(restoreOriginLocalStorage, origins);
41
- await page.evaluate(restoreOriginLocalStorage, origins);
45
+ if (!page.browserName?.toLowerCase().startsWith('safari')) {
46
+ await page.evaluateOnNewDocument(restoreOriginLocalStorage, origins);
47
+ await page.evaluate(restoreOriginLocalStorage, origins);
48
+ }
42
49
  }
43
50
  const originalUrl = page.url();
44
- const cookies = storageState.cookies ?? [];
51
+ const cookies = [...(storageState?.cookies ?? []), ...seedCookies];
45
52
  try {
46
53
  for (const cookie of cookies) {
47
54
  const origin = cookie.url
48
55
  ? new URL(cookie.url).origin
49
- : `${cookie.secure ? 'https' : 'http'}://${cookie.domain.replace(/^\./u, '')}`;
56
+ : (origins.find(
57
+ ({ origin }) =>
58
+ new URL(origin).hostname === cookie.domain?.replace(/^\./u, '')
59
+ )?.origin ??
60
+ `${cookie.secure ? 'https' : 'http'}://${cookie.domain.replace(/^\./u, '')}`);
50
61
  await page.goto(origin);
51
62
  await page.setCookie(cookie);
52
63
  }
64
+ if (page.browserName?.toLowerCase().startsWith('safari')) {
65
+ for (const { origin } of origins) {
66
+ await page.goto(origin);
67
+ await page.evaluate(restoreOriginLocalStorage, origins);
68
+ }
69
+ }
53
70
  } finally {
54
- if (cookies.length) {
71
+ if (
72
+ cookies.length ||
73
+ (origins.length && page.browserName?.toLowerCase().startsWith('safari'))
74
+ ) {
55
75
  await page.goto(originalUrl);
56
76
  }
57
77
  }
@@ -28,6 +28,7 @@ import { createRequire } from 'node:module';
28
28
  import path from 'node:path';
29
29
 
30
30
  import { createWebDriverPage } from '../core/webdriver-page.js';
31
+ import { isVerboseEnabled } from '../core/logger.js';
31
32
  import {
32
33
  runCommand as defaultRunCommand,
33
34
  startProcess as defaultStartProcess,
@@ -40,6 +41,13 @@ import {
40
41
  } from './profile-directory.js';
41
42
  import { buildRealBrowserArgs } from './real-browser.js';
42
43
  import { resolveRestrictions } from './restrictions.js';
44
+ import { findBrowserSource } from './browser-sources.js';
45
+ import {
46
+ isSafariChannel,
47
+ SafariUnsupportedError,
48
+ safariLaunchError,
49
+ validateSafariOptions,
50
+ } from './safari-support.js';
43
51
 
44
52
  const require = createRequire(import.meta.url);
45
53
  const accessAsync = promisify(access);
@@ -76,6 +84,8 @@ export const CHROMEDRIVER_DEFAULT_SWITCHES = Object.freeze([
76
84
  const DRIVERS = Object.freeze({
77
85
  chrome: { name: 'chromedriver', builder: 'chrome' },
78
86
  firefox: { name: 'geckodriver', builder: 'firefox' },
87
+ safari: { name: 'safaridriver', builder: 'safari' },
88
+ 'safari-technology-preview': { name: 'safaridriver', builder: 'safari' },
79
89
  });
80
90
 
81
91
  /**
@@ -102,7 +112,7 @@ export function loadSelenium(subpath) {
102
112
  function assertBrowser(browser) {
103
113
  if (!DRIVERS[browser]) {
104
114
  throw new Error(
105
- `Unsupported WebDriver browser: ${browser}. Expected 'chrome' or 'firefox'`
115
+ `Unsupported WebDriver browser: ${browser}. Expected 'chrome', 'firefox' or 'safari'`
106
116
  );
107
117
  }
108
118
  return DRIVERS[browser];
@@ -182,6 +192,20 @@ export async function resolveWebDriverExecutable({
182
192
  managerPath,
183
193
  } = {}) {
184
194
  const { name } = assertBrowser(browser);
195
+ if (isSafariChannel(browser)) {
196
+ if (platform !== 'darwin') {
197
+ throw new SafariUnsupportedError('Safari launch outside macOS');
198
+ }
199
+ const file = driverPath ?? findBrowserSource(browser).executables.darwin[0];
200
+ if (!(await isExecutable(file, checkAccess))) {
201
+ throw new Error(`Safari WebDriver executable is not accessible: ${file}`);
202
+ }
203
+ return {
204
+ driverPath: file,
205
+ browserPath: null,
206
+ source: 'bundled-safaridriver',
207
+ };
208
+ }
185
209
  if (driverPath) {
186
210
  if (!(await isExecutable(driverPath, checkAccess))) {
187
211
  throw new Error(`WebDriver executable is not accessible: ${driverPath}`);
@@ -237,6 +261,9 @@ export async function resolveWebDriverExecutable({
237
261
  * @returns {string[]}
238
262
  */
239
263
  export function buildDriverServerArgs(browser, port) {
264
+ if (isSafariChannel(browser)) {
265
+ return ['--port', String(port)];
266
+ }
240
267
  if (assertBrowser(browser).name === 'geckodriver') {
241
268
  return ['--port', String(port), '--host', LOOPBACK_HOST];
242
269
  }
@@ -306,6 +333,16 @@ export function buildBrowserOptions({
306
333
  }) {
307
334
  let options;
308
335
  let browserArgs;
336
+ if (isSafariChannel(browser)) {
337
+ options = new selenium.webdriver.Capabilities();
338
+ options.set(
339
+ 'browserName',
340
+ browser === 'safari-technology-preview'
341
+ ? 'Safari Technology Preview'
342
+ : 'safari'
343
+ );
344
+ return { options, args: [] };
345
+ }
309
346
  if (browser === 'chrome') {
310
347
  options = new selenium.chrome.Options();
311
348
  if (debuggerAddress) {
@@ -387,7 +424,9 @@ function buildSession({ selenium, serverUrl, browser, options }) {
387
424
  const builder = new selenium.webdriver.Builder()
388
425
  .usingServer(serverUrl)
389
426
  .forBrowser(DRIVERS[browser].builder);
390
- if (browser === 'chrome') {
427
+ if (isSafariChannel(browser)) {
428
+ builder.withCapabilities(options);
429
+ } else if (browser === 'chrome') {
391
430
  builder.setChromeOptions(options);
392
431
  } else {
393
432
  builder.setFirefoxOptions(options);
@@ -446,6 +485,68 @@ async function teardown({
446
485
  * @returns {Promise<{driver: Object, page: Object, close: function(): Promise<void>, serverUrl: string, driverProcess: Object, driverPath: string, driverSource: string, browser: string, userDataDir: string, temporaryProfile: boolean, args: Array<string>, bidi: boolean}>}
447
486
  */
448
487
  export async function launchWebDriver(options = {}, dependencies = {}) {
488
+ if (!isSafariChannel(options.browser ?? 'chrome')) {
489
+ return await launchWebDriverOnce(options, dependencies);
490
+ }
491
+ const delay = dependencies.safariRetryDelay ?? SAFARI_RETRY_DELAY;
492
+ for (let attempt = 1; ; attempt += 1) {
493
+ try {
494
+ return await launchWebDriverOnce(options, dependencies);
495
+ } catch (error) {
496
+ if (
497
+ attempt >= SAFARI_LAUNCH_ATTEMPTS ||
498
+ !TRANSIENT_DRIVER_START_ERROR.test(error?.message ?? '')
499
+ ) {
500
+ throw error;
501
+ }
502
+ if (isVerboseEnabled()) {
503
+ console.warn(
504
+ `[webdriver] Safari launch attempt ${attempt} failed, retrying in ${delay}ms: ${error.message}`
505
+ );
506
+ }
507
+ await new Promise((resolve) => setTimeout(resolve, delay));
508
+ }
509
+ }
510
+ }
511
+
512
+ /**
513
+ * safaridriver serves one Safari automation session at a time. Launched right
514
+ * after a previous session closed, CI saw a new driver exit with code 1 before
515
+ * it was ready, and a driver that was ready refuse the new-session request
516
+ * (issue #128). Both are start-up failures of a driver this process owns, so a
517
+ * Safari launch starts a fresh driver for them, a bounded number of times.
518
+ */
519
+ const SAFARI_LAUNCH_ATTEMPTS = 3;
520
+ const SAFARI_RETRY_DELAY = 1000;
521
+ const TRANSIENT_DRIVER_START_ERROR =
522
+ /ECONNREFUSED|ECONNRESET|socket hang up|exited with code \d+ before it was ready/;
523
+
524
+ /**
525
+ * Name the driver server behind a connection error from selenium, which only
526
+ * says which port refused: whether the server had exited, and its last output.
527
+ */
528
+ function withDriverContext(error, { serverUrl, exitCode, output }) {
529
+ if (
530
+ !serverUrl ||
531
+ !/ECONNREFUSED|ECONNRESET|socket hang up/.test(error?.message ?? '')
532
+ ) {
533
+ return error;
534
+ }
535
+ const state =
536
+ exitCode === null || exitCode === undefined
537
+ ? 'was still running'
538
+ : `had exited with code ${exitCode}`;
539
+ const tail = output.trim();
540
+ return Object.assign(
541
+ new Error(
542
+ `${error.message} (WebDriver server at ${serverUrl} ${state}${tail ? `: ${tail}` : ''})`,
543
+ { cause: error }
544
+ ),
545
+ { code: error.code }
546
+ );
547
+ }
548
+
549
+ async function launchWebDriverOnce(options, dependencies) {
449
550
  const {
450
551
  browser = 'chrome',
451
552
  executablePath,
@@ -462,6 +563,10 @@ export async function launchWebDriver(options = {}, dependencies = {}) {
462
563
  debuggerAddress,
463
564
  } = options;
464
565
  assertBrowser(browser);
566
+ const safari = isSafariChannel(browser);
567
+ if (safari) {
568
+ validateSafariOptions(options);
569
+ }
465
570
  if (debuggerAddress && browser !== 'chrome') {
466
571
  throw new Error('debuggerAddress is only supported by Chrome WebDriver');
467
572
  }
@@ -476,39 +581,36 @@ export async function launchWebDriver(options = {}, dependencies = {}) {
476
581
  const startProcess = dependencies.startProcess ?? defaultStartProcess;
477
582
 
478
583
  const resolved = await resolveWebDriverExecutable({
584
+ ...dependencies,
479
585
  browser,
480
586
  driverPath: requestedDriverPath,
481
587
  executablePath,
482
- runCommand: dependencies.runCommand ?? defaultRunCommand,
483
- ...(dependencies.checkAccess
484
- ? { checkAccess: dependencies.checkAccess }
485
- : {}),
486
- ...(dependencies.environment
487
- ? { environment: dependencies.environment }
488
- : {}),
489
- ...(dependencies.platform ? { platform: dependencies.platform } : {}),
490
588
  });
491
589
 
492
- const temporaryProfile = !debuggerAddress && !requestedUserDataDir;
493
- const userDataDir = debuggerAddress
494
- ? undefined
495
- : await resolveProfile(browser, requestedUserDataDir);
590
+ const temporaryProfile = !safari && !debuggerAddress && !requestedUserDataDir;
591
+ const userDataDir =
592
+ safari || debuggerAddress
593
+ ? undefined
594
+ : await resolveProfile(browser, requestedUserDataDir);
496
595
  const childEnv = driverEnvironment(restrictions, env);
497
596
 
498
597
  let driverProcess;
499
598
  let driver;
599
+ let serverUrl;
600
+ let output = '';
500
601
  try {
501
602
  const port = await reservePort();
502
- const serverUrl = `http://${LOOPBACK_HOST}:${port}`;
603
+ serverUrl = `http://${LOOPBACK_HOST}:${port}`;
503
604
  driverProcess = startProcess(
504
605
  resolved.driverPath,
505
606
  buildDriverServerArgs(browser, port),
506
607
  { env: childEnv, forwardOutput: verbose }
507
608
  );
508
- let output = '';
509
- driverProcess.stderr?.on('data', (chunk) => {
510
- output = `${output}${chunk}`.slice(-4000);
511
- });
609
+ for (const stream of [driverProcess.stdout, driverProcess.stderr]) {
610
+ stream?.on('data', (chunk) => {
611
+ output = `${output}${chunk}`.slice(-4000);
612
+ });
613
+ }
512
614
  await waitForDriverReady(serverUrl, {
513
615
  fetchImplementation: dependencies.fetch ?? fetch,
514
616
  timeout: startupTimeout,
@@ -536,7 +638,14 @@ export async function launchWebDriver(options = {}, dependencies = {}) {
536
638
  browser,
537
639
  options: built.options,
538
640
  });
539
- const page = await createWebDriverPage(driver, dependencies.pageOptions);
641
+ // Fresh Safari sessions can report an empty URL until first navigation.
642
+ if (safari) {
643
+ await driver.get('about:blank');
644
+ }
645
+ const page = await createWebDriverPage(driver, {
646
+ ...dependencies.pageOptions,
647
+ ...(safari ? { browserName: 'safari' } : {}),
648
+ });
540
649
 
541
650
  const close = memoize(() =>
542
651
  teardown({ page, driver, driverProcess, temporaryProfile, userDataDir })
@@ -557,8 +666,10 @@ export async function launchWebDriver(options = {}, dependencies = {}) {
557
666
  bidi,
558
667
  };
559
668
  } catch (error) {
669
+ const exitCode = driverProcess?.exitCode;
560
670
  await teardown({ driver, driverProcess, temporaryProfile, userDataDir });
561
- throw error;
671
+ const failure = withDriverContext(error, { serverUrl, exitCode, output });
672
+ throw safari ? safariLaunchError(failure, browser) : failure;
562
673
  }
563
674
  }
564
675
 
@@ -12,6 +12,9 @@ export const BROWSER_CHANNELS = Object.freeze({
12
12
  msedge: 'msedge',
13
13
  brave: 'brave',
14
14
  chromium: 'chromium',
15
+ safari: 'safari',
16
+ 'safari-technology-preview': 'safari-technology-preview',
17
+ 'safari-tp': 'safari-technology-preview',
15
18
  });
16
19
 
17
20
  const ENGINES = Object.freeze(['playwright', 'puppeteer', 'selenium']);
@@ -40,6 +43,7 @@ const LAUNCH_PASSTHROUGH = Object.freeze([
40
43
  'firstRun',
41
44
  'driverPath',
42
45
  'bidi',
46
+ 'seedCookies',
43
47
  ]);
44
48
 
45
49
  /** Validate an engine name, defaulting to Playwright. */
@@ -118,6 +122,7 @@ export function buildLaunchOptions(params = {}) {
118
122
  * persistent context whose `browser()` is null. Puppeteer returns a Browser.
119
123
  */
120
124
  export function sessionFromLaunch(engine, launched) {
125
+ engine = launched.engine ?? engine;
121
126
  const playwright = engine === 'playwright';
122
127
  const handle = launched.browser;
123
128
  return {
@@ -32,6 +32,7 @@ import {
32
32
  toWebDriverKey,
33
33
  } from './webdriver-conversions.js';
34
34
  import { createWebDriverEventBridge } from './webdriver-events.js';
35
+ import { SafariUnsupportedError } from '../browser/safari-support.js';
35
36
 
36
37
  const require = createRequire(import.meta.url);
37
38
 
@@ -99,6 +100,7 @@ export class WebDriverPage extends EventEmitter {
99
100
  }
100
101
  this.driver = driver;
101
102
  this.engine = 'selenium';
103
+ this.browserName = options.browserName;
102
104
  this.isWebDriverPage = true;
103
105
  this._url = 'about:blank';
104
106
  this._closed = false;
@@ -358,6 +360,57 @@ export class WebDriverPage extends EventEmitter {
358
360
  return result;
359
361
  }
360
362
 
363
+ /** Execute an asynchronous function using W3C execute/async. */
364
+ async evaluateAsync(pageFunction, ...args) {
365
+ const script = buildEvaluateScript(pageFunction);
366
+ const result = await this.driver.executeAsyncScript(
367
+ `const done = arguments[arguments.length - 1]; const args = Array.from(arguments).slice(0, -1); Promise.resolve((function(){${script}}).apply(null, args)).then(value => done({value}), error => done({error: String(error)}));`,
368
+ ...args
369
+ );
370
+ if (result.error) {
371
+ throw new Error(result.error);
372
+ }
373
+ const [value, href] = result.value;
374
+ this._url = href;
375
+ return value;
376
+ }
377
+
378
+ /** Fail before subscribing or creating files for unsupported Safari features. */
379
+ requireFeature(feature) {
380
+ if (this.browserName?.toLowerCase().startsWith('safari')) {
381
+ throw new SafariUnsupportedError(feature);
382
+ }
383
+ }
384
+
385
+ setRequestInterception() {
386
+ return Promise.resolve().then(() => {
387
+ this.requireFeature('network interception');
388
+ throw new Error('Network interception requires a supporting engine');
389
+ });
390
+ }
391
+
392
+ route() {
393
+ return this.setRequestInterception();
394
+ }
395
+
396
+ /** W3C window handles include both tabs and windows. */
397
+ async windows() {
398
+ return await this.driver.getAllWindowHandles();
399
+ }
400
+
401
+ async newWindow(type = 'tab') {
402
+ await this.driver.switchTo().newWindow(type);
403
+ this._contextId = await this.driver.getWindowHandle();
404
+ await this.syncUrl();
405
+ return this._contextId;
406
+ }
407
+
408
+ async switchToWindow(handle) {
409
+ await this.driver.switchTo().window(handle);
410
+ this._contextId = handle;
411
+ await this.syncUrl();
412
+ }
413
+
361
414
  /**
362
415
  * Puppeteer's evaluateOnNewDocument, as a BiDi preload script.
363
416
  *
@@ -626,6 +679,7 @@ export class WebDriverPage extends EventEmitter {
626
679
  * @returns {Promise<Buffer>}
627
680
  */
628
681
  async pdf(options = {}) {
682
+ this.requireFeature('PDF');
629
683
  const buffer = Buffer.from(
630
684
  await this.driver.printPage(toPrintOptions(options)),
631
685
  'base64'
@@ -709,6 +763,14 @@ export class WebDriverPage extends EventEmitter {
709
763
  * @returns {Promise<WebDriverPage>}
710
764
  */
711
765
  export async function createWebDriverPage(driver, options = {}) {
766
+ if (!options.browserName) {
767
+ const capabilities = await driver.getCapabilities();
768
+ options = {
769
+ ...options,
770
+ browserName:
771
+ capabilities?.get?.('browserName') ?? capabilities?.browserName,
772
+ };
773
+ }
712
774
  const page = new WebDriverPage(driver, options);
713
775
  await page.contextId();
714
776
  await page.syncUrl();
@@ -117,6 +117,31 @@ function isPlaywrightTextSelector(selector) {
117
117
  return selector.includes(':has-text(') || selector.includes(':text-is(');
118
118
  }
119
119
 
120
+ /**
121
+ * Split `base:<pseudo>("text")` into its base selector and text.
122
+ *
123
+ * String operations rather than a regular expression: the former
124
+ * /^(.+?):has-text\("(.+?)"\)$/ backtracked quadratically on long selectors
125
+ * that do not match (CodeQL js/polynomial-redos). The base is everything before
126
+ * the first `:<pseudo>("`, as the lazy `(.+?)` made it; base and text must be
127
+ * non-empty.
128
+ * @param {string} selector - Selector to split
129
+ * @param {string} pseudo - Pseudo-class name, e.g. 'has-text'
130
+ * @returns {{baseSelector: string, text: string}|null} - Parts, or null
131
+ */
132
+ function splitTextPseudo(selector, pseudo) {
133
+ const open = `:${pseudo}("`;
134
+ const start = selector.indexOf(open, 1);
135
+ if (start === -1 || !selector.endsWith('")')) {
136
+ return null;
137
+ }
138
+ const text = selector.slice(start + open.length, -2);
139
+ if (!text) {
140
+ return null;
141
+ }
142
+ return { baseSelector: selector.slice(0, start), text };
143
+ }
144
+
120
145
  /**
121
146
  * Parse a Playwright text selector to extract base selector and text
122
147
  * @param {string} selector - Playwright text selector like 'a:has-text("text")'
@@ -124,22 +149,14 @@ function isPlaywrightTextSelector(selector) {
124
149
  */
125
150
  function parsePlaywrightTextSelector(selector) {
126
151
  // Match patterns like 'a:has-text("text")' or 'button:text-is("exact text")'
127
- const hasTextMatch = selector.match(/^(.+?):has-text\("(.+?)"\)$/);
128
- if (hasTextMatch) {
129
- return {
130
- baseSelector: hasTextMatch[1],
131
- text: hasTextMatch[2],
132
- exact: false,
133
- };
152
+ const hasText = splitTextPseudo(selector, 'has-text');
153
+ if (hasText) {
154
+ return { ...hasText, exact: false };
134
155
  }
135
156
 
136
- const textIsMatch = selector.match(/^(.+?):text-is\("(.+?)"\)$/);
137
- if (textIsMatch) {
138
- return {
139
- baseSelector: textIsMatch[1],
140
- text: textIsMatch[2],
141
- exact: true,
142
- };
157
+ const textIs = splitTextPseudo(selector, 'text-is');
158
+ if (textIs) {
159
+ return { ...textIs, exact: true };
143
160
  }
144
161
 
145
162
  return null;
package/src/exports.js CHANGED
@@ -322,3 +322,9 @@ export {
322
322
  redactUrl,
323
323
  redactValue,
324
324
  } from './traces/redaction.js';
325
+
326
+ export {
327
+ SafariSetupError,
328
+ SafariUnsupportedError,
329
+ openSafariSettings,
330
+ } from './browser/safari-support.js';
@@ -109,8 +109,11 @@ export async function startProbeServer(probeSource) {
109
109
  });
110
110
  },
111
111
  async close() {
112
+ // Stop listening before dropping connections, so none can slip in
113
+ // between and keep close() waiting (issue #128).
114
+ const closed = new Promise((resolve) => server.close(resolve));
112
115
  server.closeAllConnections?.();
113
- await new Promise((resolve) => server.close(resolve));
116
+ await closed;
114
117
  },
115
118
  };
116
119
  }
@@ -176,7 +176,18 @@ function expandBrowserTestEntries(scenarios, engines) {
176
176
  }
177
177
 
178
178
  function sanitizeArtifactName(value) {
179
- return value.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
179
+ const name = value.replace(/[^a-zA-Z0-9._-]+/g, '-');
180
+ // Trimmed by index: /^-+|-+$/g backtracks quadratically on a long run of
181
+ // inner dashes (CodeQL js/polynomial-redos).
182
+ let start = 0;
183
+ let end = name.length;
184
+ while (start < end && name[start] === '-') {
185
+ start++;
186
+ }
187
+ while (end > start && name[end - 1] === '-') {
188
+ end--;
189
+ }
190
+ return name.slice(start, end);
180
191
  }
181
192
 
182
193
  function closeBrowserLike(browser) {
@@ -93,6 +93,7 @@ function validateLinksOptions(links) {
93
93
  * @returns {Promise<Object>} The running trace
94
94
  */
95
95
  export async function startTrace(options = {}) {
96
+ requireTraceSupport(options);
96
97
  const {
97
98
  commander,
98
99
  page = commander?.page,
@@ -468,3 +469,7 @@ export async function startTrace(options = {}) {
468
469
  },
469
470
  };
470
471
  }
472
+
473
+ function requireTraceSupport(options) {
474
+ (options.page ?? options.commander?.page)?.requireFeature?.('tracing');
475
+ }
@@ -5,6 +5,7 @@ import { createServer } from 'node:http';
5
5
  import { describe, it } from 'node:test';
6
6
  import { launchBrowser } from '../../src/browser/launcher.js';
7
7
  import { makeBrowserCommander } from '../../src/factory.js';
8
+ import { closeServer } from '../helpers/fixture-server.js';
8
9
  import {
9
10
  CHROME_LAUNCH_OPTIONS,
10
11
  PARITY_CHROME,
@@ -50,7 +51,7 @@ describe('Common native engine API', { skip: !process.env.RUN_E2E }, () => {
50
51
  );
51
52
  } finally {
52
53
  await session?.close();
53
- await new Promise((resolve) => server.close(resolve));
54
+ await closeServer(server);
54
55
  }
55
56
  }
56
57
  );