browser-commander 0.20.0 → 0.21.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.json +2 -1
- package/.prettierignore +1 -0
- package/CHANGELOG.md +159 -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 +21 -4
- 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 +26 -19
- 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 +205 -0
- package/src/browser/real-browser.js +545 -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 +300 -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 +199 -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/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 +3 -2
- package/src/traces/observers.js +14 -5
- package/src/traces/recorder.js +3 -0
- package/src/traces/viewer.js +16 -8
- 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/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 +26 -17
- package/tests/e2e/puppeteer.e2e.test.js +18 -23
- package/tests/e2e/webdriver.e2e.test.js +239 -0
- package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.html +2 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.png +1 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.state.json +54 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +2 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.png +1 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.state.json +60 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +2 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.png +1 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.state.json +60 -0
- package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +18 -0
- package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +77 -0
- package/tests/fixtures/traces/conformance/expected/bundle/mutations/0001.ndjson +2 -0
- package/tests/fixtures/traces/conformance/expected/bundle/mutations/0002.ndjson +1 -0
- package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +309 -0
- package/tests/fixtures/traces/conformance/expected/redaction.json +86 -0
- package/tests/fixtures/traces/conformance/expected/trace.lino +27 -0
- package/tests/fixtures/traces/conformance/scenario.json +249 -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/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 +321 -0
- package/tests/unit/browser/attach/snapshot.test.js +409 -0
- package/tests/unit/browser/attach/websocket.test.js +240 -0
- package/tests/unit/browser/browser-cookie-lock.test.js +102 -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 +361 -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/storage-state.test.js +2 -0
- 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 +437 -0
- package/tests/unit/cli/serve.test.js +167 -0
- package/tests/unit/core/navigation-manager.test.js +11 -1
- package/tests/unit/core/webdriver-conversions.test.js +235 -0
- package/tests/unit/core/webdriver-page.test.js +534 -0
- 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/playwright-protocol-coverage.test.js +231 -0
- package/tests/unit/puppeteer-bindings-coverage.test.js +214 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +27 -0
- package/tests/unit/scripts/feature-parity.test.js +327 -0
- package/tests/unit/traces/conformance.test.js +92 -0
- package/tests/unit/traces/reader.test.js +1 -0
- package/tests/unit/traces/recorder.test.js +1 -0
- package/tests/unit/traces/viewer.test.js +78 -0
- package/tests/unit/utilities/command-stream-only.test.js +28 -0
- package/tests/unit/utilities/subprocess.test.js +80 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// feature-parity: engine.puppeteer@native-typed
|
|
1
2
|
/**
|
|
2
3
|
* E2E tests for browser-commander using Puppeteer engine
|
|
3
4
|
*
|
|
@@ -12,6 +13,7 @@ import assert from 'node:assert';
|
|
|
12
13
|
// Dynamic import for puppeteer since it may not be installed
|
|
13
14
|
let puppeteer;
|
|
14
15
|
let createCommander;
|
|
16
|
+
const TOGGLE_SLIDER = '[data-testid="toggle-switch"] + .toggle-slider';
|
|
15
17
|
|
|
16
18
|
describe('E2E Tests - Puppeteer Engine', { skip: !process.env.RUN_E2E }, () => {
|
|
17
19
|
let browser;
|
|
@@ -216,30 +218,23 @@ describe('E2E Tests - Puppeteer Engine', { skip: !process.env.RUN_E2E }, () => {
|
|
|
216
218
|
return;
|
|
217
219
|
}
|
|
218
220
|
|
|
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 });
|
|
221
|
+
const initialChecked = await page.$eval(
|
|
222
|
+
'[data-testid="toggle-switch"]',
|
|
223
|
+
(input) => input.checked
|
|
224
|
+
);
|
|
225
|
+
await commander.click({ selector: TOGGLE_SLIDER });
|
|
226
|
+
const changedChecked = await page.$eval(
|
|
227
|
+
'[data-testid="toggle-switch"]',
|
|
228
|
+
(input) => input.checked
|
|
229
|
+
);
|
|
230
|
+
assert.notStrictEqual(changedChecked, initialChecked);
|
|
237
231
|
|
|
238
|
-
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
232
|
+
await commander.click({ selector: TOGGLE_SLIDER });
|
|
233
|
+
const finalChecked = await page.$eval(
|
|
234
|
+
'[data-testid="toggle-switch"]',
|
|
235
|
+
(input) => input.checked
|
|
236
|
+
);
|
|
237
|
+
assert.strictEqual(finalChecked, initialChecked);
|
|
243
238
|
});
|
|
244
239
|
|
|
245
240
|
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
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
fake-png
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"url": "https://[redacted]:[redacted]@example.com/start?ticket=[redacted]&lang=en#access_token=[redacted]&view=1",
|
|
3
|
+
"title": "Start – “quoted” 'single' \\ back",
|
|
4
|
+
"readyState": "complete",
|
|
5
|
+
"scroll": {
|
|
6
|
+
"x": 0,
|
|
7
|
+
"y": 12.5
|
|
8
|
+
},
|
|
9
|
+
"viewport": {
|
|
10
|
+
"width": 1280,
|
|
11
|
+
"height": 720
|
|
12
|
+
},
|
|
13
|
+
"activeElement": null,
|
|
14
|
+
"controls": [
|
|
15
|
+
{
|
|
16
|
+
"path": "form > input:nth-of-type(1)",
|
|
17
|
+
"tag": "input",
|
|
18
|
+
"type": "text",
|
|
19
|
+
"name": "user",
|
|
20
|
+
"id": null,
|
|
21
|
+
"focused": false,
|
|
22
|
+
"value": "alice",
|
|
23
|
+
"redacted": false
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"path": "form > input:nth-of-type(2)",
|
|
27
|
+
"tag": "input",
|
|
28
|
+
"type": "checkbox",
|
|
29
|
+
"name": "agree",
|
|
30
|
+
"id": null,
|
|
31
|
+
"focused": false,
|
|
32
|
+
"value": "on",
|
|
33
|
+
"checked": false,
|
|
34
|
+
"redacted": false
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"path": "form > select",
|
|
38
|
+
"tag": "select",
|
|
39
|
+
"type": null,
|
|
40
|
+
"name": "plan",
|
|
41
|
+
"id": null,
|
|
42
|
+
"focused": false,
|
|
43
|
+
"value": "a",
|
|
44
|
+
"selected": [
|
|
45
|
+
0
|
|
46
|
+
],
|
|
47
|
+
"redacted": false
|
|
48
|
+
}
|
|
49
|
+
],
|
|
50
|
+
"frames": [],
|
|
51
|
+
"name": "initial",
|
|
52
|
+
"actor": "recorder",
|
|
53
|
+
"reason": "initial"
|
|
54
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
fake-png
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"url": "https://example.com/next?token=[redacted]",
|
|
3
|
+
"title": "Next",
|
|
4
|
+
"readyState": "interactive",
|
|
5
|
+
"scroll": {
|
|
6
|
+
"x": 0,
|
|
7
|
+
"y": 0
|
|
8
|
+
},
|
|
9
|
+
"viewport": {
|
|
10
|
+
"width": 1280,
|
|
11
|
+
"height": 720
|
|
12
|
+
},
|
|
13
|
+
"activeElement": "form > textarea",
|
|
14
|
+
"controls": [
|
|
15
|
+
{
|
|
16
|
+
"path": "form > input:nth-of-type(1)",
|
|
17
|
+
"tag": "input",
|
|
18
|
+
"type": "text",
|
|
19
|
+
"name": "user",
|
|
20
|
+
"id": null,
|
|
21
|
+
"focused": false,
|
|
22
|
+
"value": "bob",
|
|
23
|
+
"redacted": false
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"path": "form > input:nth-of-type(2)",
|
|
27
|
+
"tag": "input",
|
|
28
|
+
"type": "checkbox",
|
|
29
|
+
"name": "agree",
|
|
30
|
+
"id": null,
|
|
31
|
+
"focused": false,
|
|
32
|
+
"value": "on",
|
|
33
|
+
"checked": true,
|
|
34
|
+
"redacted": false
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"path": "form > textarea",
|
|
38
|
+
"tag": "textarea",
|
|
39
|
+
"type": null,
|
|
40
|
+
"name": null,
|
|
41
|
+
"id": "note",
|
|
42
|
+
"focused": true,
|
|
43
|
+
"value": "[redacted] note",
|
|
44
|
+
"scroll": {
|
|
45
|
+
"x": 0,
|
|
46
|
+
"y": 3
|
|
47
|
+
},
|
|
48
|
+
"redacted": false
|
|
49
|
+
}
|
|
50
|
+
],
|
|
51
|
+
"frames": [
|
|
52
|
+
{
|
|
53
|
+
"url": "https://ads.example.com/frame",
|
|
54
|
+
"crossOrigin": true
|
|
55
|
+
}
|
|
56
|
+
],
|
|
57
|
+
"name": "after click",
|
|
58
|
+
"actor": "automation",
|
|
59
|
+
"reason": "checkpoint"
|
|
60
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
fake-png
|