browser-commander 0.19.0 → 0.21.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 +156 -0
- package/README.md +216 -32
- package/bin/browser-commander.js +16 -0
- package/eslint.config.js +10 -0
- package/examples/react-test-app/src/App.jsx +24 -23
- package/extension/background.js +14 -0
- package/extension/manifest.json +12 -0
- package/extension/relay-handler.js +353 -0
- package/package.json +25 -6
- package/scripts/run-tests.mjs +4 -2
- package/src/browser/attach/differences.js +131 -0
- package/src/browser/attach/extension-relay.js +402 -0
- package/src/browser/attach/index.js +81 -0
- package/src/browser/attach/snapshot-launch.js +146 -0
- package/src/browser/attach/snapshot.js +374 -0
- package/src/browser/attach/websocket.js +615 -0
- package/src/browser/browser-cookie-cache.js +12 -17
- package/src/browser/browser-cookie-credentials.js +6 -9
- package/src/browser/browser-cookies.js +5 -2
- package/src/browser/cdp-session.js +94 -0
- package/src/browser/connector.js +42 -4
- package/src/browser/debugging-port.js +155 -0
- package/src/browser/launch-options.js +63 -52
- package/src/browser/launch-restrictions.json +147 -0
- package/src/browser/launcher.js +329 -133
- package/src/browser/media.js +5 -0
- package/src/browser/migration/bookmarks.js +73 -0
- package/src/browser/migration/chromium-crypto.js +89 -0
- package/src/browser/migration/cookies.js +155 -0
- package/src/browser/migration/extensions.js +187 -0
- package/src/browser/migration/firefox-bookmarks.js +144 -0
- package/src/browser/migration/firefox-der.js +84 -0
- package/src/browser/migration/firefox-nss.js +281 -0
- package/src/browser/migration/firefox.js +334 -0
- package/src/browser/migration/fs-utils.js +51 -0
- package/src/browser/migration/history.js +103 -0
- package/src/browser/migration/index.js +307 -0
- package/src/browser/migration/os-crypt-keys.js +106 -0
- package/src/browser/migration/passwords.js +171 -0
- package/src/browser/migration/preferences.js +132 -0
- package/src/browser/migration/sqlite-snapshot.js +138 -0
- package/src/browser/open-in-user-browser.js +112 -0
- package/src/browser/parity.js +439 -0
- package/src/browser/profile-directory.js +102 -0
- package/src/browser/real-browser.js +513 -306
- package/src/browser/restrictions.js +148 -0
- package/src/browser/system-browser.js +232 -0
- package/src/browser/webdriver.js +572 -0
- package/src/cli/args.js +253 -0
- package/src/cli/commands.js +332 -0
- package/src/cli/dispatcher.js +99 -0
- package/src/cli/handle-methods.js +136 -0
- package/src/cli/handles.js +273 -0
- package/src/cli/high-level-methods.js +310 -0
- package/src/cli/main.js +67 -0
- package/src/cli/modules.js +100 -0
- package/src/cli/rpc-error.js +77 -0
- package/src/cli/script.js +77 -0
- package/src/cli/serve.js +144 -0
- package/src/cli/sessions.js +195 -0
- package/src/core/constants.js +7 -1
- package/src/core/engine-adapter.js +107 -1
- package/src/core/engine-detection.js +18 -3
- package/src/core/navigation-safety.js +4 -0
- package/src/core/webdriver-conversions.js +347 -0
- package/src/core/webdriver-events.js +229 -0
- package/src/core/webdriver-page.js +716 -0
- package/src/downloads/attach.js +6 -0
- package/src/downloads/manager.js +9 -0
- package/src/downloads/sources.js +71 -0
- package/src/downloads/staging.js +21 -9
- package/src/elements/selectors.js +3 -2
- package/src/elements/visibility.js +4 -0
- package/src/exports.js +61 -2
- package/src/factory.js +19 -4
- package/src/fingerprint/apply.js +6 -0
- package/src/fingerprint/automation-parity.js +7 -8
- package/src/fingerprint/limitations.js +1 -1
- package/src/fingerprint/limitations.json +45 -2
- package/src/parity/harness.js +303 -0
- package/src/parity/probe.js +838 -0
- package/src/parity/version-page.js +144 -0
- package/src/traces/bundle.js +10 -2
- package/src/traces/recorder.js +16 -3
- package/src/utilities/subprocess.js +199 -0
- package/tests/e2e/api-coverage.e2e.test.js +174 -0
- package/tests/e2e/cli.e2e.test.js +160 -0
- package/tests/e2e/downloads.e2e.test.js +79 -2
- package/tests/e2e/fingerprint-parity.e2e.test.js +104 -46
- package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
- package/tests/e2e/measure-parity.e2e.test.js +118 -0
- package/tests/e2e/playwright.e2e.test.js +25 -17
- package/tests/e2e/puppeteer.e2e.test.js +17 -23
- package/tests/e2e/webdriver.e2e.test.js +239 -0
- package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
- package/tests/helpers/cli-fakes.js +214 -0
- package/tests/helpers/declared-api.js +80 -0
- package/tests/helpers/e2e-browser.js +40 -14
- package/tests/helpers/fake-chrome.js +124 -0
- package/tests/helpers/migration-fixtures.js +242 -0
- package/tests/helpers/temp-directory.js +24 -0
- package/tests/helpers/trace-fixtures.js +18 -0
- package/tests/helpers/webdriver-mocks.js +183 -0
- package/tests/unit/browser/attach/extension-relay.test.js +360 -0
- package/tests/unit/browser/attach/snapshot-launch.test.js +312 -0
- package/tests/unit/browser/attach/snapshot.test.js +390 -0
- package/tests/unit/browser/attach/websocket.test.js +240 -0
- package/tests/unit/browser/browser-cookies.test.js +2 -32
- package/tests/unit/browser/cdp-session.test.js +162 -0
- package/tests/unit/browser/connector.test.js +23 -0
- package/tests/unit/browser/launch-building-blocks.test.js +238 -0
- package/tests/unit/browser/launch-options.test.js +41 -22
- package/tests/unit/browser/launcher.test.js +281 -102
- package/tests/unit/browser/media.test.js +13 -1
- package/tests/unit/browser/migration/bookmarks.test.js +69 -0
- package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
- package/tests/unit/browser/migration/cookies.test.js +94 -0
- package/tests/unit/browser/migration/extensions.test.js +104 -0
- package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
- package/tests/unit/browser/migration/firefox.test.js +139 -0
- package/tests/unit/browser/migration/history.test.js +48 -0
- package/tests/unit/browser/migration/index.test.js +117 -0
- package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
- package/tests/unit/browser/migration/passwords.test.js +144 -0
- package/tests/unit/browser/migration/preferences.test.js +77 -0
- package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
- package/tests/unit/browser/open-in-user-browser.test.js +123 -0
- package/tests/unit/browser/parity.test.js +281 -0
- package/tests/unit/browser/real-browser.test.js +458 -33
- package/tests/unit/browser/webdriver.test.js +473 -0
- package/tests/unit/cli/bin.test.js +69 -0
- package/tests/unit/cli/declared-api.test.js +49 -0
- package/tests/unit/cli/dispatcher.test.js +429 -0
- package/tests/unit/cli/handles.test.js +170 -0
- package/tests/unit/cli/main.test.js +409 -0
- package/tests/unit/cli/serve.test.js +167 -0
- package/tests/unit/core/webdriver-conversions.test.js +235 -0
- package/tests/unit/core/webdriver-page.test.js +534 -0
- package/tests/unit/downloads/manager.test.js +82 -9
- package/tests/unit/downloads/staging.test.js +24 -2
- package/tests/unit/fingerprint/automation-parity.test.js +17 -4
- package/tests/unit/fingerprint/limitations.test.js +1 -1
- package/tests/unit/parity/harness.test.js +138 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +4 -0
- package/tests/unit/scripts/ci-workflow-policy.test.js +8 -0
- package/tests/unit/scripts/feature-parity.test.js +221 -0
- package/tests/unit/scripts/npm-release-helpers.test.js +3 -2
- package/tests/unit/scripts/pre-commit-config.test.js +2 -1
- package/tests/unit/traces/bundle.test.js +24 -3
- package/tests/unit/traces/links.test.js +7 -0
- package/tests/unit/traces/reader.test.js +11 -4
- package/tests/unit/traces/recorder.test.js +4 -0
- package/tests/unit/traces/viewer.test.js +4 -0
- package/tests/unit/utilities/command-stream-only.test.js +28 -0
- package/tests/unit/utilities/subprocess.test.js +80 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E: `measureParity()` against every installed Chrome-family browser.
|
|
3
|
+
*
|
|
4
|
+
* The default real launch must be `ok` - no difference from the same binary
|
|
5
|
+
* started by hand, neither in the command line chrome://version reports nor in
|
|
6
|
+
* anything the environment probe reads - headful and headless, through either
|
|
7
|
+
* engine. The engine launch is `ok` only because every one of its extra
|
|
8
|
+
* switches is tied to the `engine-launch-switches` catalogue entry.
|
|
9
|
+
*
|
|
10
|
+
* Google Chrome is required. Chromium, Edge and Brave are measured when they
|
|
11
|
+
* are installed; a browser that is not installed defines no test rather than
|
|
12
|
+
* a skipped one, so the parity workflow's "no SKIP" gate still holds.
|
|
13
|
+
*
|
|
14
|
+
* RUN_E2E=true xvfb-run -a --server-args="-screen 0 1920x1080x24" \
|
|
15
|
+
* npm run test:e2e:parity
|
|
16
|
+
*/
|
|
17
|
+
import { describe, it } from 'node:test';
|
|
18
|
+
import assert from 'node:assert';
|
|
19
|
+
|
|
20
|
+
import { measureParity } from '../../src/browser/parity.js';
|
|
21
|
+
import { resolveSystemBrowserExecutable } from '../../src/browser/system-browser.js';
|
|
22
|
+
import {
|
|
23
|
+
PARITY_CHROME as CHROME,
|
|
24
|
+
paritySkipReason,
|
|
25
|
+
} from '../helpers/e2e-browser.js';
|
|
26
|
+
|
|
27
|
+
const TEST_TIMEOUT = 180000;
|
|
28
|
+
|
|
29
|
+
async function installedBrowsers() {
|
|
30
|
+
const browsers = [{ name: 'chrome', executablePath: CHROME }];
|
|
31
|
+
for (const channel of ['chromium', 'msedge', 'brave']) {
|
|
32
|
+
const executablePath = await resolveSystemBrowserExecutable({
|
|
33
|
+
channel,
|
|
34
|
+
}).catch(() => null);
|
|
35
|
+
if (executablePath) {
|
|
36
|
+
browsers.push({ name: channel, executablePath });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return browsers;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function describeUnlisted(report) {
|
|
43
|
+
return JSON.stringify(
|
|
44
|
+
report.unlisted.map(({ path, expected, actual }) => ({
|
|
45
|
+
path,
|
|
46
|
+
expected,
|
|
47
|
+
actual,
|
|
48
|
+
}))
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Every browser is measured headful too, so a display is required.
|
|
53
|
+
const skip = paritySkipReason();
|
|
54
|
+
const browsers = skip ? [] : await installedBrowsers();
|
|
55
|
+
|
|
56
|
+
describe('E2E Tests - measureParity', { skip }, () => {
|
|
57
|
+
for (const { name, executablePath } of browsers) {
|
|
58
|
+
for (const headless of [false, true]) {
|
|
59
|
+
const mode = headless ? 'headless' : 'headful';
|
|
60
|
+
|
|
61
|
+
for (const engine of ['playwright', 'puppeteer']) {
|
|
62
|
+
it(
|
|
63
|
+
`${name} ${mode}: the real launch through ${engine} matches a hand-started browser`,
|
|
64
|
+
{ timeout: TEST_TIMEOUT },
|
|
65
|
+
async () => {
|
|
66
|
+
const report = await measureParity({
|
|
67
|
+
engine,
|
|
68
|
+
executablePath,
|
|
69
|
+
headless,
|
|
70
|
+
});
|
|
71
|
+
assert.equal(report.browser.launch, 'real');
|
|
72
|
+
assert.deepEqual(report.commandLine.extra, []);
|
|
73
|
+
assert.deepEqual(report.commandLine.missing, []);
|
|
74
|
+
assert.equal(report.commandLine.attachment.length, 1);
|
|
75
|
+
assert.equal(report.ok, true, describeUnlisted(report));
|
|
76
|
+
assert.deepEqual(report.differences, []);
|
|
77
|
+
}
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
it(
|
|
83
|
+
`${name}: an engine launch is explained by the catalogue`,
|
|
84
|
+
{ timeout: TEST_TIMEOUT },
|
|
85
|
+
async () => {
|
|
86
|
+
const report = await measureParity({
|
|
87
|
+
engine: 'playwright',
|
|
88
|
+
executablePath,
|
|
89
|
+
launch: 'engine',
|
|
90
|
+
});
|
|
91
|
+
assert.ok(report.commandLine.extra.length > 0);
|
|
92
|
+
assert.equal(report.ok, true, describeUnlisted(report));
|
|
93
|
+
assert.ok(
|
|
94
|
+
report.differences.every(
|
|
95
|
+
(entry) => entry.limitation === 'engine-launch-switches'
|
|
96
|
+
)
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
it(
|
|
103
|
+
'reports an opt-in restriction as requested, not as a leak',
|
|
104
|
+
{ timeout: TEST_TIMEOUT },
|
|
105
|
+
async () => {
|
|
106
|
+
const report = await measureParity({
|
|
107
|
+
executablePath: CHROME,
|
|
108
|
+
restrictions: ['no-sync'],
|
|
109
|
+
});
|
|
110
|
+
assert.deepEqual(report.commandLine.extra, ['--disable-sync']);
|
|
111
|
+
assert.equal(report.ok, true, describeUnlisted(report));
|
|
112
|
+
assert.deepEqual(
|
|
113
|
+
report.differences.map(({ path, requested }) => ({ path, requested })),
|
|
114
|
+
[{ path: 'commandLine.extra.--disable-sync', requested: true }]
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
);
|
|
118
|
+
});
|
|
@@ -12,6 +12,7 @@ import assert from 'node:assert';
|
|
|
12
12
|
// Dynamic import for playwright since it may not be installed
|
|
13
13
|
let playwright;
|
|
14
14
|
let createCommander;
|
|
15
|
+
const TOGGLE_SLIDER = '[data-testid="toggle-switch"] + .toggle-slider';
|
|
15
16
|
|
|
16
17
|
describe(
|
|
17
18
|
'E2E Tests - Playwright Engine',
|
|
@@ -124,12 +125,11 @@ describe(
|
|
|
124
125
|
}
|
|
125
126
|
|
|
126
127
|
await commander.click({ selector: '[data-testid="radio-male"]' });
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
assert.ok(checked !== null);
|
|
128
|
+
// React controls the live property without adding a checked attribute.
|
|
129
|
+
assert.strictEqual(
|
|
130
|
+
await page.locator('[data-testid="radio-male"]').isChecked(),
|
|
131
|
+
true
|
|
132
|
+
);
|
|
133
133
|
});
|
|
134
134
|
|
|
135
135
|
it('should check checkbox', async () => {
|
|
@@ -237,7 +237,7 @@ describe(
|
|
|
237
237
|
const initialStatus = await commander.textContent({
|
|
238
238
|
selector: '[data-testid="toggle-status"]',
|
|
239
239
|
});
|
|
240
|
-
await commander.click({ selector:
|
|
240
|
+
await commander.click({ selector: TOGGLE_SLIDER });
|
|
241
241
|
await commander.wait({ ms: 50 });
|
|
242
242
|
const newStatus = await commander.textContent({
|
|
243
243
|
selector: '[data-testid="toggle-status"]',
|
|
@@ -337,7 +337,10 @@ describe(
|
|
|
337
337
|
}
|
|
338
338
|
|
|
339
339
|
// Scroll to target item (item 15)
|
|
340
|
-
await commander.
|
|
340
|
+
await commander.scrollIntoView({
|
|
341
|
+
locatorOrElement: page.locator('[data-testid="scroll-item-15"]'),
|
|
342
|
+
behavior: 'instant',
|
|
343
|
+
});
|
|
341
344
|
await commander.wait({ ms: 500 });
|
|
342
345
|
|
|
343
346
|
// Element should now be visible
|
|
@@ -354,17 +357,22 @@ describe(
|
|
|
354
357
|
|
|
355
358
|
// Use click which auto-scrolls
|
|
356
359
|
let alertHandled = false;
|
|
357
|
-
|
|
360
|
+
const onDialog = async (dialog) => {
|
|
358
361
|
alertHandled = true;
|
|
359
362
|
await dialog.accept();
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
363
|
+
};
|
|
364
|
+
commander.onDialog(onDialog);
|
|
365
|
+
|
|
366
|
+
try {
|
|
367
|
+
await commander.click({
|
|
368
|
+
selector: '[data-testid="scroll-target-button"]',
|
|
369
|
+
});
|
|
370
|
+
await commander.wait({ ms: 100 });
|
|
371
|
+
|
|
372
|
+
assert.strictEqual(alertHandled, true);
|
|
373
|
+
} finally {
|
|
374
|
+
commander.offDialog(onDialog);
|
|
375
|
+
}
|
|
368
376
|
});
|
|
369
377
|
});
|
|
370
378
|
|
|
@@ -12,6 +12,7 @@ import assert from 'node:assert';
|
|
|
12
12
|
// Dynamic import for puppeteer since it may not be installed
|
|
13
13
|
let puppeteer;
|
|
14
14
|
let createCommander;
|
|
15
|
+
const TOGGLE_SLIDER = '[data-testid="toggle-switch"] + .toggle-slider';
|
|
15
16
|
|
|
16
17
|
describe('E2E Tests - Puppeteer Engine', { skip: !process.env.RUN_E2E }, () => {
|
|
17
18
|
let browser;
|
|
@@ -216,30 +217,23 @@ describe('E2E Tests - Puppeteer Engine', { skip: !process.env.RUN_E2E }, () => {
|
|
|
216
217
|
return;
|
|
217
218
|
}
|
|
218
219
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
const newStatus = await commander.textContent({
|
|
230
|
-
selector: '[data-testid="toggle-status"]',
|
|
231
|
-
});
|
|
232
|
-
assert.notStrictEqual(initialStatus.trim(), newStatus.trim());
|
|
233
|
-
|
|
234
|
-
// Toggle back
|
|
235
|
-
await commander.click({ selector: '[data-testid="toggle-switch"]' });
|
|
236
|
-
await commander.wait({ ms: 50 });
|
|
220
|
+
const initialChecked = await page.$eval(
|
|
221
|
+
'[data-testid="toggle-switch"]',
|
|
222
|
+
(input) => input.checked
|
|
223
|
+
);
|
|
224
|
+
await commander.click({ selector: TOGGLE_SLIDER });
|
|
225
|
+
const changedChecked = await page.$eval(
|
|
226
|
+
'[data-testid="toggle-switch"]',
|
|
227
|
+
(input) => input.checked
|
|
228
|
+
);
|
|
229
|
+
assert.notStrictEqual(changedChecked, initialChecked);
|
|
237
230
|
|
|
238
|
-
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
231
|
+
await commander.click({ selector: TOGGLE_SLIDER });
|
|
232
|
+
const finalChecked = await page.$eval(
|
|
233
|
+
'[data-testid="toggle-switch"]',
|
|
234
|
+
(input) => input.checked
|
|
235
|
+
);
|
|
236
|
+
assert.strictEqual(finalChecked, initialChecked);
|
|
243
237
|
});
|
|
244
238
|
|
|
245
239
|
it('should interact with custom dropdown', async () => {
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E tests for the selenium (WebDriver) engine (issue #104).
|
|
3
|
+
*
|
|
4
|
+
* launchWebDriver() starts chromedriver itself (found through driverPath,
|
|
5
|
+
* PATH or Selenium Manager), so the suite needs only Chrome and the
|
|
6
|
+
* selenium-webdriver dev dependency:
|
|
7
|
+
*
|
|
8
|
+
* RUN_E2E=true CHROME_PATH=/usr/bin/google-chrome \
|
|
9
|
+
* xvfb-run -a node scripts/run-tests.mjs tests/e2e/webdriver.e2e.test.js
|
|
10
|
+
*
|
|
11
|
+
* The Firefox suite runs only when a firefox executable is on PATH (or
|
|
12
|
+
* FIREFOX_PATH names one); geckodriver comes from PATH or Selenium Manager.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, before, after } from 'node:test';
|
|
16
|
+
import assert from 'node:assert';
|
|
17
|
+
import { accessSync, constants } from 'node:fs';
|
|
18
|
+
import { createServer } from 'node:http';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { launchWebDriver } from '../../src/browser/webdriver.js';
|
|
21
|
+
import { makeBrowserCommander } from '../../src/factory.js';
|
|
22
|
+
import { CHROME_LAUNCH_OPTIONS } from '../helpers/e2e-browser.js';
|
|
23
|
+
|
|
24
|
+
const PAGE = `<!doctype html>
|
|
25
|
+
<title>WebDriver e2e</title>
|
|
26
|
+
<h1 data-testid="heading">Hello WebDriver</h1>
|
|
27
|
+
<input data-testid="name" value="">
|
|
28
|
+
<button data-testid="greet"
|
|
29
|
+
onclick="document.querySelector('[data-testid=out]').textContent =
|
|
30
|
+
'Hi ' + document.querySelector('[data-testid=name]').value;
|
|
31
|
+
console.log('greeted', document.querySelector('[data-testid=name]').value)">
|
|
32
|
+
Greet
|
|
33
|
+
</button>
|
|
34
|
+
<p data-testid="out"></p>`;
|
|
35
|
+
|
|
36
|
+
/** Serve PAGE on a loopback port; cookies need an http origin. */
|
|
37
|
+
async function startPageServer() {
|
|
38
|
+
const server = createServer((request, response) => {
|
|
39
|
+
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
40
|
+
response.end(PAGE);
|
|
41
|
+
});
|
|
42
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
43
|
+
return {
|
|
44
|
+
url: `http://127.0.0.1:${server.address().port}/`,
|
|
45
|
+
close: () => new Promise((resolve) => server.close(resolve)),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The firefox executable this machine has, or null. */
|
|
50
|
+
function findFirefox() {
|
|
51
|
+
const candidates = process.env.FIREFOX_PATH
|
|
52
|
+
? [process.env.FIREFOX_PATH]
|
|
53
|
+
: (process.env.PATH ?? '')
|
|
54
|
+
.split(path.delimiter)
|
|
55
|
+
.filter(Boolean)
|
|
56
|
+
.map((directory) => path.join(directory, 'firefox'));
|
|
57
|
+
return (
|
|
58
|
+
candidates.find((candidate) => {
|
|
59
|
+
try {
|
|
60
|
+
accessSync(candidate, constants.X_OK);
|
|
61
|
+
return true;
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}) ?? null
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const headless = process.env.HEADLESS !== 'false';
|
|
70
|
+
|
|
71
|
+
describe(
|
|
72
|
+
'E2E Tests - Selenium (WebDriver) engine',
|
|
73
|
+
{
|
|
74
|
+
skip: !process.env.RUN_E2E,
|
|
75
|
+
},
|
|
76
|
+
() => {
|
|
77
|
+
let server;
|
|
78
|
+
let session;
|
|
79
|
+
let commander;
|
|
80
|
+
|
|
81
|
+
before(async () => {
|
|
82
|
+
server = await startPageServer();
|
|
83
|
+
session = await launchWebDriver({
|
|
84
|
+
browser: 'chrome',
|
|
85
|
+
headless,
|
|
86
|
+
bidi: true,
|
|
87
|
+
...CHROME_LAUNCH_OPTIONS,
|
|
88
|
+
});
|
|
89
|
+
commander = makeBrowserCommander({ page: session.page });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
after(async () => {
|
|
93
|
+
await commander?.destroy?.();
|
|
94
|
+
await session?.close();
|
|
95
|
+
await server?.close();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('detects the selenium engine', () => {
|
|
99
|
+
assert.equal(commander.engine, 'selenium');
|
|
100
|
+
assert.equal(session.bidi, true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('navigates', async () => {
|
|
104
|
+
await commander.goto({ url: server.url });
|
|
105
|
+
assert.equal(commander.getUrl(), server.url);
|
|
106
|
+
assert.equal(await session.page.title(), 'WebDriver e2e');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('finds elements and reads their text', async () => {
|
|
110
|
+
assert.equal(await commander.count({ selector: 'input' }), 1);
|
|
111
|
+
assert.equal(
|
|
112
|
+
(
|
|
113
|
+
await commander.textContent({ selector: '[data-testid="heading"]' })
|
|
114
|
+
).trim(),
|
|
115
|
+
'Hello WebDriver'
|
|
116
|
+
);
|
|
117
|
+
assert.equal(
|
|
118
|
+
await commander.isVisible({ selector: '[data-testid="greet"]' }),
|
|
119
|
+
true
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('types, clicks and receives the page console message over BiDi', async () => {
|
|
124
|
+
const messages = [];
|
|
125
|
+
const onConsole = (message) => messages.push(message.text());
|
|
126
|
+
session.page.on('console', onConsole);
|
|
127
|
+
await session.page.eventsReady();
|
|
128
|
+
|
|
129
|
+
await commander.fill({ selector: '[data-testid="name"]', text: 'Ada' });
|
|
130
|
+
assert.equal(
|
|
131
|
+
await commander.inputValue({ selector: '[data-testid="name"]' }),
|
|
132
|
+
'Ada'
|
|
133
|
+
);
|
|
134
|
+
await commander.click({ selector: '[data-testid="greet"]' });
|
|
135
|
+
assert.equal(
|
|
136
|
+
await commander.textContent({ selector: '[data-testid="out"]' }),
|
|
137
|
+
'Hi Ada'
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const deadline = Date.now() + 5000;
|
|
141
|
+
while (
|
|
142
|
+
!messages.some((text) => text.includes('greeted')) &&
|
|
143
|
+
Date.now() < deadline
|
|
144
|
+
) {
|
|
145
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
146
|
+
}
|
|
147
|
+
session.page.off('console', onConsole);
|
|
148
|
+
assert.ok(
|
|
149
|
+
messages.some((text) => text.includes('greeted Ada')),
|
|
150
|
+
`console messages: ${JSON.stringify(messages)}`
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('executes scripts with arguments', async () => {
|
|
155
|
+
const product = await commander.evaluate({
|
|
156
|
+
fn: (left, right) => `${left}x${right}`,
|
|
157
|
+
args: ['6', '7'],
|
|
158
|
+
});
|
|
159
|
+
assert.equal(product, '6x7');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('keeps navigator.webdriver false under chromedriver', async () => {
|
|
163
|
+
assert.equal(
|
|
164
|
+
await commander.evaluate({ fn: () => navigator.webdriver }),
|
|
165
|
+
false
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('takes a PNG screenshot', async () => {
|
|
170
|
+
const png = await session.page.screenshot();
|
|
171
|
+
assert.ok(Buffer.isBuffer(png));
|
|
172
|
+
assert.equal(png.subarray(1, 4).toString('latin1'), 'PNG');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('sets, reads and deletes cookies', async () => {
|
|
176
|
+
await session.page.setCookie({ name: 'e2e', value: 'webdriver' });
|
|
177
|
+
const cookie = (await session.page.cookies()).find(
|
|
178
|
+
(candidate) => candidate.name === 'e2e'
|
|
179
|
+
);
|
|
180
|
+
assert.equal(cookie?.value, 'webdriver');
|
|
181
|
+
assert.equal(
|
|
182
|
+
await commander.evaluate({ fn: () => document.cookie }),
|
|
183
|
+
'e2e=webdriver'
|
|
184
|
+
);
|
|
185
|
+
await session.page.deleteCookie({ name: 'e2e' });
|
|
186
|
+
assert.equal(
|
|
187
|
+
(await session.page.cookies()).some((c) => c.name === 'e2e'),
|
|
188
|
+
false
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('prints the page to PDF', async () => {
|
|
193
|
+
const pdf = await session.page.pdf({ format: 'A4' });
|
|
194
|
+
assert.equal(pdf.subarray(0, 5).toString('latin1'), '%PDF-');
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const firefox = findFirefox();
|
|
200
|
+
|
|
201
|
+
describe(
|
|
202
|
+
'E2E Tests - Selenium (WebDriver) engine on Firefox',
|
|
203
|
+
{
|
|
204
|
+
skip: !process.env.RUN_E2E
|
|
205
|
+
? true
|
|
206
|
+
: !firefox && 'firefox is not installed (set FIREFOX_PATH)',
|
|
207
|
+
},
|
|
208
|
+
() => {
|
|
209
|
+
let server;
|
|
210
|
+
let session;
|
|
211
|
+
|
|
212
|
+
before(async () => {
|
|
213
|
+
server = await startPageServer();
|
|
214
|
+
session = await launchWebDriver({
|
|
215
|
+
browser: 'firefox',
|
|
216
|
+
executablePath: firefox,
|
|
217
|
+
headless,
|
|
218
|
+
bidi: true,
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
after(async () => {
|
|
223
|
+
await session?.close();
|
|
224
|
+
await server?.close();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('navigates, types and reads the result', async () => {
|
|
228
|
+
const commander = makeBrowserCommander({ page: session.page });
|
|
229
|
+
await commander.goto({ url: server.url });
|
|
230
|
+
await commander.fill({ selector: '[data-testid="name"]', text: 'Grace' });
|
|
231
|
+
await commander.click({ selector: '[data-testid="greet"]' });
|
|
232
|
+
assert.equal(
|
|
233
|
+
await commander.textContent({ selector: '[data-testid="out"]' }),
|
|
234
|
+
'Hi Grace'
|
|
235
|
+
);
|
|
236
|
+
await commander.destroy?.();
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
);
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCipheriv,
|
|
3
|
+
createHash,
|
|
4
|
+
pbkdf2Sync,
|
|
5
|
+
randomBytes,
|
|
6
|
+
} from 'node:crypto';
|
|
7
|
+
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import BetterSqlite3 from 'better-sqlite3';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build the `key4.db` and `logins.json` fixtures a Firefox login-decryption
|
|
13
|
+
* test needs, using the exact NSS structures (PBES2-wrapped keys, DER-wrapped
|
|
14
|
+
* 3DES login fields) the production decryptor parses. Both the encryptor here
|
|
15
|
+
* and the decryptor under test share the same algorithm, so a green test proves
|
|
16
|
+
* the parser and the crypto agree.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const OID_BYTES = Object.freeze({
|
|
20
|
+
PBES2: [0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x05, 0x0d],
|
|
21
|
+
PBKDF2: [0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x05, 0x0c],
|
|
22
|
+
HMAC_SHA256: [0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x02, 0x09],
|
|
23
|
+
AES_256_CBC: [0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x2a],
|
|
24
|
+
DES_EDE3_CBC: [0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x03, 0x07],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function derLength(length) {
|
|
28
|
+
if (length < 0x80) {
|
|
29
|
+
return Buffer.from([length]);
|
|
30
|
+
}
|
|
31
|
+
const bytes = [];
|
|
32
|
+
let value = length;
|
|
33
|
+
while (value > 0) {
|
|
34
|
+
bytes.unshift(value & 0xff);
|
|
35
|
+
value = Math.floor(value / 256);
|
|
36
|
+
}
|
|
37
|
+
return Buffer.from([0x80 | bytes.length, ...bytes]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function derElement(tag, content) {
|
|
41
|
+
return Buffer.concat([
|
|
42
|
+
Buffer.from([tag]),
|
|
43
|
+
derLength(content.length),
|
|
44
|
+
content,
|
|
45
|
+
]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function derSequence(...parts) {
|
|
49
|
+
return derElement(0x30, Buffer.concat(parts));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function derOctet(buffer) {
|
|
53
|
+
return derElement(0x04, buffer);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function derOid(bytes) {
|
|
57
|
+
return derElement(0x06, Buffer.from(bytes));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function derInteger(number) {
|
|
61
|
+
let bytes = [];
|
|
62
|
+
if (number === 0) {
|
|
63
|
+
bytes = [0];
|
|
64
|
+
} else {
|
|
65
|
+
let value = number;
|
|
66
|
+
while (value > 0) {
|
|
67
|
+
bytes.unshift(value & 0xff);
|
|
68
|
+
value = Math.floor(value / 256);
|
|
69
|
+
}
|
|
70
|
+
if (bytes[0] & 0x80) {
|
|
71
|
+
bytes.unshift(0);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return derElement(0x02, Buffer.from(bytes));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Mirrors the NSS key4.db scheme so fixtures decrypt like a real profile.
|
|
78
|
+
function pbes2Key(globalSalt, entrySalt, iterations, primaryPassword) {
|
|
79
|
+
const passwordHash = createHash('sha1')
|
|
80
|
+
.update(Buffer.concat([globalSalt, primaryPassword]))
|
|
81
|
+
.digest();
|
|
82
|
+
return pbkdf2Sync(passwordHash, entrySalt, iterations, 32, 'sha256');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function encodePbes2Blob({
|
|
86
|
+
globalSalt,
|
|
87
|
+
entrySalt,
|
|
88
|
+
iterations,
|
|
89
|
+
iv14,
|
|
90
|
+
plaintext,
|
|
91
|
+
primaryPassword,
|
|
92
|
+
}) {
|
|
93
|
+
const key = pbes2Key(globalSalt, entrySalt, iterations, primaryPassword);
|
|
94
|
+
const fullIv = Buffer.concat([Buffer.from([0x04, 0x0e]), iv14]);
|
|
95
|
+
const cipher = createCipheriv('aes-256-cbc', key, fullIv);
|
|
96
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
97
|
+
const kdf = derSequence(
|
|
98
|
+
derOid(OID_BYTES.PBKDF2),
|
|
99
|
+
derSequence(
|
|
100
|
+
derOctet(entrySalt),
|
|
101
|
+
derInteger(iterations),
|
|
102
|
+
derInteger(32),
|
|
103
|
+
derSequence(derOid(OID_BYTES.HMAC_SHA256))
|
|
104
|
+
)
|
|
105
|
+
);
|
|
106
|
+
const enc = derSequence(derOid(OID_BYTES.AES_256_CBC), derOctet(iv14));
|
|
107
|
+
const algorithmId = derSequence(
|
|
108
|
+
derOid(OID_BYTES.PBES2),
|
|
109
|
+
derSequence(kdf, enc)
|
|
110
|
+
);
|
|
111
|
+
return derSequence(algorithmId, derOctet(ciphertext));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function pkcs7Pad(buffer, blockSize) {
|
|
115
|
+
const padLength = blockSize - (buffer.length % blockSize);
|
|
116
|
+
return Buffer.concat([buffer, Buffer.alloc(padLength, padLength)]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Build a Firefox key4.db with a PBES2-wrapped 3DES login key.
|
|
121
|
+
*
|
|
122
|
+
* @param {Object} [options]
|
|
123
|
+
* @returns {{key4Path: string, loginKey: Buffer, primaryPassword: Buffer}}
|
|
124
|
+
*/
|
|
125
|
+
export function buildKey4Database({
|
|
126
|
+
dir,
|
|
127
|
+
primaryPassword = Buffer.alloc(0),
|
|
128
|
+
iterations = 100,
|
|
129
|
+
} = {}) {
|
|
130
|
+
const globalSalt = randomBytes(16);
|
|
131
|
+
const loginKey = randomBytes(24);
|
|
132
|
+
|
|
133
|
+
const checkBlob = encodePbes2Blob({
|
|
134
|
+
globalSalt,
|
|
135
|
+
entrySalt: randomBytes(16),
|
|
136
|
+
iterations,
|
|
137
|
+
iv14: randomBytes(14),
|
|
138
|
+
plaintext: Buffer.from('password-check\u0002\u0002', 'latin1'),
|
|
139
|
+
primaryPassword,
|
|
140
|
+
});
|
|
141
|
+
const keyBlob = encodePbes2Blob({
|
|
142
|
+
globalSalt,
|
|
143
|
+
entrySalt: randomBytes(16),
|
|
144
|
+
iterations,
|
|
145
|
+
iv14: randomBytes(14),
|
|
146
|
+
plaintext: loginKey,
|
|
147
|
+
primaryPassword,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const key4Path = path.join(dir, 'key4.db');
|
|
151
|
+
const db = new BetterSqlite3(key4Path);
|
|
152
|
+
db.exec(
|
|
153
|
+
'CREATE TABLE metadata (id TEXT PRIMARY KEY, item1 BLOB, item2 BLOB);' +
|
|
154
|
+
'CREATE TABLE nssPrivate (a11 BLOB, a102 BLOB);'
|
|
155
|
+
);
|
|
156
|
+
db.prepare('INSERT INTO metadata (id, item1, item2) VALUES (?, ?, ?)').run(
|
|
157
|
+
'password',
|
|
158
|
+
globalSalt,
|
|
159
|
+
checkBlob
|
|
160
|
+
);
|
|
161
|
+
db.prepare('INSERT INTO nssPrivate (a11, a102) VALUES (?, ?)').run(
|
|
162
|
+
keyBlob,
|
|
163
|
+
Buffer.from('f8000000000000000000000000000001', 'hex')
|
|
164
|
+
);
|
|
165
|
+
db.close();
|
|
166
|
+
return { key4Path, loginKey, primaryPassword };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Encode one NSS login field (username/password) as base64, exactly like
|
|
171
|
+
* `logins.json` stores it.
|
|
172
|
+
*
|
|
173
|
+
* @param {Object} options
|
|
174
|
+
* @param {Buffer} options.key - The 24-byte 3DES key
|
|
175
|
+
* @param {string} options.plaintext
|
|
176
|
+
* @returns {string}
|
|
177
|
+
*/
|
|
178
|
+
export function encodeLoginField({ key, plaintext }) {
|
|
179
|
+
const iv = randomBytes(8);
|
|
180
|
+
const cipher = createCipheriv('des-ede3-cbc', key, iv);
|
|
181
|
+
cipher.setAutoPadding(false);
|
|
182
|
+
const padded = pkcs7Pad(Buffer.from(plaintext, 'utf8'), 8);
|
|
183
|
+
const ciphertext = Buffer.concat([cipher.update(padded), cipher.final()]);
|
|
184
|
+
const ckaId = Buffer.from('f8000000000000000000000000000001', 'hex');
|
|
185
|
+
return derSequence(
|
|
186
|
+
derOctet(ckaId),
|
|
187
|
+
derSequence(derOid(OID_BYTES.DES_EDE3_CBC), derOctet(iv)),
|
|
188
|
+
derOctet(ciphertext)
|
|
189
|
+
).toString('base64');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Build a `logins.json` document from plaintext credentials.
|
|
194
|
+
*
|
|
195
|
+
* @param {Object} options
|
|
196
|
+
* @param {Buffer} options.key
|
|
197
|
+
* @param {Array<{hostname: string, username: string, password: string}>} options.entries
|
|
198
|
+
* @returns {string} JSON text
|
|
199
|
+
*/
|
|
200
|
+
export function buildLoginsJson({ key, entries }) {
|
|
201
|
+
return JSON.stringify({
|
|
202
|
+
nextId: entries.length + 1,
|
|
203
|
+
logins: entries.map((entry, index) => ({
|
|
204
|
+
id: index + 1,
|
|
205
|
+
hostname: entry.hostname,
|
|
206
|
+
encryptedUsername: encodeLoginField({ key, plaintext: entry.username }),
|
|
207
|
+
encryptedPassword: encodeLoginField({ key, plaintext: entry.password }),
|
|
208
|
+
guid: `{fixture-${index}}`,
|
|
209
|
+
})),
|
|
210
|
+
});
|
|
211
|
+
}
|