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.
- package/.jscpd-baseline.json +12 -17
- package/CHANGELOG.md +68 -0
- package/README.md +132 -0
- package/eslint.config.js +5 -0
- package/package.json +5 -1
- package/src/bindings.js +13 -1
- package/src/browser/connector.js +15 -3
- package/src/browser/launcher.js +14 -2
- package/src/browser/navigation.js +63 -0
- package/src/core/dialog-manager.js +48 -0
- package/src/core/navigation-manager.js +83 -87
- package/src/core/navigation-readiness.js +133 -0
- package/src/core/readiness.js +478 -0
- package/src/downloads/attach.js +58 -0
- package/src/downloads/destination.js +125 -0
- package/src/downloads/manager.js +476 -0
- package/src/downloads/naming.js +246 -0
- package/src/downloads/sources.js +238 -0
- package/src/downloads/staging.js +146 -0
- package/src/downloads/store.js +256 -0
- package/src/exports.js +86 -0
- package/src/factory.js +55 -0
- package/src/interactions/click-activation.js +317 -0
- package/src/interactions/click-result.js +108 -0
- package/src/interactions/click.js +766 -352
- package/src/tests/index.js +75 -11
- package/src/tests/tracing.js +236 -0
- package/src/traces/bundle.js +338 -0
- 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 +272 -0
- package/src/traces/page-capture.js +601 -0
- package/src/traces/reader.js +226 -0
- package/src/traces/recorder.js +454 -0
- package/src/traces/redaction.js +231 -0
- package/src/traces/schema.js +229 -0
- package/src/traces/viewer.js +448 -0
- package/tests/e2e/click-readiness.e2e.test.js +240 -0
- package/tests/e2e/downloads.e2e.test.js +235 -0
- package/tests/e2e/traces.e2e.test.js +742 -0
- package/tests/helpers/click-fixtures.js +62 -0
- package/tests/helpers/download-fixtures.js +125 -0
- package/tests/helpers/download-server.js +129 -0
- package/tests/helpers/e2e-browser.js +67 -0
- package/tests/helpers/file-modes.js +31 -0
- package/tests/helpers/fixture-server.js +56 -0
- package/tests/helpers/mocks.js +63 -66
- package/tests/helpers/readiness-server.js +96 -0
- package/tests/helpers/temp-directory.js +44 -0
- package/tests/helpers/trace-fixtures.js +274 -0
- package/tests/helpers/trace-server.js +173 -0
- package/tests/unit/core/dialog-manager.test.js +118 -10
- package/tests/unit/core/navigation-manager.test.js +162 -0
- package/tests/unit/core/readiness.test.js +517 -0
- package/tests/unit/downloads/attach.test.js +220 -0
- package/tests/unit/downloads/destination.test.js +114 -0
- package/tests/unit/downloads/manager.test.js +485 -0
- package/tests/unit/downloads/naming.test.js +174 -0
- package/tests/unit/downloads/staging.test.js +117 -0
- package/tests/unit/downloads/store.test.js +215 -0
- package/tests/unit/interactions/click-activation.test.js +283 -0
- package/tests/unit/interactions/click-result.test.js +90 -0
- package/tests/unit/interactions/click.test.js +409 -200
- package/tests/unit/tests/tracing.test.js +299 -0
- package/tests/unit/traces/bundle.test.js +260 -0
- package/tests/unit/traces/links.test.js +557 -0
- package/tests/unit/traces/reader.test.js +208 -0
- package/tests/unit/traces/recorder.test.js +730 -0
- package/tests/unit/traces/redaction.test.js +170 -0
- package/tests/unit/traces/schema.test.js +92 -0
- 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
|
+
}
|