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,105 @@
|
|
|
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
|
+
import { closeServer } from '../helpers/fixture-server.js';
|
|
12
|
+
|
|
13
|
+
it(
|
|
14
|
+
'Safari drives a local page in an isolated automation session',
|
|
15
|
+
{
|
|
16
|
+
skip:
|
|
17
|
+
process.platform !== 'darwin' || process.env.RUN_SAFARI_E2E !== 'true',
|
|
18
|
+
timeout: 60_000,
|
|
19
|
+
},
|
|
20
|
+
async () => {
|
|
21
|
+
const server = createServer((_request, response) => {
|
|
22
|
+
response.writeHead(200, { 'content-type': 'text/html' });
|
|
23
|
+
response.end(
|
|
24
|
+
'<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>'
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
28
|
+
const url = `http://127.0.0.1:${server.address().port}/`;
|
|
29
|
+
let session;
|
|
30
|
+
let commander;
|
|
31
|
+
const cli = createDispatcher();
|
|
32
|
+
const phase = (message) =>
|
|
33
|
+
console.info(`[Safari smoke] ${new Date().toISOString()} ${message}`);
|
|
34
|
+
try {
|
|
35
|
+
phase('launch seeded session');
|
|
36
|
+
session = await launchRealBrowser({
|
|
37
|
+
channel: 'safari',
|
|
38
|
+
startupTimeout: 20_000,
|
|
39
|
+
seedCookies: [{ name: 'seed', value: 'yes', url, httpOnly: true }],
|
|
40
|
+
});
|
|
41
|
+
const { page, driver } = session;
|
|
42
|
+
phase('exercise page commands');
|
|
43
|
+
commander = makeBrowserCommander({ page });
|
|
44
|
+
await page.goto(url);
|
|
45
|
+
assert.equal(commander.engine, 'selenium');
|
|
46
|
+
await commander.fill({ selector: '#name', text: 'Safari' });
|
|
47
|
+
await commander.click({ selector: '#go' });
|
|
48
|
+
assert.equal(
|
|
49
|
+
await page.evaluate(() => document.querySelector('#out').textContent),
|
|
50
|
+
'Safari'
|
|
51
|
+
);
|
|
52
|
+
assert.equal(await page.evaluateAsync(async (value) => value + 1, 4), 5);
|
|
53
|
+
assert.ok((await page.screenshot()).length > 100);
|
|
54
|
+
assert.equal((await driver.manage().getCookie('seed')).value, 'yes');
|
|
55
|
+
phase('exercise windows and tabs');
|
|
56
|
+
const initial = await driver.getWindowHandle();
|
|
57
|
+
for (const type of ['tab', 'window']) {
|
|
58
|
+
const handle = await page.newWindow(type);
|
|
59
|
+
assert.ok((await page.windows()).includes(handle));
|
|
60
|
+
await page.goto(url);
|
|
61
|
+
await driver.close();
|
|
62
|
+
await page.switchToWindow(initial);
|
|
63
|
+
}
|
|
64
|
+
await assert.rejects(() => page.pdf(), SafariUnsupportedError);
|
|
65
|
+
await assert.rejects(
|
|
66
|
+
() => page.setRequestInterception(true),
|
|
67
|
+
SafariUnsupportedError
|
|
68
|
+
);
|
|
69
|
+
phase('close seeded session');
|
|
70
|
+
await session.close();
|
|
71
|
+
phase('launch fresh session');
|
|
72
|
+
session = await launchRealBrowser({ channel: 'safari' });
|
|
73
|
+
await session.page.goto(url);
|
|
74
|
+
assert.equal(
|
|
75
|
+
(await session.driver.manage().getCookies()).some(
|
|
76
|
+
(cookie) => cookie.name === 'seed'
|
|
77
|
+
),
|
|
78
|
+
false
|
|
79
|
+
);
|
|
80
|
+
phase('close fresh session');
|
|
81
|
+
await session.close();
|
|
82
|
+
phase('launch CLI session');
|
|
83
|
+
await cli.dispatch('session.launch', {
|
|
84
|
+
browser: 'safari',
|
|
85
|
+
engine: 'selenium',
|
|
86
|
+
});
|
|
87
|
+
phase('exercise CLI commands');
|
|
88
|
+
await cli.dispatch('page.goto', { url });
|
|
89
|
+
assert.equal(
|
|
90
|
+
(await cli.dispatch('page.eval', { expression: 'document.title' }))
|
|
91
|
+
.value,
|
|
92
|
+
'Safari smoke'
|
|
93
|
+
);
|
|
94
|
+
} finally {
|
|
95
|
+
phase('close CLI session');
|
|
96
|
+
await cli.close();
|
|
97
|
+
phase('destroy commander');
|
|
98
|
+
await commander?.destroy();
|
|
99
|
+
phase('close remaining session and server');
|
|
100
|
+
await session?.close();
|
|
101
|
+
await closeServer(server);
|
|
102
|
+
phase('cleanup complete');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
);
|
|
@@ -20,6 +20,7 @@ import path from 'node:path';
|
|
|
20
20
|
import { launchWebDriver } from '../../src/browser/webdriver.js';
|
|
21
21
|
import { makeBrowserCommander } from '../../src/factory.js';
|
|
22
22
|
import { CHROME_LAUNCH_OPTIONS } from '../helpers/e2e-browser.js';
|
|
23
|
+
import { closeServer } from '../helpers/fixture-server.js';
|
|
23
24
|
|
|
24
25
|
const PAGE = `<!doctype html>
|
|
25
26
|
<title>WebDriver e2e</title>
|
|
@@ -42,7 +43,7 @@ async function startPageServer() {
|
|
|
42
43
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
43
44
|
return {
|
|
44
45
|
url: `http://127.0.0.1:${server.address().port}/`,
|
|
45
|
-
close: () =>
|
|
46
|
+
close: () => closeServer(server),
|
|
46
47
|
};
|
|
47
48
|
}
|
|
48
49
|
|
|
@@ -31,6 +31,23 @@ export function sendNotFound(res) {
|
|
|
31
31
|
res.end('not found');
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Close an HTTP server without waiting for the browser's idle connections.
|
|
36
|
+
*
|
|
37
|
+
* Browsers open speculative connections they may never send a request on.
|
|
38
|
+
* Node 24's http.Server#close() waits for those until the client drops them,
|
|
39
|
+
* which stalled the Safari smoke cleanup for ~30s (issue #128), so stop
|
|
40
|
+
* listening first and then drop every connection that is left.
|
|
41
|
+
*
|
|
42
|
+
* @param {import('node:http').Server} server - Listening server
|
|
43
|
+
* @returns {Promise<void>} Resolves once the server is closed
|
|
44
|
+
*/
|
|
45
|
+
export function closeServer(server) {
|
|
46
|
+
const closed = new Promise((resolve) => server.close(() => resolve()));
|
|
47
|
+
server.closeAllConnections();
|
|
48
|
+
return closed;
|
|
49
|
+
}
|
|
50
|
+
|
|
34
51
|
/**
|
|
35
52
|
* Start a fixture server on an ephemeral port.
|
|
36
53
|
*
|
|
@@ -50,7 +67,7 @@ export async function startFixtureHost(handle, onClose) {
|
|
|
50
67
|
baseUrl: `http://127.0.0.1:${port}`,
|
|
51
68
|
close: async () => {
|
|
52
69
|
await onClose?.();
|
|
53
|
-
await
|
|
70
|
+
await closeServer(server);
|
|
54
71
|
},
|
|
55
72
|
};
|
|
56
73
|
}
|
|
@@ -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,151 @@ 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
|
+
assert.equal(started.length, 1, 'setup errors are not retried');
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Safari dependencies whose first `failures` driver starts fail the way CI
|
|
497
|
+
* saw right after the previous session closed: `'exit'` is a safaridriver that
|
|
498
|
+
* exits with code 1 before /status answers, `'refused'` is one that answered
|
|
499
|
+
* /status and then refused the new-session request.
|
|
500
|
+
*/
|
|
501
|
+
function flakySafariDependencies(failures) {
|
|
502
|
+
const setup = safariDependencies();
|
|
503
|
+
const { dependencies, started, mock } = setup;
|
|
504
|
+
let port = 49000;
|
|
505
|
+
dependencies.reservePort = async () => (port += 1);
|
|
506
|
+
dependencies.safariRetryDelay = 0;
|
|
507
|
+
const startProcess = dependencies.startProcess;
|
|
508
|
+
dependencies.startProcess = (...args) => {
|
|
509
|
+
const child = startProcess(...args);
|
|
510
|
+
if (failures[started.length - 1] === 'exit') {
|
|
511
|
+
child.exitCode = 1;
|
|
512
|
+
child.stdout = {
|
|
513
|
+
on: (_event, listener) => listener('safaridriver: busy\n'),
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
return child;
|
|
517
|
+
};
|
|
518
|
+
dependencies.fetch = async () => ({
|
|
519
|
+
ok: true,
|
|
520
|
+
json: async () => ({ value: { ready: true } }),
|
|
521
|
+
});
|
|
522
|
+
const build = mock.selenium.webdriver.Builder.prototype.build;
|
|
523
|
+
mock.selenium.webdriver.Builder.prototype.build = function () {
|
|
524
|
+
if (failures[started.length - 1] === 'refused') {
|
|
525
|
+
throw new Error(
|
|
526
|
+
`ECONNREFUSED connect ECONNREFUSED 127.0.0.1:${port} (attempt ${started.length})`
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
return build.call(this);
|
|
530
|
+
};
|
|
531
|
+
return setup;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
describe('Safari launch right after a previous session', () => {
|
|
535
|
+
for (const failure of ['exit', 'refused']) {
|
|
536
|
+
it(`starts a new driver when the first one fails with '${failure}'`, async () => {
|
|
537
|
+
const { dependencies, started } = flakySafariDependencies([failure]);
|
|
538
|
+
const result = await launchWebDriver({ browser: 'safari' }, dependencies);
|
|
539
|
+
assert.equal(started.length, 2);
|
|
540
|
+
assert.equal(result.driverProcess, started[1].child);
|
|
541
|
+
assert.ok(started[0].child.killed || started[0].child.exitCode === 1);
|
|
542
|
+
assert.equal(result.serverUrl, 'http://127.0.0.1:49002');
|
|
543
|
+
await result.close();
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
it('gives up after three attempts and reports what the driver printed', async () => {
|
|
548
|
+
const { dependencies, started } = flakySafariDependencies([
|
|
549
|
+
'exit',
|
|
550
|
+
'refused',
|
|
551
|
+
'exit',
|
|
552
|
+
]);
|
|
553
|
+
await assert.rejects(
|
|
554
|
+
() => launchWebDriver({ browser: 'safari' }, dependencies),
|
|
555
|
+
/exited with code 1 before it was ready: safaridriver: busy/
|
|
556
|
+
);
|
|
557
|
+
assert.equal(started.length, 3);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('adds the driver exit status to a refused session request', async () => {
|
|
561
|
+
const { dependencies } = flakySafariDependencies([
|
|
562
|
+
'refused',
|
|
563
|
+
'refused',
|
|
564
|
+
'refused',
|
|
565
|
+
]);
|
|
566
|
+
await assert.rejects(
|
|
567
|
+
() => launchWebDriver({ browser: 'safari' }, dependencies),
|
|
568
|
+
(error) =>
|
|
569
|
+
/ECONNREFUSED .*\(attempt 3\)/.test(error.message) &&
|
|
570
|
+
/WebDriver server at http:\/\/127\.0\.0\.1:49003/.test(error.message)
|
|
571
|
+
);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it('does not retry other browsers', async () => {
|
|
575
|
+
const { dependencies, started, mock } = launchDependencies({
|
|
576
|
+
checkAccess: accessOnly('/opt/drivers/chromedriver'),
|
|
577
|
+
safariRetryDelay: 0,
|
|
578
|
+
});
|
|
579
|
+
mock.selenium.webdriver.Builder.prototype.build = () => {
|
|
580
|
+
throw new Error('ECONNREFUSED connect ECONNREFUSED 127.0.0.1:41001');
|
|
581
|
+
};
|
|
582
|
+
await assert.rejects(
|
|
583
|
+
() =>
|
|
584
|
+
launchWebDriver(
|
|
585
|
+
{ driverPath: '/opt/drivers/chromedriver' },
|
|
586
|
+
dependencies
|
|
587
|
+
),
|
|
588
|
+
/ECONNREFUSED/
|
|
589
|
+
);
|
|
590
|
+
assert.equal(started.length, 1);
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
|
|
449
594
|
describe('connectWebDriver', () => {
|
|
450
595
|
it('opens a session on a running server with the given capabilities', async () => {
|
|
451
596
|
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' }), {
|
|
@@ -192,6 +192,81 @@ describe('selectors', () => {
|
|
|
192
192
|
});
|
|
193
193
|
});
|
|
194
194
|
|
|
195
|
+
describe('normalizeSelector with Playwright text selectors', () => {
|
|
196
|
+
/** A page whose evaluate records the parsed selector it was given. */
|
|
197
|
+
function capturingPage() {
|
|
198
|
+
const page = {
|
|
199
|
+
parsed: null,
|
|
200
|
+
evaluate: async (_fn, parsed) => {
|
|
201
|
+
page.parsed = parsed;
|
|
202
|
+
return 'button:nth-of-type(1)';
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
return page;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
it('splits :has-text into base selector and text', async () => {
|
|
209
|
+
const page = capturingPage();
|
|
210
|
+
await normalizeSelector({
|
|
211
|
+
page,
|
|
212
|
+
engine: 'playwright',
|
|
213
|
+
selector: 'nav a:has-text("Sign in")',
|
|
214
|
+
});
|
|
215
|
+
assert.deepStrictEqual(page.parsed, {
|
|
216
|
+
baseSelector: 'nav a',
|
|
217
|
+
text: 'Sign in',
|
|
218
|
+
exact: false,
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('splits :text-is into base selector and exact text', async () => {
|
|
223
|
+
const page = capturingPage();
|
|
224
|
+
await normalizeSelector({
|
|
225
|
+
page,
|
|
226
|
+
engine: 'playwright',
|
|
227
|
+
selector: 'button:text-is("Submit")',
|
|
228
|
+
});
|
|
229
|
+
assert.deepStrictEqual(page.parsed, {
|
|
230
|
+
baseSelector: 'button',
|
|
231
|
+
text: 'Submit',
|
|
232
|
+
exact: true,
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('returns an unparseable selector unchanged', async () => {
|
|
237
|
+
for (const selector of [
|
|
238
|
+
':has-text("no base")',
|
|
239
|
+
'a:has-text("")',
|
|
240
|
+
'a:has-text("unclosed',
|
|
241
|
+
]) {
|
|
242
|
+
const page = capturingPage();
|
|
243
|
+
const result = await normalizeSelector({
|
|
244
|
+
page,
|
|
245
|
+
engine: 'playwright',
|
|
246
|
+
selector,
|
|
247
|
+
});
|
|
248
|
+
assert.strictEqual(result, selector);
|
|
249
|
+
assert.strictEqual(page.parsed, null);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('rejects a long unparseable selector in linear time', async () => {
|
|
254
|
+
// The former /^(.+?):has-text\("(.+?)"\)$/ backtracked quadratically
|
|
255
|
+
// on this input: about 18s at this length (CodeQL js/polynomial-redos,
|
|
256
|
+
// issue #128; experiments/issue-128/text-selector-redos.mjs).
|
|
257
|
+
const selector = `a${':has-text("'.repeat(50_000)}`;
|
|
258
|
+
const started = performance.now();
|
|
259
|
+
const result = await normalizeSelector({
|
|
260
|
+
page: capturingPage(),
|
|
261
|
+
engine: 'playwright',
|
|
262
|
+
selector,
|
|
263
|
+
});
|
|
264
|
+
const elapsed = performance.now() - started;
|
|
265
|
+
assert.strictEqual(result, selector);
|
|
266
|
+
assert.ok(elapsed < 1000, `took ${elapsed.toFixed(0)}ms`);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
195
270
|
describe('normalizeSelector', () => {
|
|
196
271
|
it('should throw when selector is not provided', async () => {
|
|
197
272
|
const page = createMockPlaywrightPage();
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { connect } from 'node:net';
|
|
5
|
+
import { closeServer, startFixtureHost } from '../../helpers/fixture-server.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Open a connection the way a browser preconnects: TCP only, no request.
|
|
9
|
+
* Node 24's http.Server#close() waits for such a socket until the client
|
|
10
|
+
* drops it, which is what stalled the Safari smoke cleanup (issue #128).
|
|
11
|
+
*/
|
|
12
|
+
async function preconnect(url) {
|
|
13
|
+
const { hostname, port } = new URL(url);
|
|
14
|
+
const socket = connect(Number(port), hostname);
|
|
15
|
+
socket.on('error', () => {});
|
|
16
|
+
await new Promise((resolve) => socket.once('connect', resolve));
|
|
17
|
+
return socket;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Fail instead of hanging when a close does not finish in time. */
|
|
21
|
+
function within(promise, ms) {
|
|
22
|
+
let timer;
|
|
23
|
+
return Promise.race([
|
|
24
|
+
promise,
|
|
25
|
+
new Promise((_resolve, reject) => {
|
|
26
|
+
timer = setTimeout(
|
|
27
|
+
() => reject(new Error(`did not close within ${ms}ms`)),
|
|
28
|
+
ms
|
|
29
|
+
);
|
|
30
|
+
}),
|
|
31
|
+
]).finally(() => clearTimeout(timer));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('fixture server shutdown', { timeout: 10_000 }, () => {
|
|
35
|
+
it('closeServer does not wait for a preconnected idle socket', async () => {
|
|
36
|
+
const server = createServer((_request, response) => response.end('ok'));
|
|
37
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
38
|
+
const socket = await preconnect(
|
|
39
|
+
`http://127.0.0.1:${server.address().port}/`
|
|
40
|
+
);
|
|
41
|
+
try {
|
|
42
|
+
await within(closeServer(server), 2_000);
|
|
43
|
+
assert.equal(server.listening, false);
|
|
44
|
+
} finally {
|
|
45
|
+
socket.destroy();
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('startFixtureHost close does not wait for a preconnected idle socket', async () => {
|
|
50
|
+
const host = await startFixtureHost((_path, _request, response) =>
|
|
51
|
+
response.end('ok')
|
|
52
|
+
);
|
|
53
|
+
const socket = await preconnect(host.baseUrl);
|
|
54
|
+
try {
|
|
55
|
+
await within(host.close(), 2_000);
|
|
56
|
+
} finally {
|
|
57
|
+
socket.destroy();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
});
|