browser-commander 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.jscpd-baseline.json +8 -0
- package/CHANGELOG.md +45 -0
- package/README.md +50 -2
- package/package.json +8 -3
- package/scripts/run-tests.mjs +4 -2
- package/src/downloads/manager.js +40 -8
- package/src/downloads/sources.js +127 -6
- package/src/downloads/staging.js +158 -0
- package/src/exports.js +14 -0
- package/src/traces/bundle.js +26 -3
- package/src/traces/deadline.js +33 -0
- package/src/traces/identity.js +127 -0
- package/src/traces/init-script.js +95 -0
- package/src/traces/links.js +623 -0
- package/src/traces/mutation-stream.js +232 -0
- package/src/traces/observers.js +29 -3
- package/src/traces/page-capture.js +251 -15
- package/src/traces/recorder.js +124 -94
- package/src/traces/schema.js +65 -1
- package/src/traces/viewer.js +157 -11
- package/tests/e2e/downloads.e2e.test.js +126 -2
- package/tests/e2e/traces.e2e.test.js +296 -11
- package/tests/helpers/download-server.js +26 -6
- package/tests/helpers/trace-fixtures.js +97 -3
- package/tests/helpers/trace-server.js +57 -1
- package/tests/unit/downloads/manager.test.js +213 -9
- package/tests/unit/downloads/staging.test.js +139 -0
- package/tests/unit/scripts/ci-workflow-policy.test.js +8 -0
- package/tests/unit/scripts/npm-release-helpers.test.js +3 -2
- package/tests/unit/scripts/pre-commit-config.test.js +2 -1
- package/tests/unit/traces/bundle.test.js +24 -3
- package/tests/unit/traces/links.test.js +564 -0
- package/tests/unit/traces/reader.test.js +10 -4
- package/tests/unit/traces/recorder.test.js +172 -1
- package/tests/unit/traces/viewer.test.js +9 -0
package/src/traces/bundle.js
CHANGED
|
@@ -32,7 +32,7 @@ export const TRACE_DIRECTORY_MODE = 0o700;
|
|
|
32
32
|
/**
|
|
33
33
|
* Open a bundle for writing.
|
|
34
34
|
*
|
|
35
|
-
* @param {Object} options - `{output, limits, strict, now}`
|
|
35
|
+
* @param {Object} options - `{output, limits, strict, now, onEvent}`
|
|
36
36
|
* @returns {Promise<Object>} The bundle writer
|
|
37
37
|
*/
|
|
38
38
|
export async function openTraceBundle(options = {}) {
|
|
@@ -41,6 +41,7 @@ export async function openTraceBundle(options = {}) {
|
|
|
41
41
|
strict = false,
|
|
42
42
|
limits = {},
|
|
43
43
|
now = () => Date.now(),
|
|
44
|
+
onEvent = null,
|
|
44
45
|
} = options;
|
|
45
46
|
|
|
46
47
|
if (typeof output !== 'string' || output === '') {
|
|
@@ -57,6 +58,7 @@ export async function openTraceBundle(options = {}) {
|
|
|
57
58
|
|
|
58
59
|
const eventsPath = path.join(root, TRACE_FILES.EVENTS);
|
|
59
60
|
const eventsHandle = await fs.open(eventsPath, 'a', TRACE_FILE_MODE);
|
|
61
|
+
let closeEventsPromise;
|
|
60
62
|
|
|
61
63
|
let written = 0;
|
|
62
64
|
let sequence = 0;
|
|
@@ -68,6 +70,12 @@ export async function openTraceBundle(options = {}) {
|
|
|
68
70
|
const counts = { checkpoints: 0, events: 0, mutationBatches: 0 };
|
|
69
71
|
const problems = [];
|
|
70
72
|
|
|
73
|
+
/** Close the timeline exactly once, after every queued append. */
|
|
74
|
+
function closeEvents() {
|
|
75
|
+
closeEventsPromise ??= appendQueue.then(() => eventsHandle.close());
|
|
76
|
+
return closeEventsPromise;
|
|
77
|
+
}
|
|
78
|
+
|
|
71
79
|
const fits = (bytes) =>
|
|
72
80
|
bytes <= maxResourceBytes && written + bytes <= maxBundleBytes;
|
|
73
81
|
|
|
@@ -138,6 +146,20 @@ export async function openTraceBundle(options = {}) {
|
|
|
138
146
|
await pending;
|
|
139
147
|
written += bytes;
|
|
140
148
|
counts.events += 1;
|
|
149
|
+
if (onEvent) {
|
|
150
|
+
try {
|
|
151
|
+
await onEvent(record);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
// A side export that fails is a gap in that export, not in the
|
|
154
|
+
// bundle. Reporting it through `drop` would write an event and call
|
|
155
|
+
// this hook again, so it is noted where a caller can still see it.
|
|
156
|
+
problems.push({
|
|
157
|
+
reason: TRACE_DROP_REASON.WRITE_FAILED,
|
|
158
|
+
member: 'links',
|
|
159
|
+
detail: error.message,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
141
163
|
return record;
|
|
142
164
|
} catch (error) {
|
|
143
165
|
if (retry) {
|
|
@@ -293,6 +315,8 @@ export async function openTraceBundle(options = {}) {
|
|
|
293
315
|
writeMutations,
|
|
294
316
|
writeArtifact,
|
|
295
317
|
drop,
|
|
318
|
+
/** Close an unfinished bundle without manufacturing a manifest. */
|
|
319
|
+
abort: closeEvents,
|
|
296
320
|
/**
|
|
297
321
|
* Write the manifest and close the timeline.
|
|
298
322
|
*
|
|
@@ -310,8 +334,7 @@ export async function openTraceBundle(options = {}) {
|
|
|
310
334
|
: manifest.outcome,
|
|
311
335
|
};
|
|
312
336
|
// Anything a listener reported on its way out is still queued.
|
|
313
|
-
await
|
|
314
|
-
await eventsHandle.close();
|
|
337
|
+
await closeEvents();
|
|
315
338
|
await fs.writeFile(
|
|
316
339
|
path.join(root, TRACE_FILES.MANIFEST),
|
|
317
340
|
`${JSON.stringify(finished, null, 2)}\n`,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A budget for one capture (issue #87).
|
|
3
|
+
*
|
|
4
|
+
* A page that stopped answering must cost the trace one record, not the run.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Run a capture with its own deadline.
|
|
9
|
+
*
|
|
10
|
+
* @param {Promise} work - The capture
|
|
11
|
+
* @param {number} timeoutMs - Budget in milliseconds; falsy means no deadline
|
|
12
|
+
* @param {string} what - Name used in the timeout message
|
|
13
|
+
* @returns {Promise<*>} The capture's result
|
|
14
|
+
*/
|
|
15
|
+
export async function withDeadline(work, timeoutMs, what) {
|
|
16
|
+
if (!timeoutMs) {
|
|
17
|
+
return work;
|
|
18
|
+
}
|
|
19
|
+
let timer;
|
|
20
|
+
try {
|
|
21
|
+
return await Promise.race([
|
|
22
|
+
work,
|
|
23
|
+
new Promise((_resolve, reject) => {
|
|
24
|
+
timer = setTimeout(
|
|
25
|
+
() => reject(new Error(`${what} timed out after ${timeoutMs}ms`)),
|
|
26
|
+
timeoutMs
|
|
27
|
+
);
|
|
28
|
+
}),
|
|
29
|
+
]);
|
|
30
|
+
} finally {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable identities for the things a trace talks about (issue #93).
|
|
3
|
+
*
|
|
4
|
+
* A timeline that says "a mutation happened" without saying *where* is only
|
|
5
|
+
* readable when exactly one document existed. Two tabs, or one page with an
|
|
6
|
+
* iframe, and every record becomes a guess. Issue #93 calls this out: "no
|
|
7
|
+
* browser-context / page / navigation / frame / action identifiers", so every
|
|
8
|
+
* record this recorder writes now names its owner.
|
|
9
|
+
*
|
|
10
|
+
* Identities are assigned here rather than taken from the engine because the
|
|
11
|
+
* two engines name the same things differently, and a trace is supposed to
|
|
12
|
+
* read the same whichever one recorded it. They are stable within a process:
|
|
13
|
+
* tracing the same page twice reports the same `pageId`, which is what makes
|
|
14
|
+
* two bundles of one run comparable.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Identities already handed out, so the same object keeps the same name. */
|
|
18
|
+
const assigned = new WeakMap();
|
|
19
|
+
|
|
20
|
+
const counters = { context: 0, page: 0, trace: 0 };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Name one engine object, stably.
|
|
24
|
+
*
|
|
25
|
+
* @param {Object|null|undefined} subject - The object to name
|
|
26
|
+
* @param {string} kind - Counter to draw from, such as `page`
|
|
27
|
+
* @param {string} prefix - Identifier prefix, such as `page`
|
|
28
|
+
* @returns {string|null} The identifier, or null when there is nothing to name
|
|
29
|
+
*/
|
|
30
|
+
function identify(subject, kind, prefix) {
|
|
31
|
+
if (
|
|
32
|
+
!subject ||
|
|
33
|
+
(typeof subject !== 'object' && typeof subject !== 'function')
|
|
34
|
+
) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const existing = assigned.get(subject);
|
|
38
|
+
if (existing) {
|
|
39
|
+
return existing;
|
|
40
|
+
}
|
|
41
|
+
const id = `${prefix}-${++counters[kind]}`;
|
|
42
|
+
assigned.set(subject, id);
|
|
43
|
+
return id;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The browser context a page belongs to, whichever engine owns it.
|
|
48
|
+
*
|
|
49
|
+
* @param {Object} page - Playwright or Puppeteer page
|
|
50
|
+
* @returns {Object|null} The context object, or null when it cannot be reached
|
|
51
|
+
*/
|
|
52
|
+
export function browserContextOf(page) {
|
|
53
|
+
for (const accessor of ['context', 'browserContext']) {
|
|
54
|
+
if (typeof page?.[accessor] === 'function') {
|
|
55
|
+
try {
|
|
56
|
+
return page[accessor]();
|
|
57
|
+
} catch {
|
|
58
|
+
// An engine that closed the page answers by throwing; an unnamed
|
|
59
|
+
// context is better than a failed trace.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Open an identity for one running trace.
|
|
68
|
+
*
|
|
69
|
+
* @param {Object} options - `{page}`
|
|
70
|
+
* @param {Object} options.page - The page being traced
|
|
71
|
+
* @returns {Object} The trace's identity
|
|
72
|
+
*/
|
|
73
|
+
export function createTraceIdentity({ page } = {}) {
|
|
74
|
+
const traceId = `trace-${++counters.trace}`;
|
|
75
|
+
const browserContextId = identify(
|
|
76
|
+
browserContextOf(page),
|
|
77
|
+
'context',
|
|
78
|
+
'context'
|
|
79
|
+
);
|
|
80
|
+
const pageId = identify(page, 'page', 'page');
|
|
81
|
+
|
|
82
|
+
// A navigation is what separates "the same element" from "an element with
|
|
83
|
+
// the same path in a different document", so it is numbered from the start
|
|
84
|
+
// rather than from the first navigation the recorder happens to see.
|
|
85
|
+
let navigation = 1;
|
|
86
|
+
let actions = 0;
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
traceId,
|
|
90
|
+
browserContextId,
|
|
91
|
+
pageId,
|
|
92
|
+
get navigationId() {
|
|
93
|
+
return `nav-${navigation}`;
|
|
94
|
+
},
|
|
95
|
+
/**
|
|
96
|
+
* Start a new navigation generation.
|
|
97
|
+
*
|
|
98
|
+
* @returns {string} The identifier of the document that is now current
|
|
99
|
+
*/
|
|
100
|
+
navigated() {
|
|
101
|
+
navigation += 1;
|
|
102
|
+
return `nav-${navigation}`;
|
|
103
|
+
},
|
|
104
|
+
/**
|
|
105
|
+
* Name one action, so its start, its result and its DOM effects agree.
|
|
106
|
+
*
|
|
107
|
+
* @returns {string} A fresh action identifier
|
|
108
|
+
*/
|
|
109
|
+
nextActionId() {
|
|
110
|
+
actions += 1;
|
|
111
|
+
return `${traceId}-action-${actions}`;
|
|
112
|
+
},
|
|
113
|
+
/**
|
|
114
|
+
* The owner fields every timeline record carries.
|
|
115
|
+
*
|
|
116
|
+
* @returns {Object} `{traceId, browserContextId, pageId, navigationId}`
|
|
117
|
+
*/
|
|
118
|
+
owner() {
|
|
119
|
+
return {
|
|
120
|
+
traceId,
|
|
121
|
+
browserContextId,
|
|
122
|
+
pageId,
|
|
123
|
+
navigationId: `nav-${navigation}`,
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Getting the in-page recorder in before the page's own code (issue #93).
|
|
3
|
+
*
|
|
4
|
+
* A `MutationObserver` lives on the document, so a navigation destroys it. The
|
|
5
|
+
* recorder used to be reinstalled from `checkpoint()`, which meant everything a
|
|
6
|
+
* new page did while it was loading - the whole first render of a
|
|
7
|
+
* single-page app, every `DOMContentLoaded` handler - happened before anything
|
|
8
|
+
* was watching. Replaying such a trace showed a page that had apparently always
|
|
9
|
+
* looked finished.
|
|
10
|
+
*
|
|
11
|
+
* Both engines can run a script at document creation, ahead of the page's own
|
|
12
|
+
* scripts, in every frame. That hook is the only place an observer can be
|
|
13
|
+
* installed early enough to see a document's first mutation, so this module
|
|
14
|
+
* wraps the two spellings of it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Which engine's init-script hook this page offers.
|
|
19
|
+
*
|
|
20
|
+
* The page is asked rather than the commander, because a caller may trace a raw
|
|
21
|
+
* Playwright or Puppeteer page with no commander at all.
|
|
22
|
+
*
|
|
23
|
+
* @param {Object} page - The page being traced
|
|
24
|
+
* @returns {string|null} 'playwright', 'puppeteer', or null when neither
|
|
25
|
+
*/
|
|
26
|
+
export function initScriptEngine(page) {
|
|
27
|
+
if (typeof page?.addInitScript === 'function') {
|
|
28
|
+
return 'playwright';
|
|
29
|
+
}
|
|
30
|
+
if (typeof page?.evaluateOnNewDocument === 'function') {
|
|
31
|
+
return 'puppeteer';
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Run a function in every document this page creates from now on.
|
|
38
|
+
*
|
|
39
|
+
* Playwright offers no way to unregister a single init script, so the returned
|
|
40
|
+
* detach is best-effort: it removes the registration on Puppeteer, and
|
|
41
|
+
* everywhere else the trace relies on `stop()` switching the recorder off in
|
|
42
|
+
* the documents that exist. A recorder installed into a document created after
|
|
43
|
+
* `stop()` therefore queues into a queue nobody drains, bounded by the same
|
|
44
|
+
* `maxQueuedMutations` limit as everything else.
|
|
45
|
+
*
|
|
46
|
+
* @param {Object} options - `{page, fn, arg, note}`
|
|
47
|
+
* @returns {Promise<Function|null>} Detach, or null when the page has no hook
|
|
48
|
+
*/
|
|
49
|
+
export async function installInitScript(options) {
|
|
50
|
+
const { page, fn, arg, note } = options;
|
|
51
|
+
const engine = initScriptEngine(page);
|
|
52
|
+
|
|
53
|
+
if (engine === 'playwright') {
|
|
54
|
+
await page.addInitScript(fn, arg);
|
|
55
|
+
return () => {};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (engine === 'puppeteer') {
|
|
59
|
+
const handle = await page.evaluateOnNewDocument(fn, arg);
|
|
60
|
+
const identifier = handle?.identifier ?? handle;
|
|
61
|
+
return async () => {
|
|
62
|
+
if (identifier === undefined || identifier === null) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
await page.removeScriptToEvaluateOnNewDocument?.(identifier);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
note?.(`could not remove the init script: ${error.message}`);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Every frame of a page, including the page itself.
|
|
78
|
+
*
|
|
79
|
+
* Frames come and go while a trace is running, so this is read at the moment it
|
|
80
|
+
* is needed rather than kept.
|
|
81
|
+
*
|
|
82
|
+
* @param {Object} page - The page being traced
|
|
83
|
+
* @returns {Object[]} Frames, main frame first when the engine orders them so
|
|
84
|
+
*/
|
|
85
|
+
export function framesOf(page) {
|
|
86
|
+
if (typeof page?.frames !== 'function') {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
return page.frames() ?? [];
|
|
91
|
+
} catch {
|
|
92
|
+
// A page that closed mid-drain has no frames, which is not an error here.
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
}
|