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.
Files changed (169) hide show
  1. package/.jscpd.json +2 -1
  2. package/.prettierignore +1 -0
  3. package/CHANGELOG.md +159 -0
  4. package/README.md +216 -32
  5. package/bin/browser-commander.js +16 -0
  6. package/eslint.config.js +10 -0
  7. package/examples/react-test-app/src/App.jsx +24 -23
  8. package/extension/background.js +14 -0
  9. package/extension/manifest.json +12 -0
  10. package/extension/relay-handler.js +353 -0
  11. package/package.json +21 -4
  12. package/src/browser/attach/differences.js +131 -0
  13. package/src/browser/attach/extension-relay.js +402 -0
  14. package/src/browser/attach/index.js +81 -0
  15. package/src/browser/attach/snapshot-launch.js +146 -0
  16. package/src/browser/attach/snapshot.js +374 -0
  17. package/src/browser/attach/websocket.js +615 -0
  18. package/src/browser/browser-cookie-cache.js +26 -19
  19. package/src/browser/browser-cookie-credentials.js +6 -9
  20. package/src/browser/browser-cookies.js +5 -2
  21. package/src/browser/cdp-session.js +94 -0
  22. package/src/browser/connector.js +42 -4
  23. package/src/browser/debugging-port.js +155 -0
  24. package/src/browser/launch-options.js +63 -52
  25. package/src/browser/launch-restrictions.json +147 -0
  26. package/src/browser/launcher.js +329 -133
  27. package/src/browser/media.js +5 -0
  28. package/src/browser/migration/bookmarks.js +73 -0
  29. package/src/browser/migration/chromium-crypto.js +89 -0
  30. package/src/browser/migration/cookies.js +155 -0
  31. package/src/browser/migration/extensions.js +187 -0
  32. package/src/browser/migration/firefox-bookmarks.js +144 -0
  33. package/src/browser/migration/firefox-der.js +84 -0
  34. package/src/browser/migration/firefox-nss.js +281 -0
  35. package/src/browser/migration/firefox.js +334 -0
  36. package/src/browser/migration/fs-utils.js +51 -0
  37. package/src/browser/migration/history.js +103 -0
  38. package/src/browser/migration/index.js +307 -0
  39. package/src/browser/migration/os-crypt-keys.js +106 -0
  40. package/src/browser/migration/passwords.js +171 -0
  41. package/src/browser/migration/preferences.js +132 -0
  42. package/src/browser/migration/sqlite-snapshot.js +138 -0
  43. package/src/browser/open-in-user-browser.js +112 -0
  44. package/src/browser/parity.js +439 -0
  45. package/src/browser/profile-directory.js +205 -0
  46. package/src/browser/real-browser.js +545 -306
  47. package/src/browser/restrictions.js +148 -0
  48. package/src/browser/system-browser.js +232 -0
  49. package/src/browser/webdriver.js +572 -0
  50. package/src/cli/args.js +300 -0
  51. package/src/cli/commands.js +332 -0
  52. package/src/cli/dispatcher.js +99 -0
  53. package/src/cli/handle-methods.js +136 -0
  54. package/src/cli/handles.js +273 -0
  55. package/src/cli/high-level-methods.js +310 -0
  56. package/src/cli/main.js +67 -0
  57. package/src/cli/modules.js +100 -0
  58. package/src/cli/rpc-error.js +77 -0
  59. package/src/cli/script.js +77 -0
  60. package/src/cli/serve.js +144 -0
  61. package/src/cli/sessions.js +199 -0
  62. package/src/core/constants.js +7 -1
  63. package/src/core/engine-adapter.js +107 -1
  64. package/src/core/engine-detection.js +18 -3
  65. package/src/core/navigation-safety.js +4 -0
  66. package/src/core/webdriver-conversions.js +347 -0
  67. package/src/core/webdriver-events.js +229 -0
  68. package/src/core/webdriver-page.js +716 -0
  69. package/src/downloads/attach.js +6 -0
  70. package/src/elements/selectors.js +3 -2
  71. package/src/elements/visibility.js +4 -0
  72. package/src/exports.js +61 -2
  73. package/src/factory.js +19 -4
  74. package/src/fingerprint/apply.js +6 -0
  75. package/src/fingerprint/automation-parity.js +7 -8
  76. package/src/fingerprint/limitations.js +1 -1
  77. package/src/fingerprint/limitations.json +45 -2
  78. package/src/parity/harness.js +303 -0
  79. package/src/parity/probe.js +838 -0
  80. package/src/parity/version-page.js +144 -0
  81. package/src/traces/bundle.js +3 -2
  82. package/src/traces/observers.js +14 -5
  83. package/src/traces/recorder.js +3 -0
  84. package/src/traces/viewer.js +16 -8
  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/fingerprint-parity.e2e.test.js +104 -46
  89. package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
  90. package/tests/e2e/measure-parity.e2e.test.js +118 -0
  91. package/tests/e2e/playwright.e2e.test.js +26 -17
  92. package/tests/e2e/puppeteer.e2e.test.js +18 -23
  93. package/tests/e2e/webdriver.e2e.test.js +239 -0
  94. package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
  95. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.html +2 -0
  96. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.png +1 -0
  97. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.state.json +54 -0
  98. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +2 -0
  99. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.png +1 -0
  100. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.state.json +60 -0
  101. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +2 -0
  102. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.png +1 -0
  103. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.state.json +60 -0
  104. package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +18 -0
  105. package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +77 -0
  106. package/tests/fixtures/traces/conformance/expected/bundle/mutations/0001.ndjson +2 -0
  107. package/tests/fixtures/traces/conformance/expected/bundle/mutations/0002.ndjson +1 -0
  108. package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +309 -0
  109. package/tests/fixtures/traces/conformance/expected/redaction.json +86 -0
  110. package/tests/fixtures/traces/conformance/expected/trace.lino +27 -0
  111. package/tests/fixtures/traces/conformance/scenario.json +249 -0
  112. package/tests/helpers/cli-fakes.js +214 -0
  113. package/tests/helpers/declared-api.js +80 -0
  114. package/tests/helpers/e2e-browser.js +40 -14
  115. package/tests/helpers/fake-chrome.js +124 -0
  116. package/tests/helpers/migration-fixtures.js +242 -0
  117. package/tests/helpers/temp-directory.js +24 -0
  118. package/tests/helpers/webdriver-mocks.js +183 -0
  119. package/tests/unit/browser/attach/extension-relay.test.js +360 -0
  120. package/tests/unit/browser/attach/snapshot-launch.test.js +321 -0
  121. package/tests/unit/browser/attach/snapshot.test.js +409 -0
  122. package/tests/unit/browser/attach/websocket.test.js +240 -0
  123. package/tests/unit/browser/browser-cookie-lock.test.js +102 -0
  124. package/tests/unit/browser/browser-cookies.test.js +2 -32
  125. package/tests/unit/browser/cdp-session.test.js +162 -0
  126. package/tests/unit/browser/connector.test.js +23 -0
  127. package/tests/unit/browser/launch-building-blocks.test.js +361 -0
  128. package/tests/unit/browser/launch-options.test.js +41 -22
  129. package/tests/unit/browser/launcher.test.js +281 -102
  130. package/tests/unit/browser/media.test.js +13 -1
  131. package/tests/unit/browser/migration/bookmarks.test.js +69 -0
  132. package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
  133. package/tests/unit/browser/migration/cookies.test.js +94 -0
  134. package/tests/unit/browser/migration/extensions.test.js +104 -0
  135. package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
  136. package/tests/unit/browser/migration/firefox.test.js +139 -0
  137. package/tests/unit/browser/migration/history.test.js +48 -0
  138. package/tests/unit/browser/migration/index.test.js +117 -0
  139. package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
  140. package/tests/unit/browser/migration/passwords.test.js +144 -0
  141. package/tests/unit/browser/migration/preferences.test.js +77 -0
  142. package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
  143. package/tests/unit/browser/open-in-user-browser.test.js +123 -0
  144. package/tests/unit/browser/parity.test.js +281 -0
  145. package/tests/unit/browser/real-browser.test.js +458 -33
  146. package/tests/unit/browser/storage-state.test.js +2 -0
  147. package/tests/unit/browser/webdriver.test.js +473 -0
  148. package/tests/unit/cli/bin.test.js +69 -0
  149. package/tests/unit/cli/declared-api.test.js +49 -0
  150. package/tests/unit/cli/dispatcher.test.js +429 -0
  151. package/tests/unit/cli/handles.test.js +170 -0
  152. package/tests/unit/cli/main.test.js +437 -0
  153. package/tests/unit/cli/serve.test.js +167 -0
  154. package/tests/unit/core/navigation-manager.test.js +11 -1
  155. package/tests/unit/core/webdriver-conversions.test.js +235 -0
  156. package/tests/unit/core/webdriver-page.test.js +534 -0
  157. package/tests/unit/fingerprint/automation-parity.test.js +17 -4
  158. package/tests/unit/fingerprint/limitations.test.js +1 -1
  159. package/tests/unit/parity/harness.test.js +138 -0
  160. package/tests/unit/playwright-protocol-coverage.test.js +231 -0
  161. package/tests/unit/puppeteer-bindings-coverage.test.js +214 -0
  162. package/tests/unit/scripts/ci-timeout-budgets.test.js +27 -0
  163. package/tests/unit/scripts/feature-parity.test.js +327 -0
  164. package/tests/unit/traces/conformance.test.js +92 -0
  165. package/tests/unit/traces/reader.test.js +1 -0
  166. package/tests/unit/traces/recorder.test.js +1 -0
  167. package/tests/unit/traces/viewer.test.js +78 -0
  168. package/tests/unit/utilities/command-stream-only.test.js +28 -0
  169. 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
- // 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 });
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
- // Verify back to original
239
- const finalStatus = await commander.textContent({
240
- selector: '[data-testid="toggle-status"]',
241
- });
242
- assert.strictEqual(initialStatus.trim(), finalStatus.trim());
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,2 @@
1
+ <!DOCTYPE html>
2
+ <html><head><title>Start</title></head><body><form><input name="user" value="alice"><input type="checkbox" name="agree"><select name="plan"><option>a</option></select></form></body></html>
@@ -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,2 @@
1
+ <!DOCTYPE html>
2
+ <html><body><form><input name="user" value="bob"><textarea>sk-zzz9 note</textarea></form></body></html>
@@ -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,2 @@
1
+ <!DOCTYPE html>
2
+ <html><body><form><input name="user" value="bob"><textarea>sk-zzz9 note</textarea></form></body></html>