browser-commander 0.21.0 → 0.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.jscpd.json +2 -1
- package/.prettierignore +1 -0
- package/CHANGELOG.md +14 -0
- package/package.json +4 -2
- package/src/browser/attach/snapshot.js +4 -4
- package/src/browser/browser-cookie-cache.js +14 -2
- package/src/browser/profile-directory.js +109 -6
- package/src/browser/real-browser.js +38 -6
- package/src/cli/args.js +47 -0
- package/src/cli/sessions.js +4 -0
- package/src/traces/bundle.js +3 -2
- package/src/traces/observers.js +14 -5
- package/src/traces/recorder.js +3 -0
- package/src/traces/viewer.js +16 -8
- package/tests/e2e/playwright.e2e.test.js +1 -0
- package/tests/e2e/puppeteer.e2e.test.js +1 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.html +2 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.png +1 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.state.json +54 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +2 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.png +1 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.state.json +60 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +2 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.png +1 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.state.json +60 -0
- package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +18 -0
- package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +77 -0
- package/tests/fixtures/traces/conformance/expected/bundle/mutations/0001.ndjson +2 -0
- package/tests/fixtures/traces/conformance/expected/bundle/mutations/0002.ndjson +1 -0
- package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +309 -0
- package/tests/fixtures/traces/conformance/expected/redaction.json +86 -0
- package/tests/fixtures/traces/conformance/expected/trace.lino +27 -0
- package/tests/fixtures/traces/conformance/scenario.json +249 -0
- package/tests/unit/browser/attach/extension-relay.test.js +1 -1
- package/tests/unit/browser/attach/snapshot-launch.test.js +10 -1
- package/tests/unit/browser/attach/snapshot.test.js +23 -4
- package/tests/unit/browser/browser-cookie-lock.test.js +102 -0
- package/tests/unit/browser/launch-building-blocks.test.js +127 -4
- package/tests/unit/browser/migration/index.test.js +1 -1
- package/tests/unit/browser/open-in-user-browser.test.js +1 -1
- package/tests/unit/browser/parity.test.js +1 -1
- package/tests/unit/browser/real-browser.test.js +1 -1
- package/tests/unit/browser/storage-state.test.js +2 -0
- package/tests/unit/cli/main.test.js +30 -2
- package/tests/unit/core/navigation-manager.test.js +11 -1
- package/tests/unit/playwright-protocol-coverage.test.js +231 -0
- package/tests/unit/puppeteer-bindings-coverage.test.js +214 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +23 -0
- package/tests/unit/scripts/feature-parity.test.js +111 -5
- package/tests/unit/traces/conformance.test.js +92 -0
- package/tests/unit/traces/reader.test.js +1 -1
- package/tests/unit/traces/recorder.test.js +1 -1
- 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
|
|
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')), [
|
|
85
|
-
|
|
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 +\| \[
|
|
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
|
+
});
|
|
@@ -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('<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>' }),
|