browser-commander 0.17.2 → 0.19.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 (74) hide show
  1. package/.jscpd-baseline.json +12 -17
  2. package/CHANGELOG.md +68 -0
  3. package/README.md +132 -0
  4. package/eslint.config.js +5 -0
  5. package/package.json +5 -1
  6. package/src/bindings.js +13 -1
  7. package/src/browser/connector.js +15 -3
  8. package/src/browser/launcher.js +14 -2
  9. package/src/browser/navigation.js +63 -0
  10. package/src/core/dialog-manager.js +48 -0
  11. package/src/core/navigation-manager.js +83 -87
  12. package/src/core/navigation-readiness.js +133 -0
  13. package/src/core/readiness.js +478 -0
  14. package/src/downloads/attach.js +58 -0
  15. package/src/downloads/destination.js +125 -0
  16. package/src/downloads/manager.js +476 -0
  17. package/src/downloads/naming.js +246 -0
  18. package/src/downloads/sources.js +238 -0
  19. package/src/downloads/staging.js +146 -0
  20. package/src/downloads/store.js +256 -0
  21. package/src/exports.js +86 -0
  22. package/src/factory.js +55 -0
  23. package/src/interactions/click-activation.js +317 -0
  24. package/src/interactions/click-result.js +108 -0
  25. package/src/interactions/click.js +766 -352
  26. package/src/tests/index.js +75 -11
  27. package/src/tests/tracing.js +236 -0
  28. package/src/traces/bundle.js +338 -0
  29. package/src/traces/deadline.js +33 -0
  30. package/src/traces/identity.js +127 -0
  31. package/src/traces/init-script.js +95 -0
  32. package/src/traces/links.js +623 -0
  33. package/src/traces/mutation-stream.js +232 -0
  34. package/src/traces/observers.js +272 -0
  35. package/src/traces/page-capture.js +601 -0
  36. package/src/traces/reader.js +226 -0
  37. package/src/traces/recorder.js +454 -0
  38. package/src/traces/redaction.js +231 -0
  39. package/src/traces/schema.js +229 -0
  40. package/src/traces/viewer.js +448 -0
  41. package/tests/e2e/click-readiness.e2e.test.js +240 -0
  42. package/tests/e2e/downloads.e2e.test.js +235 -0
  43. package/tests/e2e/traces.e2e.test.js +742 -0
  44. package/tests/helpers/click-fixtures.js +62 -0
  45. package/tests/helpers/download-fixtures.js +125 -0
  46. package/tests/helpers/download-server.js +129 -0
  47. package/tests/helpers/e2e-browser.js +67 -0
  48. package/tests/helpers/file-modes.js +31 -0
  49. package/tests/helpers/fixture-server.js +56 -0
  50. package/tests/helpers/mocks.js +63 -66
  51. package/tests/helpers/readiness-server.js +96 -0
  52. package/tests/helpers/temp-directory.js +44 -0
  53. package/tests/helpers/trace-fixtures.js +274 -0
  54. package/tests/helpers/trace-server.js +173 -0
  55. package/tests/unit/core/dialog-manager.test.js +118 -10
  56. package/tests/unit/core/navigation-manager.test.js +162 -0
  57. package/tests/unit/core/readiness.test.js +517 -0
  58. package/tests/unit/downloads/attach.test.js +220 -0
  59. package/tests/unit/downloads/destination.test.js +114 -0
  60. package/tests/unit/downloads/manager.test.js +485 -0
  61. package/tests/unit/downloads/naming.test.js +174 -0
  62. package/tests/unit/downloads/staging.test.js +117 -0
  63. package/tests/unit/downloads/store.test.js +215 -0
  64. package/tests/unit/interactions/click-activation.test.js +283 -0
  65. package/tests/unit/interactions/click-result.test.js +90 -0
  66. package/tests/unit/interactions/click.test.js +409 -200
  67. package/tests/unit/tests/tracing.test.js +299 -0
  68. package/tests/unit/traces/bundle.test.js +260 -0
  69. package/tests/unit/traces/links.test.js +557 -0
  70. package/tests/unit/traces/reader.test.js +208 -0
  71. package/tests/unit/traces/recorder.test.js +730 -0
  72. package/tests/unit/traces/redaction.test.js +170 -0
  73. package/tests/unit/traces/schema.test.js +92 -0
  74. package/tests/unit/traces/viewer.test.js +294 -0
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Writing a trace bundle to disk (issue #87).
3
+ *
4
+ * The bundle is a directory with the documented layout, which `zip -r` turns
5
+ * into the archive form without re-encoding anything, and which `ls` and
6
+ * `cat` can already read.
7
+ *
8
+ * Every write here is best-effort by default: a trace exists to explain a run,
9
+ * so failing to record something must leave a visible gap rather than break
10
+ * the automation that was being recorded.
11
+ */
12
+
13
+ import { createHash } from 'node:crypto';
14
+ import fs from 'node:fs/promises';
15
+ import path from 'node:path';
16
+ import {
17
+ DEFAULT_MAX_BUNDLE_BYTES,
18
+ DEFAULT_MAX_RESOURCE_BYTES,
19
+ TRACE_DROP_REASON,
20
+ TRACE_EVENT,
21
+ TRACE_FILES,
22
+ TRACE_OUTCOME,
23
+ sequenceName,
24
+ } from './schema.js';
25
+
26
+ /** Owner-only file mode: a trace may hold form values and page content. */
27
+ export const TRACE_FILE_MODE = 0o600;
28
+
29
+ /** Owner-only directory mode for the bundle root. */
30
+ export const TRACE_DIRECTORY_MODE = 0o700;
31
+
32
+ /**
33
+ * Open a bundle for writing.
34
+ *
35
+ * @param {Object} options - `{output, limits, strict, now, onEvent}`
36
+ * @returns {Promise<Object>} The bundle writer
37
+ */
38
+ export async function openTraceBundle(options = {}) {
39
+ const {
40
+ output,
41
+ strict = false,
42
+ limits = {},
43
+ now = () => Date.now(),
44
+ onEvent = null,
45
+ } = options;
46
+
47
+ if (typeof output !== 'string' || output === '') {
48
+ throw new Error('trace output must be a path');
49
+ }
50
+
51
+ const root = path.resolve(output);
52
+ const maxBundleBytes = limits.maxBundleBytes ?? DEFAULT_MAX_BUNDLE_BYTES;
53
+ const maxResourceBytes =
54
+ limits.maxResourceBytes ?? DEFAULT_MAX_RESOURCE_BYTES;
55
+ const maxEventBytes = limits.maxEventBytes ?? maxResourceBytes;
56
+
57
+ await fs.mkdir(root, { recursive: true, mode: TRACE_DIRECTORY_MODE });
58
+
59
+ const eventsPath = path.join(root, TRACE_FILES.EVENTS);
60
+ const eventsHandle = await fs.open(eventsPath, 'a', TRACE_FILE_MODE);
61
+
62
+ let written = 0;
63
+ let sequence = 0;
64
+ // Appends are serialized. Listeners report what happened without waiting
65
+ // for the previous record to reach the disk, and a timeline whose lines
66
+ // arrive in a different order than the events did is not a timeline.
67
+ let appendQueue = Promise.resolve();
68
+ let dropped = 0;
69
+ const counts = { checkpoints: 0, events: 0, mutationBatches: 0 };
70
+ const problems = [];
71
+
72
+ const fits = (bytes) =>
73
+ bytes <= maxResourceBytes && written + bytes <= maxBundleBytes;
74
+
75
+ // A record of something that was dropped is the one write that must not be
76
+ // dropped in turn, so it answers to the bundle budget alone.
77
+ const fitsEvent = (bytes, retry) =>
78
+ written + bytes <= maxBundleBytes && (!retry || bytes <= maxEventBytes);
79
+
80
+ /**
81
+ * Record that something could not be written.
82
+ *
83
+ * @param {Object} record - `{reason, detail, member}`
84
+ * @returns {Promise<void>}
85
+ */
86
+ async function drop(record) {
87
+ dropped += 1;
88
+ problems.push(record);
89
+ if (strict) {
90
+ throw new Error(
91
+ `trace ${record.member ?? 'record'} dropped: ${record.reason}${
92
+ record.detail ? ` (${record.detail})` : ''
93
+ }`
94
+ );
95
+ }
96
+ // The drop is itself an event, so a reader sees the gap in the timeline
97
+ // instead of silently reading a shorter story than the one that happened.
98
+ await appendEvent(
99
+ { kind: TRACE_EVENT.DROPPED, ...record },
100
+ { retry: false }
101
+ );
102
+ }
103
+
104
+ /**
105
+ * Append one event to the timeline.
106
+ *
107
+ * @param {Object} event - Event fields; `sequence` and `at` are added
108
+ * @param {Object} [options] - `{retry}`
109
+ * @returns {Promise<Object|null>} The event as written
110
+ */
111
+ async function appendEvent(event, { retry = true } = {}) {
112
+ const record = {
113
+ sequence: ++sequence,
114
+ at: new Date(now()).toISOString(),
115
+ monotonicMs: Math.round(performance.now()),
116
+ ...event,
117
+ };
118
+ const line = `${JSON.stringify(record)}\n`;
119
+ const bytes = Buffer.byteLength(line);
120
+
121
+ if (!fitsEvent(bytes, retry)) {
122
+ if (retry) {
123
+ await drop({
124
+ reason: TRACE_DROP_REASON.SIZE_LIMIT,
125
+ member: TRACE_FILES.EVENTS,
126
+ detail: `${bytes} bytes`,
127
+ });
128
+ }
129
+ return null;
130
+ }
131
+
132
+ const pending = appendQueue.then(() => eventsHandle.write(line));
133
+ appendQueue = pending.then(
134
+ () => {},
135
+ () => {}
136
+ );
137
+
138
+ try {
139
+ await pending;
140
+ written += bytes;
141
+ counts.events += 1;
142
+ if (onEvent) {
143
+ try {
144
+ await onEvent(record);
145
+ } catch (error) {
146
+ // A side export that fails is a gap in that export, not in the
147
+ // bundle. Reporting it through `drop` would write an event and call
148
+ // this hook again, so it is noted where a caller can still see it.
149
+ problems.push({
150
+ reason: TRACE_DROP_REASON.WRITE_FAILED,
151
+ member: 'links',
152
+ detail: error.message,
153
+ });
154
+ }
155
+ }
156
+ return record;
157
+ } catch (error) {
158
+ if (retry) {
159
+ await drop({
160
+ reason: TRACE_DROP_REASON.WRITE_FAILED,
161
+ member: TRACE_FILES.EVENTS,
162
+ detail: error.message,
163
+ });
164
+ }
165
+ return null;
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Write one member of the bundle.
171
+ *
172
+ * @param {string} member - Path relative to the bundle root
173
+ * @param {string|Buffer} contents - What to write
174
+ * @returns {Promise<Object|null>} `{member, bytes}` or null when dropped
175
+ */
176
+ async function writeMember(member, contents) {
177
+ const data = Buffer.isBuffer(contents) ? contents : Buffer.from(contents);
178
+
179
+ if (!fits(data.length)) {
180
+ await drop({
181
+ reason: TRACE_DROP_REASON.SIZE_LIMIT,
182
+ member,
183
+ detail: `${data.length} bytes`,
184
+ });
185
+ return null;
186
+ }
187
+
188
+ const target = path.join(root, member);
189
+ try {
190
+ await fs.mkdir(path.dirname(target), {
191
+ recursive: true,
192
+ mode: TRACE_DIRECTORY_MODE,
193
+ });
194
+ await fs.writeFile(target, data, { mode: TRACE_FILE_MODE });
195
+ written += data.length;
196
+ return { member, bytes: data.length };
197
+ } catch (error) {
198
+ await drop({
199
+ reason: TRACE_DROP_REASON.WRITE_FAILED,
200
+ member,
201
+ detail: error.message,
202
+ });
203
+ return null;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Write a checkpoint's HTML, state and screenshot.
209
+ *
210
+ * @param {Object} checkpoint - `{index, html, state, screenshot}`
211
+ * @returns {Promise<Object>} What was written, by member name
212
+ */
213
+ async function writeCheckpoint({ index, html, state, screenshot }) {
214
+ const name = sequenceName(index);
215
+ const members = {};
216
+
217
+ if (typeof html === 'string') {
218
+ const result = await writeMember(
219
+ `${TRACE_FILES.CHECKPOINTS_DIR}/${name}.html`,
220
+ html
221
+ );
222
+ if (result) {
223
+ members.html = result.member;
224
+ }
225
+ }
226
+ if (state) {
227
+ const result = await writeMember(
228
+ `${TRACE_FILES.CHECKPOINTS_DIR}/${name}.state.json`,
229
+ `${JSON.stringify(state, null, 2)}\n`
230
+ );
231
+ if (result) {
232
+ members.state = result.member;
233
+ }
234
+ }
235
+ if (screenshot) {
236
+ const result = await writeMember(
237
+ `${TRACE_FILES.CHECKPOINTS_DIR}/${name}.png`,
238
+ screenshot
239
+ );
240
+ if (result) {
241
+ members.screenshot = result.member;
242
+ }
243
+ }
244
+
245
+ counts.checkpoints += 1;
246
+ return members;
247
+ }
248
+
249
+ /**
250
+ * Write one checkpoint's mutation batches.
251
+ *
252
+ * @param {number} index - Checkpoint the batches follow
253
+ * @param {Object[]} batches - Ordered mutation batches
254
+ * @returns {Promise<string|null>} Member name, or null when nothing was written
255
+ */
256
+ async function writeMutations(index, batches) {
257
+ if (!batches || batches.length === 0) {
258
+ return null;
259
+ }
260
+ const member = `${TRACE_FILES.MUTATIONS_DIR}/${sequenceName(index)}.ndjson`;
261
+ const body = `${batches.map((batch) => JSON.stringify(batch)).join('\n')}\n`;
262
+ const result = await writeMember(member, body);
263
+ if (result) {
264
+ counts.mutationBatches += batches.length;
265
+ }
266
+ return result ? result.member : null;
267
+ }
268
+
269
+ /**
270
+ * Store a resource by the hash of its contents.
271
+ *
272
+ * Content addressing is what keeps one screenshot taken twice, or a file
273
+ * downloaded in three scenarios, from being stored three times.
274
+ *
275
+ * @param {Buffer|string} contents - Resource bytes
276
+ * @param {string} [extension] - Extension to keep, such as `.png`
277
+ * @returns {Promise<Object|null>} `{member, sha256, bytes}`
278
+ */
279
+ async function writeArtifact(contents, extension = '') {
280
+ const data = Buffer.isBuffer(contents) ? contents : Buffer.from(contents);
281
+ const sha256 = createHash('sha256').update(data).digest('hex');
282
+ const member = `${TRACE_FILES.ARTIFACTS_DIR}/${sha256}${extension}`;
283
+
284
+ try {
285
+ await fs.access(path.join(root, member));
286
+ return { member, sha256, bytes: data.length, deduplicated: true };
287
+ } catch {
288
+ // Not stored yet.
289
+ }
290
+
291
+ const result = await writeMember(member, data);
292
+ return result ? { ...result, sha256, deduplicated: false } : null;
293
+ }
294
+
295
+ return {
296
+ root,
297
+ counts,
298
+ problems,
299
+ get dropped() {
300
+ return dropped;
301
+ },
302
+ get bytesWritten() {
303
+ return written;
304
+ },
305
+ appendEvent,
306
+ writeMember,
307
+ writeCheckpoint,
308
+ writeMutations,
309
+ writeArtifact,
310
+ drop,
311
+ /**
312
+ * Write the manifest and close the timeline.
313
+ *
314
+ * @param {Object} manifest - Manifest built by `createManifest`
315
+ * @returns {Promise<Object>} The manifest as written
316
+ */
317
+ close: async (manifest) => {
318
+ const finished = {
319
+ ...manifest,
320
+ counts: { ...counts },
321
+ dropped,
322
+ outcome:
323
+ dropped > 0 && manifest.outcome === TRACE_OUTCOME.COMPLETE
324
+ ? TRACE_OUTCOME.PARTIAL
325
+ : manifest.outcome,
326
+ };
327
+ // Anything a listener reported on its way out is still queued.
328
+ await appendQueue;
329
+ await eventsHandle.close();
330
+ await fs.writeFile(
331
+ path.join(root, TRACE_FILES.MANIFEST),
332
+ `${JSON.stringify(finished, null, 2)}\n`,
333
+ { mode: TRACE_FILE_MODE }
334
+ );
335
+ return finished;
336
+ },
337
+ };
338
+ }
@@ -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
+ }