browser-commander 0.25.0 → 0.26.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.
- package/CHANGELOG.md +8 -0
- package/package.json +1 -1
- 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 +64 -19
- package/src/cli/sessions.js +5 -0
- package/src/core/webdriver-page.js +62 -0
- package/src/exports.js +6 -0
- package/src/traces/recorder.js +5 -0
- package/tests/e2e/safari.e2e.test.js +104 -0
- 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 +48 -3
- package/tests/unit/cli/dispatcher.test.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.26.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 8f9ef1c: Add native Safari and Technology Preview W3C WebDriver control through real/common launchers and CLI, with isolated cookie seeding, setup diagnostics and typed unsupported feature errors.
|
|
8
|
+
|
|
9
|
+
Update the transitive shell-quote lock entry to fix GHSA-pqg4-j6r4-53mv and unblock npm audit.
|
|
10
|
+
|
|
3
11
|
## 0.25.0
|
|
4
12
|
|
|
5
13
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -766,7 +766,9 @@
|
|
|
766
766
|
},
|
|
767
767
|
"default": {
|
|
768
768
|
"darwin": ["com.apple.safari"]
|
|
769
|
-
}
|
|
769
|
+
},
|
|
770
|
+
"executables": { "darwin": ["/usr/bin/safaridriver"] },
|
|
771
|
+
"controlProtocol": "webdriver"
|
|
770
772
|
},
|
|
771
773
|
{
|
|
772
774
|
"id": "safari-technology-preview",
|
|
@@ -781,7 +783,13 @@
|
|
|
781
783
|
},
|
|
782
784
|
"default": {
|
|
783
785
|
"darwin": ["com.apple.safaritechnologypreview"]
|
|
784
|
-
}
|
|
786
|
+
},
|
|
787
|
+
"executables": {
|
|
788
|
+
"darwin": [
|
|
789
|
+
"/Applications/Safari Technology Preview.app/Contents/MacOS/safaridriver"
|
|
790
|
+
]
|
|
791
|
+
},
|
|
792
|
+
"controlProtocol": "webdriver"
|
|
785
793
|
},
|
|
786
794
|
{
|
|
787
795
|
"id": "whale",
|
package/src/browser/launcher.js
CHANGED
|
@@ -17,6 +17,8 @@ import { resolveSystemBrowserExecutable } from './system-browser.js';
|
|
|
17
17
|
import { applyFingerprint } from '../fingerprint/apply.js';
|
|
18
18
|
import { attachDownloads } from '../downloads/attach.js';
|
|
19
19
|
import { launchWebDriver } from './webdriver.js';
|
|
20
|
+
import { launchSafari } from './safari-launch.js';
|
|
21
|
+
import { isSafariChannel } from './safari-support.js';
|
|
20
22
|
import {
|
|
21
23
|
loadStorageState,
|
|
22
24
|
restorePlaywrightStorageState,
|
|
@@ -300,6 +302,18 @@ export async function launchBrowserWithDependencies(
|
|
|
300
302
|
options = {},
|
|
301
303
|
dependencies = {}
|
|
302
304
|
) {
|
|
305
|
+
if (isSafariChannel(options.channel ?? options.browser)) {
|
|
306
|
+
validateLaunchMode({
|
|
307
|
+
engine: options.engine ?? 'playwright',
|
|
308
|
+
launch: options.launch ?? 'real',
|
|
309
|
+
attach: options.attach,
|
|
310
|
+
});
|
|
311
|
+
return await launchSafari(options, dependencies);
|
|
312
|
+
}
|
|
313
|
+
return await launchNonSafariBrowser(options, dependencies);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function launchNonSafariBrowser(options, dependencies) {
|
|
303
317
|
const {
|
|
304
318
|
engine = 'playwright',
|
|
305
319
|
launch = 'real',
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { findBrowserSource } from './browser-sources.js';
|
|
4
|
+
import { launchSafari } from './safari-launch.js';
|
|
5
|
+
import { isSafariChannel } from './safari-support.js';
|
|
4
6
|
|
|
5
7
|
import { startProcess } from '../utilities/subprocess.js';
|
|
6
8
|
import {
|
|
@@ -563,6 +565,9 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
563
565
|
options = {},
|
|
564
566
|
dependencies = {}
|
|
565
567
|
) {
|
|
568
|
+
if (isSafariChannel(options.channel ?? options.browser)) {
|
|
569
|
+
return launchSafari(options, dependencies);
|
|
570
|
+
}
|
|
566
571
|
const {
|
|
567
572
|
engine = 'playwright',
|
|
568
573
|
channel = 'chrome',
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { launchWebDriver } from './webdriver.js';
|
|
2
|
+
import { findBrowserSource } from './browser-sources.js';
|
|
3
|
+
import { validateSafariOptions } from './safari-support.js';
|
|
4
|
+
import { restoreWebDriverStorageState } from './storage-state.js';
|
|
5
|
+
|
|
6
|
+
/** Launch installed Safari through its native W3C WebDriver server. */
|
|
7
|
+
export async function launchSafari(options = {}, dependencies = {}) {
|
|
8
|
+
validateSafariOptions(options);
|
|
9
|
+
const channel = findBrowserSource(
|
|
10
|
+
options.channel ?? options.browser ?? 'safari'
|
|
11
|
+
).id;
|
|
12
|
+
const launched = await (dependencies.launchWebDriver ?? launchWebDriver)(
|
|
13
|
+
{
|
|
14
|
+
...options,
|
|
15
|
+
browser: channel,
|
|
16
|
+
driverPath: options.driverPath ?? options.executablePath,
|
|
17
|
+
executablePath: undefined,
|
|
18
|
+
},
|
|
19
|
+
dependencies
|
|
20
|
+
);
|
|
21
|
+
try {
|
|
22
|
+
const state = options.storageState;
|
|
23
|
+
await restoreWebDriverStorageState({
|
|
24
|
+
page: launched.page,
|
|
25
|
+
storageState: state,
|
|
26
|
+
seedCookies: options.seedCookies,
|
|
27
|
+
});
|
|
28
|
+
} catch (error) {
|
|
29
|
+
await launched.close();
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
...launched,
|
|
34
|
+
browser: launched.driver,
|
|
35
|
+
engine: 'selenium',
|
|
36
|
+
controlProtocol: 'webdriver',
|
|
37
|
+
executablePath: launched.driverPath,
|
|
38
|
+
browserProcess: launched.driverProcess,
|
|
39
|
+
downloads: null,
|
|
40
|
+
launch: options.launch ?? 'real',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -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
|
@@ -40,6 +40,13 @@ import {
|
|
|
40
40
|
} from './profile-directory.js';
|
|
41
41
|
import { buildRealBrowserArgs } from './real-browser.js';
|
|
42
42
|
import { resolveRestrictions } from './restrictions.js';
|
|
43
|
+
import { findBrowserSource } from './browser-sources.js';
|
|
44
|
+
import {
|
|
45
|
+
isSafariChannel,
|
|
46
|
+
SafariUnsupportedError,
|
|
47
|
+
safariLaunchError,
|
|
48
|
+
validateSafariOptions,
|
|
49
|
+
} from './safari-support.js';
|
|
43
50
|
|
|
44
51
|
const require = createRequire(import.meta.url);
|
|
45
52
|
const accessAsync = promisify(access);
|
|
@@ -76,6 +83,8 @@ export const CHROMEDRIVER_DEFAULT_SWITCHES = Object.freeze([
|
|
|
76
83
|
const DRIVERS = Object.freeze({
|
|
77
84
|
chrome: { name: 'chromedriver', builder: 'chrome' },
|
|
78
85
|
firefox: { name: 'geckodriver', builder: 'firefox' },
|
|
86
|
+
safari: { name: 'safaridriver', builder: 'safari' },
|
|
87
|
+
'safari-technology-preview': { name: 'safaridriver', builder: 'safari' },
|
|
79
88
|
});
|
|
80
89
|
|
|
81
90
|
/**
|
|
@@ -102,7 +111,7 @@ export function loadSelenium(subpath) {
|
|
|
102
111
|
function assertBrowser(browser) {
|
|
103
112
|
if (!DRIVERS[browser]) {
|
|
104
113
|
throw new Error(
|
|
105
|
-
`Unsupported WebDriver browser: ${browser}. Expected 'chrome' or '
|
|
114
|
+
`Unsupported WebDriver browser: ${browser}. Expected 'chrome', 'firefox' or 'safari'`
|
|
106
115
|
);
|
|
107
116
|
}
|
|
108
117
|
return DRIVERS[browser];
|
|
@@ -182,6 +191,20 @@ export async function resolveWebDriverExecutable({
|
|
|
182
191
|
managerPath,
|
|
183
192
|
} = {}) {
|
|
184
193
|
const { name } = assertBrowser(browser);
|
|
194
|
+
if (isSafariChannel(browser)) {
|
|
195
|
+
if (platform !== 'darwin') {
|
|
196
|
+
throw new SafariUnsupportedError('Safari launch outside macOS');
|
|
197
|
+
}
|
|
198
|
+
const file = driverPath ?? findBrowserSource(browser).executables.darwin[0];
|
|
199
|
+
if (!(await isExecutable(file, checkAccess))) {
|
|
200
|
+
throw new Error(`Safari WebDriver executable is not accessible: ${file}`);
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
driverPath: file,
|
|
204
|
+
browserPath: null,
|
|
205
|
+
source: 'bundled-safaridriver',
|
|
206
|
+
};
|
|
207
|
+
}
|
|
185
208
|
if (driverPath) {
|
|
186
209
|
if (!(await isExecutable(driverPath, checkAccess))) {
|
|
187
210
|
throw new Error(`WebDriver executable is not accessible: ${driverPath}`);
|
|
@@ -237,6 +260,9 @@ export async function resolveWebDriverExecutable({
|
|
|
237
260
|
* @returns {string[]}
|
|
238
261
|
*/
|
|
239
262
|
export function buildDriverServerArgs(browser, port) {
|
|
263
|
+
if (isSafariChannel(browser)) {
|
|
264
|
+
return ['--port', String(port)];
|
|
265
|
+
}
|
|
240
266
|
if (assertBrowser(browser).name === 'geckodriver') {
|
|
241
267
|
return ['--port', String(port), '--host', LOOPBACK_HOST];
|
|
242
268
|
}
|
|
@@ -306,6 +332,16 @@ export function buildBrowserOptions({
|
|
|
306
332
|
}) {
|
|
307
333
|
let options;
|
|
308
334
|
let browserArgs;
|
|
335
|
+
if (isSafariChannel(browser)) {
|
|
336
|
+
options = new selenium.webdriver.Capabilities();
|
|
337
|
+
options.set(
|
|
338
|
+
'browserName',
|
|
339
|
+
browser === 'safari-technology-preview'
|
|
340
|
+
? 'Safari Technology Preview'
|
|
341
|
+
: 'safari'
|
|
342
|
+
);
|
|
343
|
+
return { options, args: [] };
|
|
344
|
+
}
|
|
309
345
|
if (browser === 'chrome') {
|
|
310
346
|
options = new selenium.chrome.Options();
|
|
311
347
|
if (debuggerAddress) {
|
|
@@ -387,7 +423,9 @@ function buildSession({ selenium, serverUrl, browser, options }) {
|
|
|
387
423
|
const builder = new selenium.webdriver.Builder()
|
|
388
424
|
.usingServer(serverUrl)
|
|
389
425
|
.forBrowser(DRIVERS[browser].builder);
|
|
390
|
-
if (browser
|
|
426
|
+
if (isSafariChannel(browser)) {
|
|
427
|
+
builder.withCapabilities(options);
|
|
428
|
+
} else if (browser === 'chrome') {
|
|
391
429
|
builder.setChromeOptions(options);
|
|
392
430
|
} else {
|
|
393
431
|
builder.setFirefoxOptions(options);
|
|
@@ -462,6 +500,10 @@ export async function launchWebDriver(options = {}, dependencies = {}) {
|
|
|
462
500
|
debuggerAddress,
|
|
463
501
|
} = options;
|
|
464
502
|
assertBrowser(browser);
|
|
503
|
+
const safari = isSafariChannel(browser);
|
|
504
|
+
if (safari) {
|
|
505
|
+
validateSafariOptions(options);
|
|
506
|
+
}
|
|
465
507
|
if (debuggerAddress && browser !== 'chrome') {
|
|
466
508
|
throw new Error('debuggerAddress is only supported by Chrome WebDriver');
|
|
467
509
|
}
|
|
@@ -476,23 +518,17 @@ export async function launchWebDriver(options = {}, dependencies = {}) {
|
|
|
476
518
|
const startProcess = dependencies.startProcess ?? defaultStartProcess;
|
|
477
519
|
|
|
478
520
|
const resolved = await resolveWebDriverExecutable({
|
|
521
|
+
...dependencies,
|
|
479
522
|
browser,
|
|
480
523
|
driverPath: requestedDriverPath,
|
|
481
524
|
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
525
|
});
|
|
491
526
|
|
|
492
|
-
const temporaryProfile = !debuggerAddress && !requestedUserDataDir;
|
|
493
|
-
const userDataDir =
|
|
494
|
-
|
|
495
|
-
|
|
527
|
+
const temporaryProfile = !safari && !debuggerAddress && !requestedUserDataDir;
|
|
528
|
+
const userDataDir =
|
|
529
|
+
safari || debuggerAddress
|
|
530
|
+
? undefined
|
|
531
|
+
: await resolveProfile(browser, requestedUserDataDir);
|
|
496
532
|
const childEnv = driverEnvironment(restrictions, env);
|
|
497
533
|
|
|
498
534
|
let driverProcess;
|
|
@@ -506,9 +542,11 @@ export async function launchWebDriver(options = {}, dependencies = {}) {
|
|
|
506
542
|
{ env: childEnv, forwardOutput: verbose }
|
|
507
543
|
);
|
|
508
544
|
let output = '';
|
|
509
|
-
driverProcess.
|
|
510
|
-
|
|
511
|
-
|
|
545
|
+
for (const stream of [driverProcess.stdout, driverProcess.stderr]) {
|
|
546
|
+
stream?.on('data', (chunk) => {
|
|
547
|
+
output = `${output}${chunk}`.slice(-4000);
|
|
548
|
+
});
|
|
549
|
+
}
|
|
512
550
|
await waitForDriverReady(serverUrl, {
|
|
513
551
|
fetchImplementation: dependencies.fetch ?? fetch,
|
|
514
552
|
timeout: startupTimeout,
|
|
@@ -536,7 +574,14 @@ export async function launchWebDriver(options = {}, dependencies = {}) {
|
|
|
536
574
|
browser,
|
|
537
575
|
options: built.options,
|
|
538
576
|
});
|
|
539
|
-
|
|
577
|
+
// Fresh Safari sessions can report an empty URL until first navigation.
|
|
578
|
+
if (safari) {
|
|
579
|
+
await driver.get('about:blank');
|
|
580
|
+
}
|
|
581
|
+
const page = await createWebDriverPage(driver, {
|
|
582
|
+
...dependencies.pageOptions,
|
|
583
|
+
...(safari ? { browserName: 'safari' } : {}),
|
|
584
|
+
});
|
|
540
585
|
|
|
541
586
|
const close = memoize(() =>
|
|
542
587
|
teardown({ page, driver, driverProcess, temporaryProfile, userDataDir })
|
|
@@ -558,7 +603,7 @@ export async function launchWebDriver(options = {}, dependencies = {}) {
|
|
|
558
603
|
};
|
|
559
604
|
} catch (error) {
|
|
560
605
|
await teardown({ driver, driverProcess, temporaryProfile, userDataDir });
|
|
561
|
-
throw error;
|
|
606
|
+
throw safari ? safariLaunchError(error, browser) : error;
|
|
562
607
|
}
|
|
563
608
|
}
|
|
564
609
|
|
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();
|
package/src/exports.js
CHANGED
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
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// feature-parity: safari.control@native-typed
|
|
2
|
+
import { it } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { createServer } from 'node:http';
|
|
5
|
+
import {
|
|
6
|
+
launchRealBrowser,
|
|
7
|
+
SafariUnsupportedError,
|
|
8
|
+
makeBrowserCommander,
|
|
9
|
+
} from '../../src/index.js';
|
|
10
|
+
import { createDispatcher } from '../../src/cli/dispatcher.js';
|
|
11
|
+
|
|
12
|
+
it(
|
|
13
|
+
'Safari drives a local page in an isolated automation session',
|
|
14
|
+
{
|
|
15
|
+
skip:
|
|
16
|
+
process.platform !== 'darwin' || process.env.RUN_SAFARI_E2E !== 'true',
|
|
17
|
+
timeout: 60_000,
|
|
18
|
+
},
|
|
19
|
+
async () => {
|
|
20
|
+
const server = createServer((_request, response) => {
|
|
21
|
+
response.writeHead(200, { 'content-type': 'text/html' });
|
|
22
|
+
response.end(
|
|
23
|
+
'<title>Safari smoke</title><input id="name"><button id="go" onclick="document.querySelector(\'#out\').textContent=document.querySelector(\'#name\').value">Go</button><p id="out"></p>'
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
27
|
+
const url = `http://127.0.0.1:${server.address().port}/`;
|
|
28
|
+
let session;
|
|
29
|
+
let commander;
|
|
30
|
+
const cli = createDispatcher();
|
|
31
|
+
const phase = (message) =>
|
|
32
|
+
console.info(`[Safari smoke] ${new Date().toISOString()} ${message}`);
|
|
33
|
+
try {
|
|
34
|
+
phase('launch seeded session');
|
|
35
|
+
session = await launchRealBrowser({
|
|
36
|
+
channel: 'safari',
|
|
37
|
+
startupTimeout: 20_000,
|
|
38
|
+
seedCookies: [{ name: 'seed', value: 'yes', url, httpOnly: true }],
|
|
39
|
+
});
|
|
40
|
+
const { page, driver } = session;
|
|
41
|
+
phase('exercise page commands');
|
|
42
|
+
commander = makeBrowserCommander({ page });
|
|
43
|
+
await page.goto(url);
|
|
44
|
+
assert.equal(commander.engine, 'selenium');
|
|
45
|
+
await commander.fill({ selector: '#name', text: 'Safari' });
|
|
46
|
+
await commander.click({ selector: '#go' });
|
|
47
|
+
assert.equal(
|
|
48
|
+
await page.evaluate(() => document.querySelector('#out').textContent),
|
|
49
|
+
'Safari'
|
|
50
|
+
);
|
|
51
|
+
assert.equal(await page.evaluateAsync(async (value) => value + 1, 4), 5);
|
|
52
|
+
assert.ok((await page.screenshot()).length > 100);
|
|
53
|
+
assert.equal((await driver.manage().getCookie('seed')).value, 'yes');
|
|
54
|
+
phase('exercise windows and tabs');
|
|
55
|
+
const initial = await driver.getWindowHandle();
|
|
56
|
+
for (const type of ['tab', 'window']) {
|
|
57
|
+
const handle = await page.newWindow(type);
|
|
58
|
+
assert.ok((await page.windows()).includes(handle));
|
|
59
|
+
await page.goto(url);
|
|
60
|
+
await driver.close();
|
|
61
|
+
await page.switchToWindow(initial);
|
|
62
|
+
}
|
|
63
|
+
await assert.rejects(() => page.pdf(), SafariUnsupportedError);
|
|
64
|
+
await assert.rejects(
|
|
65
|
+
() => page.setRequestInterception(true),
|
|
66
|
+
SafariUnsupportedError
|
|
67
|
+
);
|
|
68
|
+
phase('close seeded session');
|
|
69
|
+
await session.close();
|
|
70
|
+
phase('launch fresh session');
|
|
71
|
+
session = await launchRealBrowser({ channel: 'safari' });
|
|
72
|
+
await session.page.goto(url);
|
|
73
|
+
assert.equal(
|
|
74
|
+
(await session.driver.manage().getCookies()).some(
|
|
75
|
+
(cookie) => cookie.name === 'seed'
|
|
76
|
+
),
|
|
77
|
+
false
|
|
78
|
+
);
|
|
79
|
+
phase('close fresh session');
|
|
80
|
+
await session.close();
|
|
81
|
+
phase('launch CLI session');
|
|
82
|
+
await cli.dispatch('session.launch', {
|
|
83
|
+
browser: 'safari',
|
|
84
|
+
engine: 'selenium',
|
|
85
|
+
});
|
|
86
|
+
phase('exercise CLI commands');
|
|
87
|
+
await cli.dispatch('page.goto', { url });
|
|
88
|
+
assert.equal(
|
|
89
|
+
(await cli.dispatch('page.eval', { expression: 'document.title' }))
|
|
90
|
+
.value,
|
|
91
|
+
'Safari smoke'
|
|
92
|
+
);
|
|
93
|
+
} finally {
|
|
94
|
+
phase('close CLI session');
|
|
95
|
+
await cli.close();
|
|
96
|
+
phase('destroy commander');
|
|
97
|
+
await commander?.destroy();
|
|
98
|
+
phase('close remaining session and server');
|
|
99
|
+
await session?.close();
|
|
100
|
+
await new Promise((resolve) => server.close(resolve));
|
|
101
|
+
phase('cleanup complete');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
);
|
|
@@ -204,7 +204,7 @@ describe('launchAndConnectRealBrowser', () => {
|
|
|
204
204
|
});
|
|
205
205
|
|
|
206
206
|
it('rejects non-CDP catalogue browsers before resolving or spawning them', async () => {
|
|
207
|
-
for (const channel of ['firefox', 'librewolf', '
|
|
207
|
+
for (const channel of ['firefox', 'librewolf', 'duckduckgo']) {
|
|
208
208
|
await assert.rejects(
|
|
209
209
|
launchAndConnectRealBrowserWithDependencies(
|
|
210
210
|
{ channel },
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// feature-parity: safari.control@native-typed safari.setup@native-typed safari.seed@native-typed safari.unsupported@native-typed
|
|
2
|
+
import { it } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import vm from 'node:vm';
|
|
5
|
+
import { findBrowserSource } from '../../../src/browser/browser-sources.js';
|
|
6
|
+
import {
|
|
7
|
+
buildDriverServerArgs,
|
|
8
|
+
resolveWebDriverExecutable,
|
|
9
|
+
} from '../../../src/browser/webdriver.js';
|
|
10
|
+
import {
|
|
11
|
+
WebDriverPage,
|
|
12
|
+
createWebDriverPage,
|
|
13
|
+
} from '../../../src/core/webdriver-page.js';
|
|
14
|
+
import { createMockDriver } from '../../helpers/webdriver-mocks.js';
|
|
15
|
+
import { launchBrowserWithDependencies } from '../../../src/browser/launcher.js';
|
|
16
|
+
import { launchAndConnectRealBrowserWithDependencies } from '../../../src/browser/real-browser.js';
|
|
17
|
+
import {
|
|
18
|
+
SafariSetupError,
|
|
19
|
+
SafariUnsupportedError,
|
|
20
|
+
safariLaunchError,
|
|
21
|
+
openSafariSettings,
|
|
22
|
+
} from '../../../src/browser/safari-support.js';
|
|
23
|
+
import { startTrace } from '../../../src/traces/recorder.js';
|
|
24
|
+
import {
|
|
25
|
+
buildLaunchOptions,
|
|
26
|
+
sessionFromLaunch,
|
|
27
|
+
} from '../../../src/cli/sessions.js';
|
|
28
|
+
|
|
29
|
+
it('catalogue exposes both Safari WebDriver executables', () => {
|
|
30
|
+
for (const channel of ['safari', 'safari-tp']) {
|
|
31
|
+
const source = findBrowserSource(channel);
|
|
32
|
+
assert.equal(source.controlProtocol, 'webdriver');
|
|
33
|
+
assert.ok(source.executables.darwin[0].endsWith('/safaridriver'));
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('Safari channels route real/common launch and CLI to Selenium and seed cookies', async () => {
|
|
38
|
+
for (const launch of [
|
|
39
|
+
launchBrowserWithDependencies,
|
|
40
|
+
launchAndConnectRealBrowserWithDependencies,
|
|
41
|
+
]) {
|
|
42
|
+
const driver = createMockDriver({
|
|
43
|
+
capabilities: { browserName: 'safari' },
|
|
44
|
+
});
|
|
45
|
+
const page = await createWebDriverPage(driver);
|
|
46
|
+
let calls = 0;
|
|
47
|
+
const launched = await launch(
|
|
48
|
+
{
|
|
49
|
+
channel: 'safari-tp',
|
|
50
|
+
seedCookies: [
|
|
51
|
+
{
|
|
52
|
+
name: 'auth',
|
|
53
|
+
value: 'yes',
|
|
54
|
+
domain: '.example.test',
|
|
55
|
+
secure: true,
|
|
56
|
+
httpOnly: true,
|
|
57
|
+
expires: -1,
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
launchWebDriver: async (options) => {
|
|
63
|
+
calls++;
|
|
64
|
+
assert.equal(options.browser, 'safari-technology-preview');
|
|
65
|
+
return {
|
|
66
|
+
driver,
|
|
67
|
+
page,
|
|
68
|
+
close: () => driver.quit(),
|
|
69
|
+
driverPath: '/Safari TP/safaridriver',
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
resolveExecutable: () => assert.fail('Safari must bypass CDP'),
|
|
73
|
+
resolveSystem: () =>
|
|
74
|
+
assert.fail('Safari must bypass Chromium resolution'),
|
|
75
|
+
}
|
|
76
|
+
);
|
|
77
|
+
assert.equal(calls, 1);
|
|
78
|
+
assert.equal(launched.engine, 'selenium');
|
|
79
|
+
assert.equal(launched.browser, driver);
|
|
80
|
+
assert.equal(driver.state.url, 'about:blank');
|
|
81
|
+
assert.ok(
|
|
82
|
+
driver.calls.some(
|
|
83
|
+
([method, url]) => method === 'get' && url === 'https://example.test'
|
|
84
|
+
)
|
|
85
|
+
);
|
|
86
|
+
assert.ok(
|
|
87
|
+
driver.calls.some(
|
|
88
|
+
([method, cookie]) => method === 'addCookie' && cookie.httpOnly
|
|
89
|
+
)
|
|
90
|
+
);
|
|
91
|
+
assert.equal(sessionFromLaunch('playwright', launched).engine, 'selenium');
|
|
92
|
+
await launched.close();
|
|
93
|
+
}
|
|
94
|
+
assert.equal(buildLaunchOptions({ browser: 'safari' }).channel, 'safari');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('Safari fails unsupported launch options before starting and page features before writes', async () => {
|
|
98
|
+
for (const options of [
|
|
99
|
+
{ headless: true },
|
|
100
|
+
{ userDataDir: '/tmp/profile' },
|
|
101
|
+
{ args: ['--flag'] },
|
|
102
|
+
{ fingerprint: {} },
|
|
103
|
+
{ downloads: true },
|
|
104
|
+
]) {
|
|
105
|
+
await assert.rejects(
|
|
106
|
+
() =>
|
|
107
|
+
launchBrowserWithDependencies(
|
|
108
|
+
{ channel: 'safari', ...options },
|
|
109
|
+
{ launchWebDriver: () => assert.fail('must reject before starting') }
|
|
110
|
+
),
|
|
111
|
+
SafariUnsupportedError
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
const page = new WebDriverPage(createMockDriver(), { browserName: 'safari' });
|
|
115
|
+
await assert.rejects(
|
|
116
|
+
() => page.pdf(),
|
|
117
|
+
(error) =>
|
|
118
|
+
error instanceof SafariUnsupportedError && error.feature === 'PDF'
|
|
119
|
+
);
|
|
120
|
+
await assert.rejects(
|
|
121
|
+
() => page.setRequestInterception(true),
|
|
122
|
+
SafariUnsupportedError
|
|
123
|
+
);
|
|
124
|
+
await assert.rejects(
|
|
125
|
+
() => startTrace({ page, output: '/must-not-create' }),
|
|
126
|
+
SafariUnsupportedError
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('Safari async evaluation uses execute/async and returns rejected promises as errors', async () => {
|
|
131
|
+
const driver = createMockDriver();
|
|
132
|
+
let mode = 'success';
|
|
133
|
+
driver.executeAsyncScript = (source, ...args) =>
|
|
134
|
+
new Promise((resolve) => {
|
|
135
|
+
vm.runInNewContext(`(function(){${source}}).apply(null, args)`, {
|
|
136
|
+
args: [...args, resolve],
|
|
137
|
+
location: { href: 'https://example.test' },
|
|
138
|
+
setTimeout,
|
|
139
|
+
mode,
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
const page = new WebDriverPage(driver, { browserName: 'safari' });
|
|
143
|
+
assert.equal(await page.evaluateAsync(async (value) => value + 1, 4), 5);
|
|
144
|
+
mode = 'error';
|
|
145
|
+
await assert.rejects(
|
|
146
|
+
() =>
|
|
147
|
+
page.evaluateAsync(async () => {
|
|
148
|
+
throw new Error('rejected');
|
|
149
|
+
}),
|
|
150
|
+
/rejected/
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('setup errors identify both authorization steps and offer an explicit settings opener', async () => {
|
|
155
|
+
for (const message of [
|
|
156
|
+
"You must enable the 'Allow Remote Automation' option",
|
|
157
|
+
'safaridriver requires --enable authorization',
|
|
158
|
+
]) {
|
|
159
|
+
const error = safariLaunchError(
|
|
160
|
+
new Error(message),
|
|
161
|
+
'safari-technology-preview'
|
|
162
|
+
);
|
|
163
|
+
assert.ok(error instanceof SafariSetupError);
|
|
164
|
+
assert.match(error.message, /Show features for web developers/);
|
|
165
|
+
assert.match(error.message, /admin password/);
|
|
166
|
+
assert.match(error.message, /Safari Technology Preview.app/);
|
|
167
|
+
assert.equal(typeof error.openSettings, 'function');
|
|
168
|
+
}
|
|
169
|
+
const other = new Error('port already in use');
|
|
170
|
+
assert.equal(safariLaunchError(other, 'safari'), other);
|
|
171
|
+
let argv;
|
|
172
|
+
await openSafariSettings({
|
|
173
|
+
channel: 'safari-tp',
|
|
174
|
+
platform: 'darwin',
|
|
175
|
+
run: async (file, args) => {
|
|
176
|
+
assert.equal(file, 'osascript');
|
|
177
|
+
argv = args;
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
assert.ok(argv.some((arg) => arg.includes('Safari Technology Preview')));
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('Safari uses its bundled driver and the native --port argument', async () => {
|
|
184
|
+
assert.deepEqual(buildDriverServerArgs('safari', 12345), ['--port', '12345']);
|
|
185
|
+
const result = await resolveWebDriverExecutable({
|
|
186
|
+
browser: 'safari',
|
|
187
|
+
platform: 'darwin',
|
|
188
|
+
checkAccess: async () => {},
|
|
189
|
+
runCommand: () => assert.fail('Safari needs no Selenium Manager'),
|
|
190
|
+
});
|
|
191
|
+
assert.equal(result.driverPath, '/usr/bin/safaridriver');
|
|
192
|
+
});
|
|
@@ -92,7 +92,7 @@ function createMockSelenium(driver = createMockDriver()) {
|
|
|
92
92
|
record,
|
|
93
93
|
driver,
|
|
94
94
|
selenium: {
|
|
95
|
-
webdriver: { Builder },
|
|
95
|
+
webdriver: { Builder, Capabilities: Options },
|
|
96
96
|
chrome: {
|
|
97
97
|
Options: class extends Options {
|
|
98
98
|
constructor() {
|
|
@@ -265,8 +265,8 @@ describe('resolveWebDriverExecutable', () => {
|
|
|
265
265
|
|
|
266
266
|
it('rejects browsers it has no driver for', async () => {
|
|
267
267
|
await assert.rejects(
|
|
268
|
-
() => resolveWebDriverExecutable({ browser: '
|
|
269
|
-
/Unsupported WebDriver browser:
|
|
268
|
+
() => resolveWebDriverExecutable({ browser: 'unknown-browser' }),
|
|
269
|
+
/Unsupported WebDriver browser: unknown-browser/
|
|
270
270
|
);
|
|
271
271
|
});
|
|
272
272
|
});
|
|
@@ -446,6 +446,51 @@ describe('launchWebDriver', () => {
|
|
|
446
446
|
});
|
|
447
447
|
});
|
|
448
448
|
|
|
449
|
+
function safariDependencies() {
|
|
450
|
+
return launchDependencies({
|
|
451
|
+
platform: 'darwin',
|
|
452
|
+
checkAccess: async () => {},
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
describe('Safari driver lifecycle', () => {
|
|
457
|
+
it('uses no disk profile or Chromium/BiDi capabilities and closes idempotently', async () => {
|
|
458
|
+
const { dependencies, started, mock } = safariDependencies();
|
|
459
|
+
mock.driver.state.url = '';
|
|
460
|
+
const result = await launchWebDriver(
|
|
461
|
+
{ browser: 'safari-technology-preview' },
|
|
462
|
+
dependencies
|
|
463
|
+
);
|
|
464
|
+
assert.equal(result.page.url(), 'about:blank');
|
|
465
|
+
assert.equal(result.userDataDir, undefined);
|
|
466
|
+
assert.equal(result.temporaryProfile, false);
|
|
467
|
+
assert.equal(
|
|
468
|
+
mock.record.builders[0].capabilities.capabilities.browserName,
|
|
469
|
+
'Safari Technology Preview'
|
|
470
|
+
);
|
|
471
|
+
assert.deepEqual(started[0].args, ['--port', '41001']);
|
|
472
|
+
assert.match(started[0].file, /Safari Technology Preview.app/);
|
|
473
|
+
await Promise.all([result.close(), result.close()]);
|
|
474
|
+
assert.equal(
|
|
475
|
+
mock.driver.calls.filter(([name]) => name === 'quit').length,
|
|
476
|
+
1
|
|
477
|
+
);
|
|
478
|
+
assert.ok(started[0].child.killed);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it('cleans up the driver if Safari authorization fails', async () => {
|
|
482
|
+
const { dependencies, started, mock } = safariDependencies();
|
|
483
|
+
mock.selenium.webdriver.Builder.prototype.build = () => {
|
|
484
|
+
throw new Error("You must enable the 'Allow Remote Automation' option");
|
|
485
|
+
};
|
|
486
|
+
await assert.rejects(
|
|
487
|
+
() => launchWebDriver({ browser: 'safari' }, dependencies),
|
|
488
|
+
(error) => error.code === 'SAFARI_SETUP_REQUIRED'
|
|
489
|
+
);
|
|
490
|
+
assert.ok(started[0].child.killed);
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
|
|
449
494
|
describe('connectWebDriver', () => {
|
|
450
495
|
it('opens a session on a running server with the given capabilities', async () => {
|
|
451
496
|
const mock = createMockSelenium();
|
|
@@ -280,7 +280,7 @@ describe('dispatcher: high-level methods', () => {
|
|
|
280
280
|
{ code: -32602 }
|
|
281
281
|
);
|
|
282
282
|
await assert.rejects(
|
|
283
|
-
dispatcher.dispatch('session.launch', { browser: '
|
|
283
|
+
dispatcher.dispatch('session.launch', { browser: 'unknown-browser' }),
|
|
284
284
|
{ code: -32602 }
|
|
285
285
|
);
|
|
286
286
|
await assert.rejects(dispatcher.dispatch('page.goto', { url: 'x' }), {
|