browser-commander 0.21.0 → 0.21.2

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 (53) hide show
  1. package/.jscpd.json +2 -1
  2. package/.prettierignore +1 -0
  3. package/CHANGELOG.md +20 -0
  4. package/package.json +6 -3
  5. package/src/browser/attach/snapshot.js +4 -4
  6. package/src/browser/browser-cookie-cache.js +14 -2
  7. package/src/browser/profile-directory.js +109 -6
  8. package/src/browser/real-browser.js +54 -16
  9. package/src/cli/args.js +47 -0
  10. package/src/cli/sessions.js +4 -0
  11. package/src/traces/bundle.js +3 -2
  12. package/src/traces/observers.js +14 -5
  13. package/src/traces/recorder.js +3 -0
  14. package/src/traces/viewer.js +16 -8
  15. package/tests/e2e/playwright.e2e.test.js +1 -0
  16. package/tests/e2e/puppeteer.e2e.test.js +1 -0
  17. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.html +2 -0
  18. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.png +1 -0
  19. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.state.json +54 -0
  20. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +2 -0
  21. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.png +1 -0
  22. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.state.json +60 -0
  23. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +2 -0
  24. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.png +1 -0
  25. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.state.json +60 -0
  26. package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +18 -0
  27. package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +77 -0
  28. package/tests/fixtures/traces/conformance/expected/bundle/mutations/0001.ndjson +2 -0
  29. package/tests/fixtures/traces/conformance/expected/bundle/mutations/0002.ndjson +1 -0
  30. package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +309 -0
  31. package/tests/fixtures/traces/conformance/expected/redaction.json +86 -0
  32. package/tests/fixtures/traces/conformance/expected/trace.lino +27 -0
  33. package/tests/fixtures/traces/conformance/scenario.json +249 -0
  34. package/tests/unit/browser/attach/extension-relay.test.js +1 -1
  35. package/tests/unit/browser/attach/snapshot-launch.test.js +10 -1
  36. package/tests/unit/browser/attach/snapshot.test.js +23 -4
  37. package/tests/unit/browser/browser-cookie-lock.test.js +102 -0
  38. package/tests/unit/browser/launch-building-blocks.test.js +127 -4
  39. package/tests/unit/browser/migration/index.test.js +1 -1
  40. package/tests/unit/browser/open-in-user-browser.test.js +1 -1
  41. package/tests/unit/browser/parity.test.js +1 -1
  42. package/tests/unit/browser/real-browser.test.js +1 -1
  43. package/tests/unit/browser/storage-state.test.js +2 -0
  44. package/tests/unit/cli/main.test.js +30 -2
  45. package/tests/unit/core/navigation-manager.test.js +11 -1
  46. package/tests/unit/playwright-protocol-coverage.test.js +231 -0
  47. package/tests/unit/puppeteer-bindings-coverage.test.js +214 -0
  48. package/tests/unit/scripts/ci-timeout-budgets.test.js +23 -0
  49. package/tests/unit/scripts/feature-parity.test.js +111 -5
  50. package/tests/unit/traces/conformance.test.js +92 -0
  51. package/tests/unit/traces/reader.test.js +1 -1
  52. package/tests/unit/traces/recorder.test.js +1 -1
  53. package/tests/unit/traces/viewer.test.js +78 -0
@@ -35,7 +35,11 @@ const {
35
35
  const TAG = ['feature', 'parity:'].join('-');
36
36
 
37
37
  function tag(comment, ids) {
38
- return `${comment} ${TAG} ${ids}\n`;
38
+ return `${comment} ${TAG} ${ids
39
+ .split(/[\s,]+/u)
40
+ .filter(Boolean)
41
+ .map((id) => `${id}@native-typed`)
42
+ .join(' ')}\n`;
39
43
  }
40
44
 
41
45
  function fixture({ tests, limitations = [] }) {
@@ -73,6 +77,15 @@ function fixture({ tests, limitations = [] }) {
73
77
  return root;
74
78
  }
75
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
+
76
89
  const ALL_LANGUAGES = {
77
90
  'js/tests/unit/launch.test.js': tag('//', 'launch.real launch.attach'),
78
91
  'python/tests/unit/test_launch.py': tag('#', 'launch.real, launch.attach'),
@@ -81,11 +94,17 @@ const ALL_LANGUAGES = {
81
94
 
82
95
  describe('feature parity generator', () => {
83
96
  it('reads space- and comma-separated tags from line comments only', () => {
84
- assert.deepEqual(parseTags(tag('//', 'a.b c-d')), ['a.b', 'c-d']);
85
- assert.deepEqual(parseTags(tag(' #', 'a.b,c.d')), ['a.b', 'c.d']);
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
+ ]);
86
105
  assert.deepEqual(parseTags(`const s = '${tag('//', 'a.b').trim()}';`), []);
87
106
  assert.deepEqual(parseTags(tag('//', 'a.b').replaceAll('\n', '\r\n')), [
88
- 'a.b',
107
+ 'a.b@native-typed',
89
108
  ]);
90
109
  });
91
110
 
@@ -126,7 +145,7 @@ describe('feature parity generator', () => {
126
145
  assert.match(next, /^# Parity\n\nprose\n\n<!-- feature-parity/u);
127
146
  assert.match(
128
147
  next,
129
- /\| Real launch +\| \[Supported\]\(\.\.\/js\/tests\/unit\/launch\.test\.js\) +\| \[Supported\]\(\.\.\/python\/tests\/unit\/test_launch\.py\) +\| \[Supported\]\(\.\.\/rust\/src\/launch\.rs\) +\|/u
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
130
149
  );
131
150
  assert.match(next, /Attach \\\| snapshot/u);
132
151
  assert.match(next, /None: every feature is tested in every language/u);
@@ -179,6 +198,93 @@ describe('feature parity generator', () => {
179
198
  }
180
199
  });
181
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
+
182
288
  it('rejects stale, reasonless and unknown entries', () => {
183
289
  const tests = {
184
290
  ...ALL_LANGUAGES,
@@ -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,4 +1,4 @@
1
- // feature-parity: trace.read
1
+ // feature-parity: trace.read@native-typed
2
2
  import { describe, it } from 'node:test';
3
3
  import assert from 'node:assert';
4
4
  import fs from 'node:fs/promises';
@@ -1,4 +1,4 @@
1
- // feature-parity: trace.record
1
+ // feature-parity: trace.record@native-typed
2
2
  import { describe, it } from 'node:test';
3
3
  import assert from 'node:assert';
4
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>' }),