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.
Files changed (35) hide show
  1. package/.jscpd-baseline.json +8 -0
  2. package/CHANGELOG.md +45 -0
  3. package/README.md +50 -2
  4. package/package.json +8 -3
  5. package/scripts/run-tests.mjs +4 -2
  6. package/src/downloads/manager.js +40 -8
  7. package/src/downloads/sources.js +127 -6
  8. package/src/downloads/staging.js +158 -0
  9. package/src/exports.js +14 -0
  10. package/src/traces/bundle.js +26 -3
  11. package/src/traces/deadline.js +33 -0
  12. package/src/traces/identity.js +127 -0
  13. package/src/traces/init-script.js +95 -0
  14. package/src/traces/links.js +623 -0
  15. package/src/traces/mutation-stream.js +232 -0
  16. package/src/traces/observers.js +29 -3
  17. package/src/traces/page-capture.js +251 -15
  18. package/src/traces/recorder.js +124 -94
  19. package/src/traces/schema.js +65 -1
  20. package/src/traces/viewer.js +157 -11
  21. package/tests/e2e/downloads.e2e.test.js +126 -2
  22. package/tests/e2e/traces.e2e.test.js +296 -11
  23. package/tests/helpers/download-server.js +26 -6
  24. package/tests/helpers/trace-fixtures.js +97 -3
  25. package/tests/helpers/trace-server.js +57 -1
  26. package/tests/unit/downloads/manager.test.js +213 -9
  27. package/tests/unit/downloads/staging.test.js +139 -0
  28. package/tests/unit/scripts/ci-workflow-policy.test.js +8 -0
  29. package/tests/unit/scripts/npm-release-helpers.test.js +3 -2
  30. package/tests/unit/scripts/pre-commit-config.test.js +2 -1
  31. package/tests/unit/traces/bundle.test.js +24 -3
  32. package/tests/unit/traces/links.test.js +564 -0
  33. package/tests/unit/traces/reader.test.js +10 -4
  34. package/tests/unit/traces/recorder.test.js +172 -1
  35. package/tests/unit/traces/viewer.test.js +9 -0
@@ -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 appendQueue;
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
+ }