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
@@ -0,0 +1,327 @@
1
+ /**
2
+ * The generated feature-parity matrix (issue #104).
3
+ *
4
+ * `scripts/generate-feature-parity.mjs` builds the per-language matrix in
5
+ * docs/feature-parity.md from the `feature-parity:` tags in the three test
6
+ * suites, and fails when a feature is tested in one language and missing in
7
+ * another without a reason in docs/feature-parity/limitations.json. These
8
+ * tests pin those rules against a fixture tree, then pin the repository
9
+ * itself: the committed document must be what the generator writes.
10
+ *
11
+ * Fixture tags are assembled at run time, because the generator scans this
12
+ * file too and a literal tag here would claim a feature.
13
+ */
14
+
15
+ import assert from 'node:assert/strict';
16
+ import * as fs from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import path from 'node:path';
19
+ import { describe, it } from 'node:test';
20
+ import { pathToFileURL } from 'node:url';
21
+
22
+ import { repoPath } from '../../helpers/repo.js';
23
+
24
+ const {
25
+ BEGIN_MARKER,
26
+ END_MARKER,
27
+ escapeCell,
28
+ generate,
29
+ parseTags,
30
+ renderTable,
31
+ } = await import(
32
+ pathToFileURL(repoPath('scripts/generate-feature-parity.mjs'))
33
+ );
34
+
35
+ const TAG = ['feature', 'parity:'].join('-');
36
+
37
+ function tag(comment, ids) {
38
+ return `${comment} ${TAG} ${ids
39
+ .split(/[\s,]+/u)
40
+ .filter(Boolean)
41
+ .map((id) => `${id}@native-typed`)
42
+ .join(' ')}\n`;
43
+ }
44
+
45
+ function fixture({ tests, limitations = [] }) {
46
+ const root = fs.mkdtempSync(path.join(tmpdir(), 'feature-parity-'));
47
+ const write = (relative, content) => {
48
+ const full = path.join(root, relative);
49
+ fs.mkdirSync(path.dirname(full), { recursive: true });
50
+ fs.writeFileSync(full, content);
51
+ };
52
+ write(
53
+ 'docs/feature-parity/features.json',
54
+ JSON.stringify({
55
+ sections: [
56
+ {
57
+ title: 'Launch',
58
+ features: [
59
+ { id: 'launch.real', title: 'Real launch' },
60
+ { id: 'launch.attach', title: 'Attach | snapshot' },
61
+ ],
62
+ },
63
+ ],
64
+ })
65
+ );
66
+ write(
67
+ 'docs/feature-parity/limitations.json',
68
+ JSON.stringify(limitations, null, 2)
69
+ );
70
+ write(
71
+ 'docs/feature-parity.md',
72
+ `# Parity\n\nprose\n\n${BEGIN_MARKER}\n${END_MARKER}\n\ntail\n`
73
+ );
74
+ for (const [relative, content] of Object.entries(tests)) {
75
+ write(relative, content);
76
+ }
77
+ return root;
78
+ }
79
+
80
+ function inspectFixture(options, assertion) {
81
+ const root = fixture(options);
82
+ try {
83
+ assertion(generate(root));
84
+ } finally {
85
+ fs.rmSync(root, { recursive: true, force: true });
86
+ }
87
+ }
88
+
89
+ const ALL_LANGUAGES = {
90
+ 'js/tests/unit/launch.test.js': tag('//', 'launch.real launch.attach'),
91
+ 'python/tests/unit/test_launch.py': tag('#', 'launch.real, launch.attach'),
92
+ 'rust/src/launch.rs': `fn x() {}\n${tag(' //', 'launch.real')}${tag('//', 'launch.attach')}`,
93
+ };
94
+
95
+ describe('feature parity generator', () => {
96
+ it('reads space- and comma-separated tags from line comments only', () => {
97
+ assert.deepEqual(parseTags(tag('//', 'a.b c-d')), [
98
+ 'a.b@native-typed',
99
+ 'c-d@native-typed',
100
+ ]);
101
+ assert.deepEqual(parseTags(tag(' #', 'a.b,c.d')), [
102
+ 'a.b@native-typed',
103
+ 'c.d@native-typed',
104
+ ]);
105
+ assert.deepEqual(parseTags(`const s = '${tag('//', 'a.b').trim()}';`), []);
106
+ assert.deepEqual(parseTags(tag('//', 'a.b').replaceAll('\n', '\r\n')), [
107
+ 'a.b@native-typed',
108
+ ]);
109
+ });
110
+
111
+ it('escapes both Markdown pipes and backslashes in feature names', () => {
112
+ assert.equal(escapeCell('a\\b|c\nx'), 'a\\\\b\\|c x');
113
+ });
114
+
115
+ it('accepts a generated document checked out with CRLF endings', () => {
116
+ const root = fixture({ tests: ALL_LANGUAGES });
117
+ try {
118
+ const documentPath = path.join(root, 'docs/feature-parity.md');
119
+ const { next } = generate(root);
120
+ fs.writeFileSync(documentPath, next.replaceAll('\n', '\r\n'));
121
+ const generated = generate(root);
122
+ assert.equal(generated.current, generated.next);
123
+ assert.equal(generated.next.includes('\r'), false);
124
+ } finally {
125
+ fs.rmSync(root, { recursive: true, force: true });
126
+ }
127
+ });
128
+
129
+ it('pads tables so the output is stable under Prettier', () => {
130
+ assert.equal(
131
+ renderTable(['A', 'Long header'], [['wide cell', 'x']]),
132
+ [
133
+ '| A | Long header |',
134
+ '| --------- | ----------- |',
135
+ '| wide cell | x |',
136
+ ].join('\n')
137
+ );
138
+ });
139
+
140
+ it('writes a matrix with links to the claiming tests', () => {
141
+ const root = fixture({ tests: ALL_LANGUAGES });
142
+ try {
143
+ const { next, errors } = generate(root);
144
+ assert.deepEqual(errors, []);
145
+ assert.match(next, /^# Parity\n\nprose\n\n<!-- feature-parity/u);
146
+ assert.match(
147
+ next,
148
+ /\| Real launch +\| \[Native typed\]\(\.\.\/js\/tests\/unit\/launch\.test\.js\) +\| \[Native typed\]\(\.\.\/python\/tests\/unit\/test_launch\.py\) +\| \[Native typed\]\(\.\.\/rust\/src\/launch\.rs\) +\|/u
149
+ );
150
+ assert.match(next, /Attach \\\| snapshot/u);
151
+ assert.match(next, /None: every feature is tested in every language/u);
152
+ assert.match(next, /\n\ntail\n$/u);
153
+ } finally {
154
+ fs.rmSync(root, { recursive: true, force: true });
155
+ }
156
+ });
157
+
158
+ it('fails on a gap without a limitation', () => {
159
+ const tests = { ...ALL_LANGUAGES };
160
+ tests['rust/src/launch.rs'] = tag('//', 'launch.real');
161
+ const root = fixture({ tests });
162
+ try {
163
+ const { errors, next } = generate(root);
164
+ assert.deepEqual(errors, [
165
+ 'feature "launch.attach" is tested in javascript, python but not in ' +
166
+ 'rust, and docs/feature-parity/limitations.json gives no reason',
167
+ ]);
168
+ assert.match(next, /\| Missing +\|/u);
169
+ } finally {
170
+ fs.rmSync(root, { recursive: true, force: true });
171
+ }
172
+ });
173
+
174
+ it('accepts a gap with a technical reason and links to it', () => {
175
+ const tests = { ...ALL_LANGUAGES };
176
+ tests['rust/src/launch.rs'] = tag('//', 'launch.real');
177
+ const root = fixture({
178
+ tests,
179
+ limitations: [
180
+ {
181
+ id: 'rust-no-attach',
182
+ features: ['launch.attach'],
183
+ languages: ['rust'],
184
+ reason: 'The crate has no WebSocket server.',
185
+ },
186
+ ],
187
+ });
188
+ try {
189
+ const { errors, next } = generate(root);
190
+ assert.deepEqual(errors, []);
191
+ assert.match(next, /\[Limitation\]\(#rust-no-attach\)/u);
192
+ assert.match(
193
+ next,
194
+ /<a id="rust-no-attach"><\/a>\*\*`rust-no-attach`\*\* \(Rust\): The crate has no WebSocket server\./u
195
+ );
196
+ } finally {
197
+ fs.rmSync(root, { recursive: true, force: true });
198
+ }
199
+ });
200
+
201
+ it('rejects a claim without an API tier', () => {
202
+ inspectFixture(
203
+ {
204
+ tests: {
205
+ ...ALL_LANGUAGES,
206
+ 'js/tests/unit/legacy.test.js': `// ${TAG} launch.real\n`,
207
+ },
208
+ },
209
+ ({ errors }) => {
210
+ assert.ok(errors.some((error) => /has no valid API tier/u.test(error)));
211
+ }
212
+ );
213
+ });
214
+
215
+ it('requires a technical reason for untyped CLI access', () => {
216
+ inspectFixture(
217
+ {
218
+ tests: {
219
+ ...ALL_LANGUAGES,
220
+ 'rust/src/launch.rs': `// ${TAG} launch.real@untyped-via-cli launch.attach@native-typed\n`,
221
+ },
222
+ },
223
+ ({ errors }) => {
224
+ assert.ok(
225
+ errors.some((error) => /below typed via bridge/u.test(error))
226
+ );
227
+ }
228
+ );
229
+ });
230
+
231
+ it('renders typed bridge access and a documented CLI exception', () => {
232
+ inspectFixture(
233
+ {
234
+ tests: {
235
+ ...ALL_LANGUAGES,
236
+ 'python/tests/unit/test_launch.py': `# ${TAG} launch.real@typed-via-bridge launch.attach@native-typed\n`,
237
+ 'rust/src/launch.rs': `// ${TAG} launch.real@untyped-via-cli launch.attach@native-typed\n`,
238
+ },
239
+ limitations: [
240
+ {
241
+ id: 'dynamic-launch',
242
+ features: ['launch.real'],
243
+ languages: ['rust'],
244
+ tier: 'untyped-via-cli',
245
+ reason: 'This command accepts a dynamic JSON script.',
246
+ },
247
+ ],
248
+ },
249
+ ({ errors, next }) => {
250
+ assert.deepEqual(errors, []);
251
+ assert.match(
252
+ next,
253
+ /\[Typed via bridge\]\(\.\.\/python\/tests\/unit\/test_launch\.py\)/u
254
+ );
255
+ assert.match(
256
+ next,
257
+ /\[Untyped via CLI\]\(\.\.\/rust\/src\/launch\.rs\) \(\[reason\]\(#dynamic-launch\)\)/u
258
+ );
259
+ }
260
+ );
261
+ });
262
+
263
+ it('rejects a stale CLI exception that has no claim', () => {
264
+ inspectFixture(
265
+ {
266
+ tests: {
267
+ ...ALL_LANGUAGES,
268
+ 'rust/src/launch.rs': tag('//', 'launch.attach'),
269
+ },
270
+ limitations: [
271
+ {
272
+ id: 'stale-cli-exception',
273
+ features: ['launch.real'],
274
+ languages: ['rust'],
275
+ tier: 'untyped-via-cli',
276
+ reason: 'Dynamic JSON input.',
277
+ },
278
+ ],
279
+ },
280
+ ({ errors }) => {
281
+ assert.ok(
282
+ errors.some((error) => /expects untyped CLI access/u.test(error))
283
+ );
284
+ }
285
+ );
286
+ });
287
+
288
+ it('rejects stale, reasonless and unknown entries', () => {
289
+ const tests = {
290
+ ...ALL_LANGUAGES,
291
+ 'js/tests/unit/extra.test.js': tag('//', 'launch.unknown'),
292
+ };
293
+ const root = fixture({
294
+ tests,
295
+ limitations: [
296
+ {
297
+ id: 'stale',
298
+ features: ['launch.real', 'launch.missing'],
299
+ languages: ['rust', 'cobol'],
300
+ reason: ' ',
301
+ },
302
+ ],
303
+ });
304
+ try {
305
+ const { errors } = generate(root);
306
+ assert.deepEqual(errors.sort(), [
307
+ 'limitation "stale" covers launch.real in rust, but rust/src/launch.rs tests it; remove the stale entry',
308
+ 'limitation "stale" gives no technical reason',
309
+ 'limitation "stale" names unknown feature "launch.missing"',
310
+ 'limitation "stale" names unknown language "cobol"',
311
+ 'unknown feature "launch.unknown" claimed by js/tests/unit/extra.test.js; add it to docs/feature-parity/features.json',
312
+ ]);
313
+ } finally {
314
+ fs.rmSync(root, { recursive: true, force: true });
315
+ }
316
+ });
317
+
318
+ it('keeps docs/feature-parity.md in sync with the tests', () => {
319
+ const { current, next, errors } = generate(repoPath());
320
+ assert.deepEqual(errors, []);
321
+ assert.equal(
322
+ current,
323
+ next,
324
+ 'run node scripts/generate-feature-parity.mjs and commit the result'
325
+ );
326
+ });
327
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Trace conformance across languages (issue #108).
3
+ *
4
+ * Python and Rust record traces natively and must write what JavaScript
5
+ * writes. They compare their output with two committed sets of files, and
6
+ * these tests keep both in step with the JavaScript sources:
7
+ *
8
+ * - `assets.json` in each package holds the in-page capture functions and
9
+ * the viewer's style and script (`scripts/generate-trace-assets.mjs`);
10
+ * - `tests/fixtures/traces/conformance/expected/` is the bundle, Links
11
+ * Notation export and redaction corpus the JavaScript recorder produces
12
+ * for `scenario.json` (`scripts/generate-trace-conformance.mjs`).
13
+ */
14
+
15
+ import { describe, it } from 'node:test';
16
+ import assert from 'node:assert/strict';
17
+ import fs from 'node:fs';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+ import { pathToFileURL } from 'node:url';
21
+
22
+ import { formatTraceLinks, traceLinks } from '../../../src/traces/links.js';
23
+ import { repoPath } from '../../helpers/repo.js';
24
+
25
+ const { generateTraceAssets } = await import(
26
+ pathToFileURL(repoPath('scripts/generate-trace-assets.mjs'))
27
+ );
28
+ const {
29
+ EXPECTED_DIR,
30
+ generateTraceConformance,
31
+ readScenario,
32
+ recordConformanceScenario,
33
+ } = await import(
34
+ pathToFileURL(repoPath('scripts/generate-trace-conformance.mjs'))
35
+ );
36
+
37
+ describe('trace conformance', () => {
38
+ it('keeps the Python and Rust trace assets in sync', async () => {
39
+ assert.deepEqual(
40
+ await generateTraceAssets({ check: true }),
41
+ [],
42
+ 'run node scripts/generate-trace-assets.mjs and commit the result'
43
+ );
44
+ });
45
+
46
+ it('keeps the golden conformance bundle in sync', async () => {
47
+ assert.deepEqual(
48
+ await generateTraceConformance({ check: true }),
49
+ [],
50
+ 'run node scripts/generate-trace-conformance.mjs and commit the result'
51
+ );
52
+ });
53
+
54
+ it('streams the same Links Notation the bundle exports afterwards', async () => {
55
+ const output = fs.mkdtempSync(path.join(os.tmpdir(), 'bc-conformance-'));
56
+ try {
57
+ await recordConformanceScenario(readScenario(), output);
58
+ assert.equal(
59
+ formatTraceLinks(await traceLinks(path.join(output, 'bundle'))),
60
+ fs.readFileSync(path.join(output, 'trace.lino'), 'utf8')
61
+ );
62
+ } finally {
63
+ fs.rmSync(output, { recursive: true, force: true });
64
+ }
65
+ });
66
+
67
+ it('covers every kind of timeline event in the golden bundle', () => {
68
+ const kinds = new Set(
69
+ fs
70
+ .readFileSync(path.join(EXPECTED_DIR, 'bundle/events.ndjson'), 'utf8')
71
+ .trim()
72
+ .split('\n')
73
+ .map((line) => JSON.parse(line).kind)
74
+ );
75
+ for (const kind of [
76
+ 'trace.start',
77
+ 'checkpoint',
78
+ 'navigation',
79
+ 'console',
80
+ 'pageerror',
81
+ 'requestfailed',
82
+ 'dialog',
83
+ 'download',
84
+ 'interaction',
85
+ 'dropped',
86
+ 'mutations',
87
+ 'trace.stop',
88
+ ]) {
89
+ assert.ok(kinds.has(kind), `the scenario never records ${kind}`);
90
+ }
91
+ });
92
+ });
@@ -1,3 +1,4 @@
1
+ // feature-parity: trace.read@native-typed
1
2
  import { describe, it } from 'node:test';
2
3
  import assert from 'node:assert';
3
4
  import fs from 'node:fs/promises';
@@ -1,3 +1,4 @@
1
+ // feature-parity: trace.record@native-typed
1
2
  import { describe, it } from 'node:test';
2
3
  import assert from 'node:assert';
3
4
  import { setImmediate as yieldToEngine } from 'node:timers/promises';
@@ -2,12 +2,14 @@ import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert';
3
3
  import fs from 'node:fs/promises';
4
4
  import path from 'node:path';
5
+ import vm from 'node:vm';
5
6
 
6
7
  import { readTrace } from '../../../src/traces/reader.js';
7
8
  import { startTrace } from '../../../src/traces/recorder.js';
8
9
  import { TRACE_FILES, TRACE_MODE } from '../../../src/traces/schema.js';
9
10
  import {
10
11
  DEFAULT_MAX_INLINE_BYTES,
12
+ VIEWER_SCRIPT,
11
13
  renderTraceViewer,
12
14
  writeTraceViewer,
13
15
  } from '../../../src/traces/viewer.js';
@@ -82,6 +84,42 @@ describe('offline trace viewer (issue #87)', () => {
82
84
  )[1]
83
85
  );
84
86
 
87
+ /**
88
+ * Run the viewer's own script against a minimal stand-in for its document.
89
+ *
90
+ * @param {string} viewer - The rendered viewer document
91
+ * @returns {Object} The stand-in elements, by id
92
+ */
93
+ const runViewerScript = (viewer) => {
94
+ const element = () => {
95
+ const node = {
96
+ innerHTML: '',
97
+ textContent: '',
98
+ children: [],
99
+ listeners: {},
100
+ parts: {},
101
+ classList: { add() {}, remove() {} },
102
+ addEventListener: (type, listener) => {
103
+ node.listeners[type] = listener;
104
+ },
105
+ append: (child) => node.children.push(child),
106
+ querySelector: (selector) => (node.parts[selector] ??= element()),
107
+ };
108
+ return node;
109
+ };
110
+ const elements = { 'trace-data': element() };
111
+ elements['trace-data'].textContent = viewer.match(
112
+ /<script id="trace-data" type="application\/json">([\s\S]*?)<\/script>/
113
+ )[1];
114
+ const document = {
115
+ getElementById: (id) => (elements[id] ??= element()),
116
+ createElement: () => element(),
117
+ };
118
+ assert.ok(viewer.includes(VIEWER_SCRIPT));
119
+ vm.runInNewContext(VIEWER_SCRIPT, { document, JSON, String, Object });
120
+ return elements;
121
+ };
122
+
85
123
  /**
86
124
  * Render the viewer for a bundle recorded from one snapshot.
87
125
  *
@@ -151,6 +189,46 @@ describe('offline trace viewer (issue #87)', () => {
151
189
  assert.ok(viewer.includes('\\u003cimg src=x onerror=alert(1)\\u003e'));
152
190
  });
153
191
 
192
+ it('should show recorded text as text, never as the viewer’s own markup', async () => {
193
+ // The URL, the title, a control's value and an error message are all
194
+ // whatever the recorded page made them. The viewer's policy allows
195
+ // inline handlers for its own script, so markup reaching innerHTML
196
+ // would run in the reviewer's viewer.
197
+ const payload = '<img src=x onerror=alert(1)>';
198
+ const { trace, page } = await beginTrace('hostile', {
199
+ pageOptions: {
200
+ snapshot: makeSnapshot({ state: { url: payload, title: payload } }),
201
+ },
202
+ });
203
+ await trace.checkpoint('before');
204
+ page.setSnapshot(
205
+ makeSnapshot({
206
+ state: {
207
+ url: payload,
208
+ title: payload,
209
+ controls: [{ path: payload, tag: 'input', value: payload }],
210
+ },
211
+ })
212
+ );
213
+ await trace.checkpoint('after');
214
+ const stopped = await trace.stop({ error: new Error(payload) });
215
+
216
+ const ui = runViewerScript(
217
+ await renderTraceViewer(await readTrace(stopped.path))
218
+ );
219
+ const shown = [];
220
+ for (const li of ui.timeline.children) {
221
+ li.listeners.click();
222
+ shown.push(ui.details.innerHTML, ui.diff.innerHTML);
223
+ }
224
+
225
+ for (const html of shown) {
226
+ assert.ok(!html.includes('<img'), `markup reached the viewer: ${html}`);
227
+ }
228
+ // The text is still shown, escaped, so a reviewer can read it.
229
+ assert.ok(shown.some((html) => html.includes('&#60;img src=x')));
230
+ });
231
+
154
232
  it('should escape the line separators a JavaScript parser treats as newlines', async () => {
155
233
  const page = createFakePage({
156
234
  snapshot: makeSnapshot({ html: '<p>one
two
three</p>' }),
@@ -0,0 +1,28 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { readdirSync, readFileSync } from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ import { repoPath } from '../../helpers/repo.js';
7
+
8
+ const SOURCE_ROOT = repoPath('js', 'src');
9
+ const DIRECT_SUBPROCESS = /from\s+['"](?:node:)?child_process['"]/u;
10
+
11
+ function listSourceFiles(directory) {
12
+ return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
13
+ const entryPath = path.join(directory, entry.name);
14
+ if (entry.isDirectory()) {
15
+ return listSourceFiles(entryPath);
16
+ }
17
+ return /\.[cm]?js$/u.test(entry.name) ? [entryPath] : [];
18
+ });
19
+ }
20
+
21
+ describe('subprocesses go through command-stream (issue #104)', () => {
22
+ it('no library module imports node:child_process directly', () => {
23
+ const offenders = listSourceFiles(SOURCE_ROOT)
24
+ .filter((file) => DIRECT_SUBPROCESS.test(readFileSync(file, 'utf8')))
25
+ .map((file) => path.relative(SOURCE_ROOT, file));
26
+ assert.deepEqual(offenders, []);
27
+ });
28
+ });
@@ -0,0 +1,80 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import {
4
+ CommandError,
5
+ runCommand,
6
+ startProcess,
7
+ } from '../../../src/utilities/subprocess.js';
8
+
9
+ const node = process.execPath;
10
+
11
+ describe('runCommand', () => {
12
+ it('passes arguments without a shell and captures output', async () => {
13
+ const result = await runCommand(node, [
14
+ '-e',
15
+ 'process.stdout.write(JSON.stringify(process.argv.slice(1)))',
16
+ 'a b',
17
+ '$HOME',
18
+ '"q"',
19
+ ]);
20
+ assert.equal(result.code, 0);
21
+ assert.deepEqual(JSON.parse(result.stdout), ['a b', '$HOME', '"q"']);
22
+ });
23
+
24
+ it('writes input to stdin and sets the child environment only', async () => {
25
+ const result = await runCommand(
26
+ node,
27
+ [
28
+ '-e',
29
+ 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(s+process.env.BC_TEST_ONLY))',
30
+ ],
31
+ { input: 'in:', env: { ...process.env, BC_TEST_ONLY: 'child' } }
32
+ );
33
+ assert.equal(result.stdout, 'in:child');
34
+ assert.equal(process.env.BC_TEST_ONLY, undefined);
35
+ });
36
+
37
+ it('throws CommandError on a non-zero exit unless check is false', async () => {
38
+ const args = ['-e', 'process.stderr.write("boom");process.exit(3)'];
39
+ await assert.rejects(
40
+ () => runCommand(node, args),
41
+ (error) =>
42
+ error instanceof CommandError &&
43
+ error.code === 3 &&
44
+ error.stderr === 'boom'
45
+ );
46
+ const result = await runCommand(node, args, { check: false });
47
+ assert.equal(result.code, 3);
48
+ });
49
+ });
50
+
51
+ describe('startProcess', () => {
52
+ it('streams output and reports the exit code', async () => {
53
+ const child = startProcess(node, [
54
+ '-e',
55
+ 'process.stderr.write("hello\\n");process.exit(4)',
56
+ ]);
57
+ let stderr = '';
58
+ child.stderr.on('data', (chunk) => {
59
+ stderr += chunk;
60
+ });
61
+ const exitCode = await new Promise((resolve) =>
62
+ child.once('exit', resolve)
63
+ );
64
+ assert.equal(exitCode, 4);
65
+ assert.equal(stderr, 'hello\n');
66
+ assert.equal(await child.exited, 4);
67
+ assert.equal(child.kill(), false);
68
+ });
69
+
70
+ it('kills a running process', async () => {
71
+ const child = startProcess(node, ['-e', 'setInterval(() => {}, 1000)'], {
72
+ killGrace: 200,
73
+ });
74
+ await new Promise((resolve) => setTimeout(resolve, 100));
75
+ assert.ok(child.pid > 0);
76
+ assert.equal(child.kill(), true);
77
+ const exitCode = await child.exited;
78
+ assert.notEqual(exitCode, 0);
79
+ });
80
+ });