pw-repl 0.12.4 → 0.12.6

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/lib/trace.js ADDED
@@ -0,0 +1,530 @@
1
+ // Reading a Chrome performance trace for one tab: what its main thread was busy with, its long
2
+ // tasks, forced layouts, frames and the input it handled. The trace is the whole browser's; the tab's
3
+ // events are told apart by its renderer process, found from events that name its main frame.
4
+ const fs = require('fs');
5
+ const readline = require('readline');
6
+
7
+ const LONG_TASK = 50000;
8
+ const BIG_REQUEST = 100 * 1024;
9
+ // Input events this close together are one step: a wheel-zoom or a scroll is many events.
10
+ const STEP_GAP = 300000;
11
+ // A step's frames and busy time include this long after its last input, for what it drew.
12
+ const STEP_TAIL = 100000;
13
+ // How long before its scroll events a scroll's frame can be drawn: the compositor scrolls and draws, and
14
+ // the page gets the scroll event in its next frame, later still when frames are being dropped.
15
+ const SCROLL_LEAD = 50000;
16
+
17
+ const BUCKETS = ['scripting', 'style/layout', 'paint', 'GC', 'other'];
18
+
19
+ // DevTools' categories, by event name; anything else counts as the task's own time, other.
20
+ const SCRIPTING = new Set(['FunctionCall', 'EvaluateScript', 'v8.evaluateModule', 'v8.compile', 'v8.compileModule',
21
+ 'V8.CompileCode', 'v8.produceCache', 'v8.run', 'TimerFire', 'EventDispatch', 'FireAnimationFrame', 'FireIdleCallback',
22
+ 'RunMicrotasks', 'XHRReadyStateChange', 'XHRLoad', 'v8.callFunction', 'v8.parseOnBackground', 'CppGC.IncrementalSweep']);
23
+ const LAYOUT = new Set(['UpdateLayoutTree', 'RecalculateStyles', 'Layout', 'HitTest', 'IntersectionObserverController::computeIntersections']);
24
+ const PAINT = new Set(['Paint', 'PaintImage', 'PrePaint', 'Layerize', 'UpdateLayer', 'UpdateLayerTree', 'CompositeLayers',
25
+ 'Commit', 'Decode Image', 'Draw LazyPixelRef', 'Decode LazyPixelRef', 'ImageDecodeTask']);
26
+ const GC = new Set(['MinorGC', 'MajorGC', 'BlinkGC.AtomicPhase', 'V8.GCScavenger', 'V8.GCFinalizeMC', 'V8.GCIncrementalMarking',
27
+ 'V8.GC_MC_BACKGROUND_EVACUATE_COPY', 'BlinkGC.ConcurrentMarking', 'CppGC.AtomicMark']);
28
+
29
+ function bucketOf(name) {
30
+ if (SCRIPTING.has(name)) return 'scripting';
31
+ if (LAYOUT.has(name)) return 'style/layout';
32
+ if (PAINT.has(name)) return 'paint';
33
+ if (GC.has(name) || /^(?:V8|CppGC|BlinkGC)\.GC|GC$/.test(name)) return 'GC';
34
+ return null;
35
+ }
36
+
37
+ // What set a task off, from the first event in it that says.
38
+ const TRIGGERS = { TimerFire: 'timer', FireAnimationFrame: 'animation frame', FireIdleCallback: 'idle callback',
39
+ ParseHTML: 'parsing HTML', XHRLoad: 'XHR load', XHRReadyStateChange: 'XHR', RunMicrotasks: 'promise callbacks' };
40
+
41
+ const RUN_TASK = new Set(['RunTask', 'ThreadControllerImpl::RunTask']);
42
+
43
+ // The main thread's events the summary times by name alone, read without parsing them: most of a busy
44
+ // trace (a style recalculation and a paint per element changed).
45
+ function timedOnly(name) {
46
+ return !DETAILED.has(name) && (RUN_TASK.has(name) || bucketOf(name) || TRIGGERS[name] || name === 'ParseHTML');
47
+ }
48
+ const DETAILED = new Set(['FunctionCall', 'EvaluateScript', 'EventDispatch', 'Layout', 'UpdateLayoutTree']);
49
+ // The rest that collect reads.
50
+ const PARSED = new Set([...DETAILED, 'EventTiming', 'PipelineReporter', 'ResourceSendRequest', 'ResourceFinish', 'MarkDOMContent',
51
+ 'MarkLoad', 'navigationStart', 'firstContentfulPaint', 'largestContentfulPaint::Candidate', 'Profile', 'ProfileChunk']);
52
+
53
+ // The trace's events, one at a time: Chrome writes each on a line of its own. A trace runs to hundreds
54
+ // of megabytes, mostly other processes' events: wanted(line) passes over a line before it is parsed.
55
+ async function eachEvent(file, fn, wanted = null) {
56
+ await eachLine(file, line => {
57
+ if (wanted && !wanted(line)) return;
58
+ let event;
59
+ try { event = JSON.parse(line); } catch { return; }
60
+ fn(event, line);
61
+ });
62
+ }
63
+
64
+ async function eachLine(file, fn) {
65
+ const lines = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
66
+ for await (let line of lines) {
67
+ if (!line.startsWith('{"') || line.startsWith('{"traceEvents"')) continue;
68
+ if (line.endsWith(',')) line = line.slice(0, -1);
69
+ // The last event shares its line with the end of the list, and the start of the trace's metadata.
70
+ else line = line.replace(/\](?:,"metadata":.*|\})$/, '');
71
+ fn(line);
72
+ }
73
+ }
74
+
75
+ function nameOf(line) {
76
+ const at = line.lastIndexOf('"name":"');
77
+ return at < 0 ? '' : line.slice(at + 8, line.indexOf('"', at + 8));
78
+ }
79
+
80
+ // A number field of an event, read from its line without parsing it. Chrome writes the keys in order,
81
+ // args first, so the last one by that name is the event's own.
82
+ function field(line, name) {
83
+ const at = line.lastIndexOf(`"${name}":`);
84
+ return at < 0 ? NaN : parseFloat(line.slice(at + name.length + 3));
85
+ }
86
+
87
+ function frameOf(e) {
88
+ return e.args?.data?.frame || e.args?.frame || null;
89
+ }
90
+
91
+ function shortUrl(url) {
92
+ if (!url) return '';
93
+ try {
94
+ const u = new URL(url);
95
+ if (u.protocol === 'data:') return 'data:…';
96
+ const file = u.pathname.split('/').filter(Boolean).pop();
97
+ return file || u.host;
98
+ } catch {
99
+ return url.slice(0, 60);
100
+ }
101
+ }
102
+
103
+ // url:line function, as DevTools names a function. Lines are 1-based in the trace's events and stacks,
104
+ // 0-based in its JS samples' call frames.
105
+ function where(frame, zeroBased = false) {
106
+ if (!frame) return '';
107
+ const line = frame.lineNumber != null && frame.lineNumber >= 0 ? `:${frame.lineNumber + (zeroBased ? 1 : 0)}` : '';
108
+ const fn = frame.functionName || '(anonymous)';
109
+ return `${shortUrl(frame.url)}${line} ${fn}`.trim();
110
+ }
111
+
112
+ // Reads the trace twice: once to find the tab's renderer processes and their threads, then to sum
113
+ // up the tab's events, writing them (with what DevTools needs to open them) to saveFile.
114
+ async function summarize(file, { frameId, url, saveFile, cpu = false }) {
115
+ const pids = new Set();
116
+ const threads = new Map();
117
+ let started = null;
118
+ let firstTs = Infinity;
119
+ let lastTs = 0;
120
+ const firstSeen = new Map();
121
+ await eachEvent(file, e => {
122
+ if (e.name === 'thread_name' && e.ph === 'M') threads.set(`${e.pid}:${e.tid}`, e.args?.name);
123
+ if (e.name === 'TracingStartedInBrowser') started = e;
124
+ if (frameOf(e) === frameId && e.pid) pids.add(e.pid);
125
+ }, line => {
126
+ const ts = field(line, 'ts');
127
+ if (ts && !line.includes('"ph":"M"')) {
128
+ const pid = field(line, 'pid');
129
+ if (!firstSeen.has(pid)) firstSeen.set(pid, ts);
130
+ firstTs = Math.min(firstTs, ts);
131
+ lastTs = Math.max(lastTs, ts + (field(line, 'dur') || 0));
132
+ }
133
+ return line.includes('"thread_name"') || line.includes('TracingStartedInBrowser') || line.includes(frameId);
134
+ });
135
+ const mainOf = new Map();
136
+ const compositorOf = new Map();
137
+ for (const [key, name] of threads) {
138
+ const [pid, tid] = key.split(':').map(Number);
139
+ if (!pids.has(pid)) continue;
140
+ if (name === 'CrRendererMain') mainOf.set(pid, tid);
141
+ if (name === 'Compositor') compositorOf.set(pid, tid);
142
+ }
143
+ // The browser process names the tab's frame too (its navigations): only renderers are the tab's.
144
+ for (const pid of pids) if (!mainOf.has(pid)) pids.delete(pid);
145
+ const t0 = started?.ts ?? firstTs;
146
+ const tracks = { main: new Events(), timings: new Map(), frames: [], requests: new Map(), marks: {}, profiles: new Map() };
147
+ // Written as it is read, in batches: a write stream would hold what the disk has not taken yet.
148
+ const save = saveFile ? { fd: fs.openSync(saveFile, 'w', 0o600), started: false, chunk: [], size: 0 } : null;
149
+ const flush = () => { fs.writeSync(save.fd, save.chunk.join('')); save.chunk = []; save.size = 0; };
150
+ const write = line => {
151
+ save.chunk.push(`${save.started ? ',\n' : '{"traceEvents":[\n'}${line}`);
152
+ save.started = true;
153
+ save.size += line.length;
154
+ if (save.size > 1 << 20) flush();
155
+ };
156
+ // DevTools finds the tab's renderer from these, which a trace of the whole browser leaves out.
157
+ if (save && started) {
158
+ const [first, ...later] = [...pids].map(pid => [pid, firstSeen.get(pid)]).sort((a, b) => a[1] - b[1]);
159
+ if (first) {
160
+ write(JSON.stringify({ ...started, args: { data: { frameTreeNodeId: 0, persistentIds: true, frames: [{ frame: frameId, url, name: '', processId: first[0] }] } } }));
161
+ for (const [pid, ts] of later) {
162
+ write(JSON.stringify({ ...started, name: 'FrameCommittedInBrowser', ts, args: { data: { frame: frameId, url, name: '', processId: pid } } }));
163
+ }
164
+ }
165
+ }
166
+ try {
167
+ await eachLine(file, line => {
168
+ const pid = field(line, 'pid');
169
+ if (!pids.has(pid)) return;
170
+ if (save) write(line);
171
+ const name = nameOf(line);
172
+ const main = mainOf.get(pid) === field(line, 'tid');
173
+ if (main && timedOnly(name)) {
174
+ if (line.includes('"ph":"X"')) tracks.main.push(name, field(line, 'ts'), field(line, 'dur') || 0, null);
175
+ return;
176
+ }
177
+ if (!PARSED.has(name)) return;
178
+ let e;
179
+ try { e = JSON.parse(line); } catch { return; }
180
+ collect(e, tracks, { frameId, main, compositor: compositorOf.get(pid) === e.tid });
181
+ });
182
+ if (save) {
183
+ if (!save.started) save.chunk.push('{"traceEvents":[');
184
+ save.chunk.push('\n]}\n');
185
+ flush();
186
+ }
187
+ } finally {
188
+ if (save) fs.closeSync(save.fd);
189
+ }
190
+ if (!pids.size) return null;
191
+ return analyse(tracks, t0, Math.max(lastTs, t0), { cpu });
192
+ }
193
+
194
+ // The main thread's events, kept compact: a busy page makes hundreds of thousands in a minute (a style
195
+ // recalculation and a paint per element it changes). Their times and names in typed arrays; the few
196
+ // details the summary names (a script, an event's type, a forced layout's stack) beside them.
197
+ class Events {
198
+ constructor() {
199
+ this.n = 0;
200
+ this.ts = new Float64Array(1024);
201
+ this.dur = new Float64Array(1024);
202
+ this.code = new Uint8Array(1024);
203
+ this.names = [];
204
+ this.codes = new Map();
205
+ this.details = new Map();
206
+ }
207
+
208
+ push(name, ts, dur, detail) {
209
+ if (this.n === this.ts.length) {
210
+ for (const key of ['ts', 'dur', 'code']) {
211
+ const grown = new this[key].constructor(this.n * 2);
212
+ grown.set(this[key]);
213
+ this[key] = grown;
214
+ }
215
+ }
216
+ if (!this.codes.has(name)) { this.codes.set(name, this.names.length); this.names.push(name); }
217
+ this.ts[this.n] = ts;
218
+ this.dur[this.n] = dur;
219
+ this.code[this.n] = this.codes.get(name);
220
+ if (detail) this.details.set(this.n, detail);
221
+ this.n += 1;
222
+ }
223
+
224
+ name(i) {
225
+ return this.names[this.code[i]];
226
+ }
227
+ }
228
+
229
+ function collect(e, tracks, { frameId, main, compositor }) {
230
+ const data = e.args?.data;
231
+ if (main && e.ph === 'X' && (RUN_TASK.has(e.name) || bucketOf(e.name) || TRIGGERS[e.name] || e.name === 'ParseHTML')) {
232
+ let detail = null;
233
+ if (e.name === 'FunctionCall' || e.name === 'EvaluateScript') detail = { frame: { url: data?.url, lineNumber: data?.lineNumber, functionName: data?.functionName } };
234
+ if (e.name === 'EventDispatch') detail = { type: data?.type };
235
+ // A style recalculation or layout with the page's script on the stack: one the script waited for (pw-repl's
236
+ // own scripts have no URL).
237
+ if ((e.name === 'Layout' || e.name === 'UpdateLayoutTree') && e.args?.beginData?.stackTrace?.[0]?.url) detail = { stack: e.args.beginData.stackTrace[0] };
238
+ tracks.main.push(e.name, e.ts, e.dur || 0, detail);
239
+ }
240
+ if (e.name === 'EventTiming' && e.ph === 'b' && data?.interactionId && frameOf(e) === frameId) {
241
+ const list = tracks.timings.get(data.interactionId) || [];
242
+ list.push({ ts: e.ts, ...data });
243
+ tracks.timings.set(data.interactionId, list);
244
+ }
245
+ if (compositor && e.name === 'PipelineReporter' && e.ph === 'b') {
246
+ const state = e.args?.frame_reporter?.state;
247
+ if (state && state !== 'STATE_NO_UPDATE_DESIRED') tracks.frames.push({ ts: e.ts, dropped: state === 'STATE_DROPPED' });
248
+ }
249
+ if (e.name === 'ResourceSendRequest' && data?.requestId) tracks.requests.set(data.requestId, { url: data.url, method: data.requestMethod, sent: e.ts });
250
+ if (e.name === 'ResourceFinish' && data?.requestId) {
251
+ const request = tracks.requests.get(data.requestId);
252
+ // One from the cache has no encoded length.
253
+ if (request) Object.assign(request, { finished: e.ts, size: Math.max(data.encodedDataLength || 0, data.decodedBodyLength || 0), failed: data.didFail });
254
+ }
255
+ const isMain = data?.isOutermostMainFrame ?? data?.isMainFrame;
256
+ if (e.name === 'MarkDOMContent' && isMain && frameOf(e) === frameId) tracks.marks.DOMContentLoaded ??= e.ts;
257
+ if (e.name === 'MarkLoad' && isMain && frameOf(e) === frameId) tracks.marks.load ??= e.ts;
258
+ if (e.name === 'navigationStart' && frameOf(e) === frameId) tracks.marks.navigation ??= e.ts;
259
+ if (e.name === 'firstContentfulPaint' && frameOf(e) === frameId) tracks.marks['first contentful paint'] ??= e.ts;
260
+ if (e.name === 'largestContentfulPaint::Candidate' && frameOf(e) === frameId) tracks.marks['largest contentful paint'] = e.ts;
261
+ // The JS sampling profile of a thread: its call tree and samples, in chunks after a Profile event
262
+ // on the thread it samples.
263
+ if ((e.name === 'Profile' || e.name === 'ProfileChunk') && e.id) {
264
+ const profile = tracks.profiles.get(`${e.pid}:${e.id}`) || { nodes: new Map(), samples: [], deltas: [] };
265
+ if (e.name === 'Profile') Object.assign(profile, { main, startTime: data?.startTime });
266
+ const cpu = data?.cpuProfile;
267
+ for (const node of cpu?.nodes || []) profile.nodes.set(node.id, { ...node.callFrame, parent: node.parent });
268
+ for (const id of cpu?.samples || []) profile.samples.push(id);
269
+ for (const delta of data?.timeDeltas || []) profile.deltas.push(delta);
270
+ tracks.profiles.set(`${e.pid}:${e.id}`, profile);
271
+ }
272
+ }
273
+
274
+ // The main thread's events nested as they ran: the order they started in, each one's own time (less
275
+ // its children's), and its top-level task. A task's events follow it in that order, up to its end.
276
+ function nest(events) {
277
+ const { n, ts, dur } = events;
278
+ const order = new Uint32Array(n);
279
+ for (let i = 0; i < n; i++) order[i] = i;
280
+ order.sort((a, b) => ts[a] - ts[b] || dur[b] - dur[a]);
281
+ const self = Float64Array.from(dur.subarray(0, n));
282
+ const taskOf = new Int32Array(n).fill(-1);
283
+ const tasks = [];
284
+ const stack = [];
285
+ for (let p = 0; p < n; p++) {
286
+ const i = order[p];
287
+ while (stack.length && ts[stack[stack.length - 1]] + dur[stack[stack.length - 1]] <= ts[i]) stack.pop();
288
+ const parent = stack[stack.length - 1];
289
+ if (parent !== undefined) { self[parent] -= Math.min(dur[i], ts[parent] + dur[parent] - ts[i]); taskOf[i] = taskOf[parent]; }
290
+ else if (RUN_TASK.has(events.name(i))) { taskOf[i] = i; tasks.push({ i, p, ts: ts[i], dur: dur[i] }); }
291
+ stack.push(i);
292
+ }
293
+ for (const task of tasks) {
294
+ let p = task.p + 1;
295
+ while (p < n && ts[order[p]] < task.ts + task.dur) p++;
296
+ task.end = p;
297
+ }
298
+ return { order, self, taskOf, tasks };
299
+ }
300
+
301
+ // The main thread's JS samples in time order: when each was taken, how long it stands for, and the
302
+ // page's function it was in (an index into keys), -1 for none.
303
+ function mainSamples(profiles) {
304
+ const keys = [];
305
+ const keyIndex = new Map();
306
+ const taken = [];
307
+ for (const { main, startTime, nodes, samples: ids, deltas } of profiles.values()) {
308
+ if (!main || startTime == null) continue;
309
+ const scriptOf = new Map();
310
+ let t = startTime;
311
+ for (let i = 0; i < ids.length; i++) {
312
+ t += deltas[i] ?? 0;
313
+ if (!scriptOf.has(ids[i])) {
314
+ const frame = scriptFrame(nodes, nodes.get(ids[i]));
315
+ const key = frame ? where(frame, true) : null;
316
+ if (key !== null && !keyIndex.has(key)) { keyIndex.set(key, keys.length); keys.push(key); }
317
+ scriptOf.set(ids[i], key === null ? -1 : keyIndex.get(key));
318
+ }
319
+ const frame = nodes.get(ids[i]);
320
+ taken.push(t, scriptOf.get(ids[i]), cpuKey(frame, scriptFrame(nodes, frame), keyIndex, keys));
321
+ }
322
+ }
323
+ const count = taken.length / 3;
324
+ const order = new Uint32Array(count);
325
+ for (let i = 0; i < count; i++) order[i] = i;
326
+ order.sort((a, b) => taken[a * 3] - taken[b * 3]);
327
+ const samples = { t: new Float64Array(count), us: new Float64Array(count), script: new Int32Array(count), own: new Int32Array(count), keys };
328
+ for (let k = 0; k < count; k++) {
329
+ samples.t[k] = taken[order[k] * 3];
330
+ samples.script[k] = taken[order[k] * 3 + 1];
331
+ samples.own[k] = taken[order[k] * 3 + 2];
332
+ }
333
+ // Samples can come out of order (a negative delta); each stands for the time until the next one,
334
+ // but no more than a few intervals, so one before a pause does not take the pause.
335
+ for (let k = 0; k < count; k++) samples.us[k] = k + 1 < count ? Math.min(samples.t[k + 1] - samples.t[k], 2000) : 0;
336
+ return samples;
337
+ }
338
+
339
+ // The function a sample itself was in, for --cpu's list: a built-in with the page's function that
340
+ // called it; -1 for idle, and for code not the page's (pw-repl's own, run in the page, has no URL).
341
+ function cpuKey(frame, script, keyIndex, keys) {
342
+ if (!frame || /^\((?:idle|program|root)\)$/.test(frame.functionName)) return -1;
343
+ let key;
344
+ if (frame.url) key = where(frame, true);
345
+ else if (frame.functionName === '(garbage collector)') key = frame.functionName;
346
+ else if (script) key = `${frame.functionName || '(anonymous)'} (built-in, from ${where(script, true)})`;
347
+ else return -1;
348
+ if (!keyIndex.has(key)) { keyIndex.set(key, keys.length); keys.push(key); }
349
+ return keyIndex.get(key);
350
+ }
351
+
352
+ // The page's own function a sample was in: a built-in (performance.now, JSON.parse) counts as the
353
+ // function that called it.
354
+ function scriptFrame(nodes, frame) {
355
+ for (let f = frame, depth = 0; f && depth < 200; f = nodes.get(f.parent), depth++) if (f.url) return f;
356
+ return null;
357
+ }
358
+
359
+ // The page's function that ran longest itself between two times, from the samples.
360
+ function hottest(samples, from, to) {
361
+ const self = new Map();
362
+ for (let k = lowerBound(samples.t, from); k < samples.t.length && samples.t[k] < to; k++) {
363
+ if (samples.script[k] < 0) continue;
364
+ self.set(samples.script[k], (self.get(samples.script[k]) || 0) + samples.us[k]);
365
+ }
366
+ const [top] = [...self].sort((a, b) => b[1] - a[1]);
367
+ return top ? { where: samples.keys[top[0]], us: top[1] } : null;
368
+ }
369
+
370
+ function lowerBound(times, t) {
371
+ let lo = 0;
372
+ let hi = times.length;
373
+ while (lo < hi) { const mid = (lo + hi) >> 1; if (times[mid] < t) lo = mid + 1; else hi = mid; }
374
+ return lo;
375
+ }
376
+
377
+ function analyse(tracks, t0, tEnd, { cpu }) {
378
+ const events = tracks.main;
379
+ const nested = nest(events);
380
+ const samples = mainSamples(tracks.profiles);
381
+ const buckets = Object.fromEntries(BUCKETS.map(b => [b, 0]));
382
+ for (let i = 0; i < events.n; i++) {
383
+ if (nested.taskOf[i] < 0) continue;
384
+ buckets[bucketOf(events.name(i)) || 'other'] += Math.max(0, nested.self[i]);
385
+ }
386
+ const busy = nested.tasks.reduce((sum, t) => sum + t.dur, 0);
387
+ const big = [...tracks.requests.values()].filter(r => r.finished && !r.failed && r.size >= BIG_REQUEST).sort((a, b) => a.finished - b.finished);
388
+ const longTasks = nested.tasks.filter(t => t.dur >= LONG_TASK).map(t => ({ ...describeTask(t, events, nested, big), hot: hottest(samples, t.ts, t.ts + t.dur) }));
389
+ const forced = new Map();
390
+ for (const [i, detail] of events.details) {
391
+ if (!detail.stack) continue;
392
+ const kind = events.name(i) === 'Layout' ? 'layout' : 'style';
393
+ const key = `${where(detail.stack)} (${kind})`;
394
+ const entry = forced.get(key) || { where: key, count: 0, dur: 0 };
395
+ entry.count += 1;
396
+ entry.dur += events.dur[i];
397
+ forced.set(key, entry);
398
+ }
399
+ return {
400
+ t0, duration: tEnd - t0, busy, buckets,
401
+ marks: Object.entries(tracks.marks).sort((a, b) => a[1] - b[1]),
402
+ longTasks,
403
+ forced: [...forced.values()].sort((a, b) => b.dur - a.dur),
404
+ steps: steps(tracks, events, nested),
405
+ cpu: cpu ? cpuTop(samples) : null,
406
+ frames: tracks.frames.length,
407
+ };
408
+ }
409
+
410
+ function describeTask(task, events, { order, self }, big) {
411
+ const own = {};
412
+ let trigger = null;
413
+ let script = null;
414
+ for (let p = task.p; p < task.end; p++) {
415
+ const i = order[p];
416
+ const name = events.name(i);
417
+ const b = bucketOf(name) || 'other';
418
+ own[b] = (own[b] || 0) + Math.max(0, self[i]);
419
+ // One task can dispatch several events (pointerdown, mousedown, click): the one that took longest.
420
+ if ((name === 'EventDispatch' || TRIGGERS[name] || name === 'EvaluateScript') && (!trigger || events.dur[i] > events.dur[trigger])) trigger = i;
421
+ if (events.details.get(i)?.frame?.url && (script === null || events.dur[i] > events.dur[script])) script = i;
422
+ }
423
+ const kind = Object.entries(own).sort((a, b) => b[1] - a[1])[0][0];
424
+ const triggerName = trigger === null ? null : events.name(trigger);
425
+ const triggerText = triggerName === null ? null : triggerName === 'EventDispatch' ? `${events.details.get(trigger).type} event` : triggerName === 'EvaluateScript' ? 'script' : TRIGGERS[triggerName];
426
+ const after = big.filter(r => r.finished <= task.ts && task.ts - r.finished < 1000000).pop() || null;
427
+ return { ts: task.ts, dur: task.dur, kind, trigger: triggerText, script: script === null ? null : where(events.details.get(script).frame), after };
428
+ }
429
+
430
+ // Each step of the user's: an interaction (click, key), a burst of wheel or scroll events, or a drag
431
+ // (pointer down, moves, up), with what the page did in it. Interactions inside a drag are its own.
432
+ function steps(tracks, events, { taskOf, tasks }) {
433
+ const found = [];
434
+ const dispatches = [...events.details].filter(([, d]) => d.type).map(([i, d]) => ({ ts: events.ts[i], dur: events.dur[i], type: d.type, task: taskOf[i] })).sort((a, b) => a.ts - b.ts);
435
+ // A step starts with the task its first event ran in.
436
+ const began = e => (e.task >= 0 ? events.ts[e.task] : e.ts);
437
+ let burst = null;
438
+ for (const e of dispatches.filter(d => /^(?:wheel|mousewheel|scroll)$/.test(d.type))) {
439
+ const wheel = e.type !== 'scroll';
440
+ if (burst && e.ts - burst.end <= STEP_GAP) { burst.end = e.ts + e.dur; burst.events += 1; burst.wheel ||= wheel; continue; }
441
+ burst = { kind: 'wheel/scroll', start: began(e), end: e.ts + e.dur, events: 1, wheel };
442
+ found.push(burst);
443
+ }
444
+ let down = null;
445
+ let moves = 0;
446
+ for (const e of dispatches) {
447
+ if (/^(?:pointerdown|mousedown|touchstart)$/.test(e.type)) { if (!down) { down = e; moves = 0; } }
448
+ else if (down && /^(?:pointermove|mousemove|touchmove)$/.test(e.type)) moves += 1;
449
+ else if (down && /^(?:pointerup|mouseup|touchend|pointercancel|touchcancel)$/.test(e.type)) {
450
+ if (moves >= 3) found.push({ kind: 'drag', start: began(down), end: e.ts + e.dur, events: moves });
451
+ down = null;
452
+ }
453
+ }
454
+ // An interaction's latency is its slowest event's, to the paint after it, as DevTools has it; a key's
455
+ // keydown and keyup share an interaction but not a frame. It is split over the events shown in that
456
+ // frame (pointerup and click): input delay to the first handler, handlers to the last one's end,
457
+ // presentation from then to the paint.
458
+ const drags = found.filter(s => s.kind === 'drag');
459
+ for (const list of tracks.timings.values()) {
460
+ const ts = Math.min(...list.map(t => t.ts));
461
+ const end = Math.max(...list.map(t => t.ts + t.duration * 1000));
462
+ if (drags.some(s => ts <= s.end && end >= s.start)) continue;
463
+ const slowest = list.reduce((a, b) => (b.duration > a.duration ? b : a));
464
+ const painted = slowest.timeStamp + slowest.duration;
465
+ const frame = list.filter(t => Math.abs(t.timeStamp + t.duration - painted) < 8);
466
+ const startAt = Math.min(...frame.map(t => t.timeStamp));
467
+ const processingStart = Math.min(...frame.map(t => t.processingStart));
468
+ const processingEnd = Math.max(...frame.map(t => t.processingEnd));
469
+ const latency = slowest.duration;
470
+ const type = list.some(t => /^key/.test(t.type)) ? 'key press' : list.some(t => /click|pointer|mouse/.test(t.type)) ? 'click' : list[0].type;
471
+ found.push({
472
+ kind: type, start: ts, end, latency,
473
+ delay: processingStart - startAt, handlers: processingEnd - processingStart,
474
+ presentation: Math.max(0, latency - (processingEnd - startAt)),
475
+ });
476
+ }
477
+ // A page gets scroll events for any scroll, not only input: a click's scroll into view (dispatched in
478
+ // the frame after it, or before it when the click waited a frame) or its handler's scrollTo. Scroll
479
+ // events with no wheel event next to a click or key press are that step's.
480
+ for (const burst of found.filter(s => s.kind === 'wheel/scroll' && !s.wheel)) {
481
+ const step = found.find(s => s.latency != null && burst.start <= s.end + STEP_TAIL && burst.end >= s.start - STEP_TAIL);
482
+ if (!step) continue;
483
+ step.scrolled = true;
484
+ step.end = Math.max(step.end, burst.end);
485
+ found.splice(found.indexOf(burst), 1);
486
+ }
487
+ found.sort((a, b) => a.start - b.start);
488
+ const taskStarts = tasks.map(t => t.ts);
489
+ const frames = tracks.frames.sort((a, b) => a.ts - b.ts);
490
+ const frameTimes = frames.map(f => f.ts);
491
+ for (const [i, step] of found.entries()) {
492
+ // Up to the next step, which what follows is counted in.
493
+ const end = Math.max(step.end, Math.min(step.end + STEP_TAIL, found[i + 1]?.start ?? Infinity));
494
+ step.busy = 0;
495
+ step.longest = 0;
496
+ // Tasks in start order; one that began before the step and runs into it counts its part.
497
+ for (let k = Math.max(0, lowerBound(taskStarts, step.start) - 1); k < tasks.length && tasks[k].ts < end; k++) {
498
+ const t = tasks[k];
499
+ if (t.ts + t.dur <= step.start) continue;
500
+ step.busy += Math.min(t.ts + t.dur, end) - Math.max(t.ts, step.start);
501
+ if (t.ts >= step.start) step.longest = Math.max(step.longest, t.dur);
502
+ }
503
+ step.frames = 0;
504
+ step.dropped = 0;
505
+ let first = lowerBound(frameTimes, step.start);
506
+ // A scroll's own frame comes before its scroll event: the last frame before it, if close and not
507
+ // the step before's.
508
+ if (step.kind === 'wheel/scroll' && first > 0) {
509
+ const before = frames[first - 1].ts;
510
+ if (step.start - before <= SCROLL_LEAD && before > (found[i - 1]?.start ?? -Infinity)) first -= 1;
511
+ }
512
+ for (let k = first; k < frames.length && frames[k].ts < end; k++) {
513
+ step.frames += 1;
514
+ if (frames[k].dropped) step.dropped += 1;
515
+ }
516
+ }
517
+ return found;
518
+ }
519
+
520
+ // The functions that ran longest themselves, from the sampling profile's samples.
521
+ function cpuTop(samples) {
522
+ const self = new Map();
523
+ for (let k = 0; k < samples.t.length; k++) {
524
+ if (samples.own[k] < 0) continue;
525
+ self.set(samples.own[k], (self.get(samples.own[k]) || 0) + samples.us[k]);
526
+ }
527
+ return [...self].map(([key, us]) => ({ where: samples.keys[key], us })).sort((a, b) => b.us - a.us);
528
+ }
529
+
530
+ module.exports = { summarize, eachEvent, LONG_TASK };
package/lib/util.js CHANGED
@@ -7,6 +7,9 @@ const COMMAND_TIMEOUT = 15000;
7
7
 
8
8
  const MAX_EVENT_TEXT = 4000;
9
9
 
10
+ // For a command that timed out waiting on the page.
11
+ const BUSY_PAGE = 'a page busy with a long task does not answer meanwhile: profile on, then try again, shows what it is busy with';
12
+
10
13
  // For a command that needs a tab, when none is selected.
11
14
  const NO_TAB = 'No tab is selected; select one with tab <index|url-part>, or open one with tab new';
12
15
 
@@ -66,4 +69,4 @@ function playwrightBrowsersDir() {
66
69
  return path.join(cache, 'ms-playwright');
67
70
  }
68
71
 
69
- module.exports = { STOPPED_RECORDING, ENDED_UNSEEN, COMMAND_TIMEOUT, MAX_EVENT_TEXT, NO_TAB, hints, clipText, numbers, regexOf, patternText, clock, playwrightBrowsersDir };
72
+ module.exports = { BUSY_PAGE, STOPPED_RECORDING, ENDED_UNSEEN, COMMAND_TIMEOUT, MAX_EVENT_TEXT, NO_TAB, hints, clipText, numbers, regexOf, patternText, clock, playwrightBrowsersDir };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pw-repl",
3
- "version": "0.12.4",
3
+ "version": "0.12.6",
4
4
  "description": "Text REPL for driving an existing Chromium through Playwright over CDP",
5
5
  "keywords": [
6
6
  "playwright",
package/skill/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pw-repl
3
- description: Inspect and drive a Chromium browser, possibly one the user is using, through the pw-repl REPL - tabs, page snapshots, clicks and typing, the mouse at a point (moves, buttons, wheel), requests and their bodies, console messages, waiting for pages and elements, choosing files, faking, patching or delaying responses, cutting or slowing the network, emulating a phone, dark mode, a locale or a timezone, recording what the user does, and recording a video of a tab, with a pointer and on-screen captions. Use when asked to look at, debug or test something in a web page, in an existing Chromium with remote debugging or in one it starts itself.
3
+ description: Inspect and drive a Chromium browser, possibly one the user is using, through the pw-repl REPL - tabs, page snapshots, clicks and typing, the mouse at a point (moves, buttons, wheel), requests and their bodies, console messages, waiting for pages and elements, choosing files, faking, patching or delaying responses, cutting or slowing the network, emulating a phone, dark mode, a locale or a timezone, recording what the user does, profiling what makes a page slow (long tasks, forced layouts, input delay, dropped frames), and recording a video of a tab, with a pointer and on-screen captions. Use when asked to look at, debug or test something in a web page, in an existing Chromium with remote debugging or in one it starts itself.
4
4
  allowed-tools: Bash(pw-repl:*) Bash(npx pw-repl@latest:*)
5
5
  ---
6
6
 
@@ -181,6 +181,11 @@ When watching someone:
181
181
  - A watch belongs to the tab: when another client watches it too, read it with `watch <n>`, since
182
182
  `watch new`'s place is shared and each one's would hide steps from the other.
183
183
 
184
+ When a page is slow or feels laggy, `requests` shows a slow response; for what the page itself is busy
185
+ with, `profile on` and `profile off` around what you do, or while the user uses it, sum up a performance
186
+ trace of the tab: long tasks and the function behind each, forced layouts, each click's time to the next
187
+ paint, and frames dropped while scrolling or dragging. `profile reload` profiles a load. `help profile`.
188
+
184
189
  Exit status: `0` ok, `1` the command failed, `2` completion not confirmed (outcome unknown: do not
185
190
  blindly retry a change), `64` usage or the REPL is not reachable. `pw-repl --help` has the options.
186
191