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.
Files changed (155) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/README.md +216 -32
  3. package/bin/browser-commander.js +16 -0
  4. package/eslint.config.js +10 -0
  5. package/examples/react-test-app/src/App.jsx +24 -23
  6. package/extension/background.js +14 -0
  7. package/extension/manifest.json +12 -0
  8. package/extension/relay-handler.js +353 -0
  9. package/package.json +25 -6
  10. package/scripts/run-tests.mjs +4 -2
  11. package/src/browser/attach/differences.js +131 -0
  12. package/src/browser/attach/extension-relay.js +402 -0
  13. package/src/browser/attach/index.js +81 -0
  14. package/src/browser/attach/snapshot-launch.js +146 -0
  15. package/src/browser/attach/snapshot.js +374 -0
  16. package/src/browser/attach/websocket.js +615 -0
  17. package/src/browser/browser-cookie-cache.js +12 -17
  18. package/src/browser/browser-cookie-credentials.js +6 -9
  19. package/src/browser/browser-cookies.js +5 -2
  20. package/src/browser/cdp-session.js +94 -0
  21. package/src/browser/connector.js +42 -4
  22. package/src/browser/debugging-port.js +155 -0
  23. package/src/browser/launch-options.js +63 -52
  24. package/src/browser/launch-restrictions.json +147 -0
  25. package/src/browser/launcher.js +329 -133
  26. package/src/browser/media.js +5 -0
  27. package/src/browser/migration/bookmarks.js +73 -0
  28. package/src/browser/migration/chromium-crypto.js +89 -0
  29. package/src/browser/migration/cookies.js +155 -0
  30. package/src/browser/migration/extensions.js +187 -0
  31. package/src/browser/migration/firefox-bookmarks.js +144 -0
  32. package/src/browser/migration/firefox-der.js +84 -0
  33. package/src/browser/migration/firefox-nss.js +281 -0
  34. package/src/browser/migration/firefox.js +334 -0
  35. package/src/browser/migration/fs-utils.js +51 -0
  36. package/src/browser/migration/history.js +103 -0
  37. package/src/browser/migration/index.js +307 -0
  38. package/src/browser/migration/os-crypt-keys.js +106 -0
  39. package/src/browser/migration/passwords.js +171 -0
  40. package/src/browser/migration/preferences.js +132 -0
  41. package/src/browser/migration/sqlite-snapshot.js +138 -0
  42. package/src/browser/open-in-user-browser.js +112 -0
  43. package/src/browser/parity.js +439 -0
  44. package/src/browser/profile-directory.js +102 -0
  45. package/src/browser/real-browser.js +513 -306
  46. package/src/browser/restrictions.js +148 -0
  47. package/src/browser/system-browser.js +232 -0
  48. package/src/browser/webdriver.js +572 -0
  49. package/src/cli/args.js +253 -0
  50. package/src/cli/commands.js +332 -0
  51. package/src/cli/dispatcher.js +99 -0
  52. package/src/cli/handle-methods.js +136 -0
  53. package/src/cli/handles.js +273 -0
  54. package/src/cli/high-level-methods.js +310 -0
  55. package/src/cli/main.js +67 -0
  56. package/src/cli/modules.js +100 -0
  57. package/src/cli/rpc-error.js +77 -0
  58. package/src/cli/script.js +77 -0
  59. package/src/cli/serve.js +144 -0
  60. package/src/cli/sessions.js +195 -0
  61. package/src/core/constants.js +7 -1
  62. package/src/core/engine-adapter.js +107 -1
  63. package/src/core/engine-detection.js +18 -3
  64. package/src/core/navigation-safety.js +4 -0
  65. package/src/core/webdriver-conversions.js +347 -0
  66. package/src/core/webdriver-events.js +229 -0
  67. package/src/core/webdriver-page.js +716 -0
  68. package/src/downloads/attach.js +6 -0
  69. package/src/downloads/manager.js +9 -0
  70. package/src/downloads/sources.js +71 -0
  71. package/src/downloads/staging.js +21 -9
  72. package/src/elements/selectors.js +3 -2
  73. package/src/elements/visibility.js +4 -0
  74. package/src/exports.js +61 -2
  75. package/src/factory.js +19 -4
  76. package/src/fingerprint/apply.js +6 -0
  77. package/src/fingerprint/automation-parity.js +7 -8
  78. package/src/fingerprint/limitations.js +1 -1
  79. package/src/fingerprint/limitations.json +45 -2
  80. package/src/parity/harness.js +303 -0
  81. package/src/parity/probe.js +838 -0
  82. package/src/parity/version-page.js +144 -0
  83. package/src/traces/bundle.js +10 -2
  84. package/src/traces/recorder.js +16 -3
  85. package/src/utilities/subprocess.js +199 -0
  86. package/tests/e2e/api-coverage.e2e.test.js +174 -0
  87. package/tests/e2e/cli.e2e.test.js +160 -0
  88. package/tests/e2e/downloads.e2e.test.js +79 -2
  89. package/tests/e2e/fingerprint-parity.e2e.test.js +104 -46
  90. package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
  91. package/tests/e2e/measure-parity.e2e.test.js +118 -0
  92. package/tests/e2e/playwright.e2e.test.js +25 -17
  93. package/tests/e2e/puppeteer.e2e.test.js +17 -23
  94. package/tests/e2e/webdriver.e2e.test.js +239 -0
  95. package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
  96. package/tests/helpers/cli-fakes.js +214 -0
  97. package/tests/helpers/declared-api.js +80 -0
  98. package/tests/helpers/e2e-browser.js +40 -14
  99. package/tests/helpers/fake-chrome.js +124 -0
  100. package/tests/helpers/migration-fixtures.js +242 -0
  101. package/tests/helpers/temp-directory.js +24 -0
  102. package/tests/helpers/trace-fixtures.js +18 -0
  103. package/tests/helpers/webdriver-mocks.js +183 -0
  104. package/tests/unit/browser/attach/extension-relay.test.js +360 -0
  105. package/tests/unit/browser/attach/snapshot-launch.test.js +312 -0
  106. package/tests/unit/browser/attach/snapshot.test.js +390 -0
  107. package/tests/unit/browser/attach/websocket.test.js +240 -0
  108. package/tests/unit/browser/browser-cookies.test.js +2 -32
  109. package/tests/unit/browser/cdp-session.test.js +162 -0
  110. package/tests/unit/browser/connector.test.js +23 -0
  111. package/tests/unit/browser/launch-building-blocks.test.js +238 -0
  112. package/tests/unit/browser/launch-options.test.js +41 -22
  113. package/tests/unit/browser/launcher.test.js +281 -102
  114. package/tests/unit/browser/media.test.js +13 -1
  115. package/tests/unit/browser/migration/bookmarks.test.js +69 -0
  116. package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
  117. package/tests/unit/browser/migration/cookies.test.js +94 -0
  118. package/tests/unit/browser/migration/extensions.test.js +104 -0
  119. package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
  120. package/tests/unit/browser/migration/firefox.test.js +139 -0
  121. package/tests/unit/browser/migration/history.test.js +48 -0
  122. package/tests/unit/browser/migration/index.test.js +117 -0
  123. package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
  124. package/tests/unit/browser/migration/passwords.test.js +144 -0
  125. package/tests/unit/browser/migration/preferences.test.js +77 -0
  126. package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
  127. package/tests/unit/browser/open-in-user-browser.test.js +123 -0
  128. package/tests/unit/browser/parity.test.js +281 -0
  129. package/tests/unit/browser/real-browser.test.js +458 -33
  130. package/tests/unit/browser/webdriver.test.js +473 -0
  131. package/tests/unit/cli/bin.test.js +69 -0
  132. package/tests/unit/cli/declared-api.test.js +49 -0
  133. package/tests/unit/cli/dispatcher.test.js +429 -0
  134. package/tests/unit/cli/handles.test.js +170 -0
  135. package/tests/unit/cli/main.test.js +409 -0
  136. package/tests/unit/cli/serve.test.js +167 -0
  137. package/tests/unit/core/webdriver-conversions.test.js +235 -0
  138. package/tests/unit/core/webdriver-page.test.js +534 -0
  139. package/tests/unit/downloads/manager.test.js +82 -9
  140. package/tests/unit/downloads/staging.test.js +24 -2
  141. package/tests/unit/fingerprint/automation-parity.test.js +17 -4
  142. package/tests/unit/fingerprint/limitations.test.js +1 -1
  143. package/tests/unit/parity/harness.test.js +138 -0
  144. package/tests/unit/scripts/ci-timeout-budgets.test.js +4 -0
  145. package/tests/unit/scripts/ci-workflow-policy.test.js +8 -0
  146. package/tests/unit/scripts/feature-parity.test.js +221 -0
  147. package/tests/unit/scripts/npm-release-helpers.test.js +3 -2
  148. package/tests/unit/scripts/pre-commit-config.test.js +2 -1
  149. package/tests/unit/traces/bundle.test.js +24 -3
  150. package/tests/unit/traces/links.test.js +7 -0
  151. package/tests/unit/traces/reader.test.js +11 -4
  152. package/tests/unit/traces/recorder.test.js +4 -0
  153. package/tests/unit/traces/viewer.test.js +4 -0
  154. package/tests/unit/utilities/command-stream-only.test.js +28 -0
  155. 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
- const checked = await commander.getAttribute({
128
- selector: '[data-testid="radio-male"]',
129
- attribute: 'checked',
130
- });
131
- // Playwright returns empty string for checked attribute when true
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: '[data-testid="toggle-switch"]' });
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.scroll({ selector: '[data-testid="scroll-item-15"]' });
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
- page.on('dialog', async (dialog) => {
360
+ const onDialog = async (dialog) => {
358
361
  alertHandled = true;
359
362
  await dialog.accept();
360
- });
361
-
362
- await commander.click({
363
- selector: '[data-testid="scroll-target-button"]',
364
- });
365
- await commander.wait({ ms: 100 });
366
-
367
- assert.strictEqual(alertHandled, true);
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
- // Get initial state
220
- const initialStatus = await commander.textContent({
221
- selector: '[data-testid="toggle-status"]',
222
- });
223
-
224
- // Toggle
225
- await commander.click({ selector: '[data-testid="toggle-switch"]' });
226
- await commander.wait({ ms: 50 });
227
-
228
- // Verify changed
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
- // Verify back to original
239
- const finalStatus = await commander.textContent({
240
- selector: '[data-testid="toggle-status"]',
241
- });
242
- assert.strictEqual(initialStatus.trim(), finalStatus.trim());
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
+ }