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.
- package/.jscpd-baseline.json +6 -4
- package/CHANGELOG.md +32 -0
- package/package.json +2 -2
- package/scripts/check-duplication.mjs +176 -0
- package/scripts/create-manual-changeset.mjs +3 -1
- package/scripts/format-release-notes.mjs +8 -3
- package/scripts/merge-changesets.mjs +4 -1
- package/scripts/validate-changeset.mjs +23 -10
- package/scripts/version-and-commit.mjs +8 -10
- package/src/browser/browser-sources.json +10 -2
- package/src/browser/launcher.js +14 -0
- package/src/browser/real-browser.js +5 -0
- package/src/browser/safari-launch.js +42 -0
- package/src/browser/safari-support.js +98 -0
- package/src/browser/storage-state.js +28 -8
- package/src/browser/webdriver.js +132 -21
- package/src/cli/sessions.js +5 -0
- package/src/core/webdriver-page.js +62 -0
- package/src/elements/selectors.js +31 -14
- package/src/exports.js +6 -0
- package/src/parity/harness.js +4 -1
- package/src/tests/index.js +12 -1
- package/src/traces/recorder.js +5 -0
- package/tests/e2e/engine-matrix.e2e.test.js +2 -1
- package/tests/e2e/safari.e2e.test.js +105 -0
- package/tests/e2e/webdriver.e2e.test.js +2 -1
- package/tests/helpers/fixture-server.js +18 -1
- package/tests/unit/browser/real-browser.test.js +1 -1
- package/tests/unit/browser/safari.test.js +192 -0
- package/tests/unit/browser/webdriver.test.js +148 -3
- package/tests/unit/cli/dispatcher.test.js +1 -1
- package/tests/unit/elements/selectors.test.js +75 -0
- package/tests/unit/helpers/fixture-server.test.js +60 -0
- package/tests/unit/playwright-protocol-coverage.test.js +9 -6
- package/tests/unit/puppeteer-bindings-coverage.test.js +28 -7
- package/tests/unit/scripts/bot-commit-attribution.test.js +70 -0
- package/tests/unit/scripts/check-duplication.test.js +139 -0
- package/tests/unit/scripts/check-pipeline-status.test.js +264 -52
- package/tests/unit/scripts/check-version-modification.test.js +107 -1
- package/tests/unit/scripts/ci-timeout-budgets.test.js +15 -2
- package/tests/unit/scripts/ci-workflow-policy.test.js +91 -7
- package/tests/unit/scripts/command-stream-errexit.test.js +83 -41
- package/tests/unit/scripts/e2e-suite-coverage.test.js +174 -0
- package/tests/unit/scripts/github-actions-log.test.js +99 -0
- package/tests/unit/scripts/links-workflow.test.js +42 -0
- package/tests/unit/scripts/preflight-credentials.test.js +527 -0
- package/tests/unit/scripts/run-with-budget-warning.test.js +212 -6
- package/tests/unit/scripts/use-module-integration.test.js +2 -9
- package/tests/unit/scripts/use-module.test.js +20 -0
- package/tests/unit/scripts/validate-changeset.test.js +127 -0
- package/tests/unit/scripts/zizmor-version.test.js +82 -0
- 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({
|
|
35
|
-
|
|
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
|
|
43
|
+
const origins = storageState?.origins ?? [];
|
|
39
44
|
if (origins.length) {
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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
|
-
:
|
|
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 (
|
|
71
|
+
if (
|
|
72
|
+
cookies.length ||
|
|
73
|
+
(origins.length && page.browserName?.toLowerCase().startsWith('safari'))
|
|
74
|
+
) {
|
|
55
75
|
await page.goto(originalUrl);
|
|
56
76
|
}
|
|
57
77
|
}
|
package/src/browser/webdriver.js
CHANGED
|
@@ -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 '
|
|
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
|
|
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 =
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
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
|
-
|
|
671
|
+
const failure = withDriverContext(error, { serverUrl, exitCode, output });
|
|
672
|
+
throw safari ? safariLaunchError(failure, browser) : failure;
|
|
562
673
|
}
|
|
563
674
|
}
|
|
564
675
|
|
package/src/cli/sessions.js
CHANGED
|
@@ -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
|
|
128
|
-
if (
|
|
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
|
|
137
|
-
if (
|
|
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
package/src/parity/harness.js
CHANGED
|
@@ -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
|
|
116
|
+
await closed;
|
|
114
117
|
},
|
|
115
118
|
};
|
|
116
119
|
}
|
package/src/tests/index.js
CHANGED
|
@@ -176,7 +176,18 @@ function expandBrowserTestEntries(scenarios, engines) {
|
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
function sanitizeArtifactName(value) {
|
|
179
|
-
|
|
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) {
|
package/src/traces/recorder.js
CHANGED
|
@@ -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
|
|
54
|
+
await closeServer(server);
|
|
54
55
|
}
|
|
55
56
|
}
|
|
56
57
|
);
|