pw-repl 0.12.3 → 0.12.5
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/AGENTS.md +2 -1
- package/README.md +8 -5
- package/lib/commands.js +4 -0
- package/lib/help.js +65 -9
- package/lib/inspect.js +1 -1
- package/lib/launch.js +6 -2
- package/lib/modes.js +6 -0
- package/lib/output.js +13 -4
- package/lib/profile.js +310 -0
- package/lib/record.js +2 -3
- package/lib/runner.js +2 -2
- package/lib/send.js +3 -2
- package/lib/tabs.js +1 -1
- package/lib/trace.js +509 -0
- package/lib/util.js +16 -1
- package/package.json +1 -1
- package/skill/SKILL.md +8 -2
package/AGENTS.md
CHANGED
|
@@ -20,7 +20,8 @@ If you hit a limitation or write a workaround, consider adding the capability to
|
|
|
20
20
|
drawn over the page, which the element and mouse commands glide first), `lib/toast.js` (a line of text
|
|
21
21
|
over the page), `lib/overlay.js` (the element the cursor and toast are drawn in, one layer each), `lib/inspect.js` (reading the
|
|
22
22
|
page, screenshots, eval, CDP, waits), `lib/watch.js`, `lib/requestlog.js` (requests, bodies, console),
|
|
23
|
-
`lib/routes.js`, `lib/network.js`, `lib/emulation.js` (and viewport), `lib/capture.js`, `lib/
|
|
23
|
+
`lib/routes.js`, `lib/network.js`, `lib/emulation.js` (and viewport), `lib/capture.js`, `lib/profile.js` (performance traces;
|
|
24
|
+
`lib/trace.js` reads and sums them up), `lib/record.js`
|
|
24
25
|
(video, through ffmpeg, and the steps file saved with it), `lib/dialogs.js` and `lib/modes.js`. Beneath them, `lib/tabstate.js` is the one record kept per tab, `lib/cdp.js` the CDP
|
|
25
26
|
session kept per tab, and `lib/util.js` small shared helpers. No facility requires `lib/modes.js` or
|
|
26
27
|
`lib/commands.js`, and a unit test fails on a require cycle, which CommonJS would not report.
|
package/README.md
CHANGED
|
@@ -54,6 +54,8 @@ pw> console error # and see how the page copes
|
|
|
54
54
|
- **Record:** what someone does in a tab (`watch`), a timeline of requests and console (`capture`), or a
|
|
55
55
|
video of the tab (`record`), with a virtual pointer (`cursor`), captions (`toast`) and highlights
|
|
56
56
|
(`highlight`).
|
|
57
|
+
- **Profile:** what keeps a page's main thread busy (`profile`): long tasks and the function behind each,
|
|
58
|
+
forced style and layout, each click's or key's time to the next paint, and dropped frames.
|
|
57
59
|
- **Share:** many clients on one REPL, each with its own tab; a command server on a Unix socket or a local
|
|
58
60
|
port, with an HTTP+JSON protocol.
|
|
59
61
|
|
|
@@ -146,6 +148,7 @@ example the Docker bridge gateway, `ip route | awk '/default/ {print $3}'`).
|
|
|
146
148
|
| see each step as I click | `watch on --live` |
|
|
147
149
|
| replay what I clicked through | `watch on`, click around, `watch save flow.txt`, then `pw-repl send --file flow.txt` |
|
|
148
150
|
| record requests and console together | `capture on`, then `capture off` |
|
|
151
|
+
| find what makes the page slow | `profile on`, then `profile off`; `profile reload` for a page load |
|
|
149
152
|
| fake a backend response | `route <glob> <status> <json>`, `route <glob> abort` |
|
|
150
153
|
| change or slow an API response | `route <glob> patch <json>`, `route <glob> delay <secs>` |
|
|
151
154
|
| cut or slow the whole network | `network off`, `network slow`, then `network on` |
|
|
@@ -159,11 +162,11 @@ example the Docker bridge gateway, `ip route | awk '/default/ {print $3}'`).
|
|
|
159
162
|
|
|
160
163
|
### Modes
|
|
161
164
|
|
|
162
|
-
Some commands turn on a mode that stays on until you turn it off: `watch`, `capture`, `
|
|
163
|
-
off` or `slow`, `emulate`, `viewport`, `record`, `highlight`, `cursor` and `toast`. While any
|
|
164
|
-
the selected tab, the prompt shows them: `(watch network:off routes:2) pw>`. `modes` lists them
|
|
165
|
-
tab, and `modes off` turns them all off. Each of these commands on its own shows its state and
|
|
166
|
-
can run next.
|
|
165
|
+
Some commands turn on a mode that stays on until you turn it off: `watch`, `capture`, `profile`, `route`,
|
|
166
|
+
`network off` or `slow`, `emulate`, `viewport`, `record`, `highlight`, `cursor` and `toast`. While any
|
|
167
|
+
are on in the selected tab, the prompt shows them: `(watch network:off routes:2) pw>`. `modes` lists them
|
|
168
|
+
for every tab, and `modes off` turns them all off. Each of these commands on its own shows its state and
|
|
169
|
+
what you can run next.
|
|
167
170
|
|
|
168
171
|
## For agents and scripts
|
|
169
172
|
|
package/lib/commands.js
CHANGED
|
@@ -14,6 +14,7 @@ const { commands: networkCommands } = require('./network');
|
|
|
14
14
|
const { commands: emulationCommands, EMULATE_KINDS } = require('./emulation');
|
|
15
15
|
const { commands: captureCommands } = require('./capture');
|
|
16
16
|
const { commands: recordCommands } = require('./record');
|
|
17
|
+
const { commands: profileCommands } = require('./profile');
|
|
17
18
|
const { commands: inspectCommands } = require('./inspect');
|
|
18
19
|
const { commands: watchCommands, nextTabWanted, waitingForMe, watchNewTab } = require('./watch');
|
|
19
20
|
const { commands: tabsCommands, isOpening } = require('./tabs');
|
|
@@ -33,6 +34,7 @@ const commands = {
|
|
|
33
34
|
...emulationCommands,
|
|
34
35
|
...captureCommands,
|
|
35
36
|
...recordCommands,
|
|
37
|
+
...profileCommands,
|
|
36
38
|
...dialogsCommands,
|
|
37
39
|
...modesCommands,
|
|
38
40
|
|
|
@@ -117,6 +119,7 @@ function complete(line) {
|
|
|
117
119
|
if (command === 'watch') return match(['on', 'off', 'new', '--all']);
|
|
118
120
|
if (command === 'capture') return match(['on', 'off']);
|
|
119
121
|
if (command === 'record') return match(['on', 'off']);
|
|
122
|
+
if (command === 'profile') return match(['on', 'off', 'save', 'reload']);
|
|
120
123
|
if (command === 'cursor') return match(['on', 'off']);
|
|
121
124
|
if (command === 'toast') return match(['off']);
|
|
122
125
|
if (command === 'viewport') return match(['off']);
|
|
@@ -124,6 +127,7 @@ function complete(line) {
|
|
|
124
127
|
}
|
|
125
128
|
if (words.length >= 3 && command === 'watch' && words[1] === 'on') return match(['--changes', '--live', '--next-tab'].filter(f => !words.slice(2, -1).includes(f)));
|
|
126
129
|
if (words.length === 3 && command === 'capture' && words[1] === 'on') return match(['requests', 'console']);
|
|
130
|
+
if (words.length >= 3 && command === 'profile' && words[1] === 'on') return match(['--cpu']);
|
|
127
131
|
if (words.length === 3 && command === 'emulate' && EMULATE_KINDS.includes(words[1])) return match(['off']);
|
|
128
132
|
if (words.length === 3 && command === 'route' && words[1] === 'off') return match(['--all', ...(state.page ? routesFor(state.page).keys() : [])]);
|
|
129
133
|
return [[], current];
|
package/lib/help.js
CHANGED
|
@@ -74,7 +74,7 @@ fill for selectors with spaces. mousemove and the other mouse commands act at a
|
|
|
74
74
|
},
|
|
75
75
|
inspect: {
|
|
76
76
|
intro: `Output is capped; --all right after the command, or at the end, shows everything (text --all body).`,
|
|
77
|
-
commands: ['snapshot', 'watch', 'text', 'html', 'attrs', 'listeners', 'count', 'visible', 'links', 'inputs', 'screenshot', 'highlight', 'viewport', 'emulate', 'wait', 'sleep'],
|
|
77
|
+
commands: ['snapshot', 'watch', 'profile', 'text', 'html', 'attrs', 'listeners', 'count', 'visible', 'links', 'inputs', 'screenshot', 'highlight', 'viewport', 'emulate', 'wait', 'sleep'],
|
|
78
78
|
},
|
|
79
79
|
video: {
|
|
80
80
|
intro: `A video of what you do in a tab, for someone to watch. Record it from a file of commands sent with
|
|
@@ -235,7 +235,7 @@ A click at a point: mouseclick <x> <y>. A drag: mousedown, then mousemove to ano
|
|
|
235
235
|
clicked with click, which waits for it. help mousemove shows how to find a point. The mouse stays at the point, for a mousewheel or mousedown after
|
|
236
236
|
it. The cursor (help cursor) moves there first and shows a ring at the press.`,
|
|
237
237
|
},
|
|
238
|
-
mousedown: { usage: 'mousedown [button]', summary: 'press a mouse button
|
|
238
|
+
mousedown: { usage: 'mousedown [button]', summary: 'press a mouse button at the mouse (left, right, middle)' },
|
|
239
239
|
mouseup: { usage: 'mouseup [button]', summary: 'release a mouse button where the mouse is' },
|
|
240
240
|
mousewheel: {
|
|
241
241
|
usage: 'mousewheel <dx> <dy>',
|
|
@@ -252,7 +252,7 @@ scrolled or redrawn. While recording, it scrolls in eased steps over up to 1s (h
|
|
|
252
252
|
summary: 'clear an input and fill it',
|
|
253
253
|
args: [
|
|
254
254
|
['<selector>', 'the first word; quote it if it has spaces: fill "text=Your name" Ada.'],
|
|
255
|
-
['<value>', 'the rest of the line; quotes around all of it are removed, so fill #name "" clears it.'],
|
|
255
|
+
['<value>', 'the rest of the line; quotes around all of it are removed, so fill #name "" clears it. Over send, the shell\'s quotes do it: pw-repl send fill #name "" (\'""\' types two quote marks).'],
|
|
256
256
|
],
|
|
257
257
|
options: [
|
|
258
258
|
['--submit', 'at the end: presses Enter in it afterwards.'],
|
|
@@ -261,8 +261,8 @@ scrolled or redrawn. While recording, it scrolls in eased steps over up to 1s (h
|
|
|
261
261
|
once, even while recording: type shows it being typed.`,
|
|
262
262
|
},
|
|
263
263
|
type: {
|
|
264
|
-
usage: 'type [<selector>] <text> [--submit]',
|
|
265
|
-
summary: 'type key by key after what the field holds
|
|
264
|
+
usage: 'type [--delay=<ms>] [<selector>] <text> [--submit]',
|
|
265
|
+
summary: 'type key by key after what the field holds',
|
|
266
266
|
args: [
|
|
267
267
|
['[<selector>]', 'as for fill. Without it (one word, or one quoted), into what has focus: type "garden hose".'],
|
|
268
268
|
['<text>', 'the rest of the line, as fill\'s value.'],
|
|
@@ -281,10 +281,10 @@ Date and time inputs: they take keys part by part, in the order they show them (
|
|
|
281
281
|
from the first part with a selector, or from the part a click landed on. type says what the input then
|
|
282
282
|
holds. Their ISO form (2026-10-02) is refused, since typed it comes out mangled: fill sets it.`,
|
|
283
283
|
},
|
|
284
|
-
press: { usage: 'press <key> | press <selector> <key>', summary: 'press a key
|
|
284
|
+
press: { usage: 'press <key> | press <selector> <key>', summary: 'press a key: Enter, Escape, Control+A (any case)' },
|
|
285
285
|
select: {
|
|
286
286
|
usage: 'select <selector> <value>',
|
|
287
|
-
summary: 'choose an option
|
|
287
|
+
summary: 'choose an option; the value is the rest of the line',
|
|
288
288
|
args: [
|
|
289
289
|
['<value>', 'an option\'s value or its label.'],
|
|
290
290
|
],
|
|
@@ -314,6 +314,7 @@ all.`,
|
|
|
314
314
|
['<selector>', 'outlines the first match, with new refs: those from before stop working.'],
|
|
315
315
|
],
|
|
316
316
|
options: [
|
|
317
|
+
['--all', 'the whole outline, not capped.'],
|
|
317
318
|
['--full', 'Playwright\'s snapshot unchanged.'],
|
|
318
319
|
['--grep <text>', 'only the lines containing text (role, name or a flag such as [disabled], any case), each with the named elements around it.'],
|
|
319
320
|
['--regex <pattern>', '--grep by a regular expression: snapshot --regex "Total: \\d+".'],
|
|
@@ -332,7 +333,8 @@ value attribute so too. A field a Show password button made plain text, with no
|
|
|
332
333
|
shows its value. Every other field shows what it holds (textbox "Name": Ada), and a select its chosen
|
|
333
334
|
option: inputs lists the fields without what they hold.
|
|
334
335
|
|
|
335
|
-
Output over 60 lines ends with a line count
|
|
336
|
+
Output over 60 lines ends with a line count. Past 12000 characters it is cut at the end of a line, saying
|
|
337
|
+
where: --all shows all.`,
|
|
336
338
|
},
|
|
337
339
|
watch: {
|
|
338
340
|
usage: 'watch on [options] | off | [n|new] | save <file>',
|
|
@@ -573,7 +575,8 @@ A (page) line marks each new page, loaded or routed to by a single-page app. It
|
|
|
573
575
|
video.
|
|
574
576
|
|
|
575
577
|
ffmpeg: $PW_FFMPEG, and only it when it is set (the REPL's, not send's); otherwise Playwright's own (npx
|
|
576
|
-
playwright-core install ffmpeg, in $PLAYWRIGHT_BROWSERS_PATH or ~/.cache/ms-playwright
|
|
578
|
+
playwright-core install ffmpeg, in $PLAYWRIGHT_BROWSERS_PATH, or ~/.cache/ms-playwright on Linux and
|
|
579
|
+
~/Library/Caches/ms-playwright on macOS), then one on the
|
|
577
580
|
PATH. Playwright's writes only WebM; an MP4 needs a system ffmpeg with H.264. Without one that can write
|
|
578
581
|
the format, record on fails at once, recording nothing. WebM plays in browsers, GitHub, GitLab and Slack;
|
|
579
582
|
MP4 everywhere. About 15 MB a minute.`,
|
|
@@ -702,6 +705,59 @@ Kept from when the REPL connects; the last 200 per tab.
|
|
|
702
705
|
|
|
703
706
|
A page brought back from the back/forward cache (back, forward) reports its earlier messages again, at
|
|
704
707
|
the time it comes back, though nothing loaded again.`,
|
|
708
|
+
},
|
|
709
|
+
profile: {
|
|
710
|
+
usage: 'profile on [secs] | off | reload | save <file>',
|
|
711
|
+
summary: 'what slows the tab: long tasks, layouts, input, frames',
|
|
712
|
+
args: [
|
|
713
|
+
['on', 'starts a performance trace of the selected tab, until profile off (at most 120 seconds).'],
|
|
714
|
+
['[secs]', 'records that long (1 to 120), then prints. Other commands wait until it ends. Over 20, give send time too: pw-repl send -t 70 profile on 60.'],
|
|
715
|
+
['off', 'stops it and prints the summary.'],
|
|
716
|
+
['reload', 'reloads the tab and profiles its load, until its network is idle (at most 10s after load) and half a second more. For work that starts later (a timer): profile on, reload, sleep, profile off.'],
|
|
717
|
+
['save <file>', 'writes the last profile\'s trace, the tab\'s events only, as Chrome trace JSON for DevTools\' Performance panel (Load profile) or ui.perfetto.dev. It refuses a file that exists.'],
|
|
718
|
+
['(none)', 'says whether one is running, or shows the last one.'],
|
|
719
|
+
],
|
|
720
|
+
optionsFor: 'profile on',
|
|
721
|
+
options: [
|
|
722
|
+
['--cpu', 'samples JS more often, and adds the functions that ran longest themselves. The trace gets much bigger.'],
|
|
723
|
+
],
|
|
724
|
+
detail: `For "the page feels slow": what the tab's main thread was busy with, and when. It is a Chrome
|
|
725
|
+
performance trace, as DevTools' Performance panel records (Playwright's browser.startTracing), summed up.
|
|
726
|
+
Use it around something you do (profile on, click e5, profile off), or while the user uses the page: steps
|
|
727
|
+
they take in the browser are in it.
|
|
728
|
+
|
|
729
|
+
The summary: main-thread busy time by kind (scripting, style/layout, paint, GC, other); the page's
|
|
730
|
+
load marks, when it loaded; each long task (over 50ms), with what set it off, the script it ran, the
|
|
731
|
+
function it spent most time in ("most in"), each as file:line function, and a big response (100 KB or more) that finished just before
|
|
732
|
+
it; forced style and layout (work a script waited for, reading a size or a style), by where in the script; and the steps:
|
|
733
|
+
each click or key press with its time to the next paint (input delay, handlers, presentation), and each
|
|
734
|
+
burst of wheel or scroll events and each drag, with the main thread's busy time and the frames drawn and
|
|
735
|
+
dropped. Times are from when it started. --all lists every long task and step, not the first ones.
|
|
736
|
+
|
|
737
|
+
Size: a busy page fills Chrome's trace buffer in under a minute (one that restyles many elements as it
|
|
738
|
+
draws, sooner), which stops the profile there, and the summary says so. Stopping a long one takes a
|
|
739
|
+
while, as Chrome writes out its trace: give send time, pw-repl send -t 60 profile off. If send gives up
|
|
740
|
+
first, the profile still ends, and profile shows it.
|
|
741
|
+
|
|
742
|
+
One profile runs at a time, in a browser: Chrome traces one at a time, the whole browser's, so another
|
|
743
|
+
REPL's profile, or DevTools' Performance panel recording, stops profile on. It belongs to the tab, ends
|
|
744
|
+
if the tab closes (profile shows it), and modes lists it.`,
|
|
745
|
+
moreAbout: 'what is left out, inlined functions, frames in a background tab, and the mark it leaves in the page',
|
|
746
|
+
more: `The tab's own renderer only: a cross-site iframe running in a process of its own is left out, and
|
|
747
|
+
so is anything another tab of the same site, sharing the tab's process, does at the same time.
|
|
748
|
+
|
|
749
|
+
Frames: a tab pw-repl uses is kept drawing as if it were in front (help tab), so its frame counts are a
|
|
750
|
+
tab in front's; one behind another tab draws far less, as the user would see it. The summary says so.
|
|
751
|
+
|
|
752
|
+
A function the JS engine inlined into its caller counts as the caller, in "most in" and --cpu alike: a
|
|
753
|
+
small hot function called in a loop can show as the loop's function.
|
|
754
|
+
|
|
755
|
+
A wheel-zoom or drag the page handles in its own script (a map) shows as a step from its events; one the
|
|
756
|
+
browser handles alone, with no listener, may show only as frames.
|
|
757
|
+
|
|
758
|
+
profile on and off each run console.timeStamp in the page, which prints nothing, so the tab is found in
|
|
759
|
+
the trace even when its page did nothing. The trace is kept in a temporary folder until the next
|
|
760
|
+
profile, or until the REPL stops.`,
|
|
705
761
|
},
|
|
706
762
|
capture: {
|
|
707
763
|
usage: 'capture on [requests|console] [secs] | off',
|
package/lib/inspect.js
CHANGED
|
@@ -422,7 +422,7 @@ const commands = {
|
|
|
422
422
|
return;
|
|
423
423
|
}
|
|
424
424
|
const lines = text.split('\n').length;
|
|
425
|
-
printOutput(lines > SNAPSHOT_HINT_LINES ?
|
|
425
|
+
printOutput(text, all, lines > SNAPSHOT_HINT_LINES ? `[${lines} lines; snapshot --grep <text> or snapshot <eN> shows less]` : '');
|
|
426
426
|
},
|
|
427
427
|
|
|
428
428
|
async wait(args) {
|
package/lib/launch.js
CHANGED
|
@@ -6,6 +6,7 @@ const { spawn } = require('child_process');
|
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
const os = require('os');
|
|
8
8
|
const path = require('path');
|
|
9
|
+
const { playwrightBrowsersDir } = require('./util');
|
|
9
10
|
|
|
10
11
|
const START_TIMEOUT = 20000;
|
|
11
12
|
// Stopping: a moment to connect and ask it to close, a few seconds to close, as long again after SIGTERM,
|
|
@@ -31,11 +32,14 @@ function findChrome() {
|
|
|
31
32
|
if (fs.existsSync(bundled)) return bundled;
|
|
32
33
|
} catch {}
|
|
33
34
|
// Any Chromium works over CDP, so a revision other than this Playwright's is fine.
|
|
34
|
-
const dir =
|
|
35
|
+
const dir = playwrightBrowsersDir();
|
|
35
36
|
let names = [];
|
|
36
37
|
try { names = fs.readdirSync(dir).filter(n => /^chromium-\d+$/.test(n)).sort().reverse(); } catch {}
|
|
37
38
|
for (const name of names) {
|
|
38
|
-
for (const sub of ['chrome-linux64/chrome', 'chrome-linux/chrome', 'chrome-
|
|
39
|
+
for (const sub of ['chrome-linux64/chrome', 'chrome-linux-arm64/chrome', 'chrome-linux/chrome', 'chrome-win64/chrome.exe',
|
|
40
|
+
'chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
|
|
41
|
+
'chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
|
|
42
|
+
'chrome-mac/Chromium.app/Contents/MacOS/Chromium']) {
|
|
39
43
|
const candidate = path.join(dir, name, sub);
|
|
40
44
|
if (fs.existsSync(candidate)) return candidate;
|
|
41
45
|
}
|
package/lib/modes.js
CHANGED
|
@@ -8,6 +8,7 @@ const { setNetwork } = require('./network');
|
|
|
8
8
|
const { emulatedKinds, setEmulation, setViewport } = require('./emulation');
|
|
9
9
|
const { recordingOf, finishRecording } = require('./record');
|
|
10
10
|
const { currentCapture, endCapture } = require('./capture');
|
|
11
|
+
const { currentProfile, finishProfile } = require('./profile');
|
|
11
12
|
const { nextTabWanted, waitingText, stopWaitingForNextTab, stopWatching } = require('./watch');
|
|
12
13
|
const { hideHighlights, setLabels } = require('./elements');
|
|
13
14
|
const { cursorOf, cursorOff } = require('./cursor');
|
|
@@ -31,6 +32,7 @@ function activeModes(p) {
|
|
|
31
32
|
if (cursorOf(p)) modes.push('cursor');
|
|
32
33
|
if (toastOf(p)) modes.push('toast');
|
|
33
34
|
if (currentCapture()?.page === p) modes.push('capture');
|
|
35
|
+
if (currentProfile()?.page === p) modes.push('profile');
|
|
34
36
|
if (recordingOf(p)) modes.push('record');
|
|
35
37
|
return modes;
|
|
36
38
|
}
|
|
@@ -87,6 +89,8 @@ function modeOwners(p) {
|
|
|
87
89
|
if (toast) owners.push([toast.owner ?? null, 'toast']);
|
|
88
90
|
const capture = currentCapture();
|
|
89
91
|
if (capture?.page === p) owners.push([capture.owner ?? null, 'capture']);
|
|
92
|
+
const profile = currentProfile();
|
|
93
|
+
if (profile?.page === p) owners.push([profile.owner ?? null, 'profile']);
|
|
90
94
|
const recording = recordingOf(p);
|
|
91
95
|
if (recording) owners.push([recording.owner ?? null, 'record']);
|
|
92
96
|
return owners;
|
|
@@ -116,6 +120,8 @@ async function allModesOff(mine = false) {
|
|
|
116
120
|
if (watch?.on && ours(watch.owner)) { stopWatching(p, watch); done.push('watch off'); }
|
|
117
121
|
const capture = currentCapture();
|
|
118
122
|
if (capture?.page === p && ours(capture.owner)) { endCapture(); done.push('capture off (capture shows it)'); }
|
|
123
|
+
const profile = currentProfile();
|
|
124
|
+
if (profile?.page === p && ours(profile.owner)) { await finishProfile(); done.push('profile off (profile shows it)'); }
|
|
119
125
|
const globs = [...(tabState(p).routes || [])].filter(([, route]) => ours(route.owner)).map(([glob]) => glob);
|
|
120
126
|
if (globs.length) { await unroute(p, globs); done.push(`${globs.length} route${globs.length === 1 ? '' : 's'} removed`); }
|
|
121
127
|
if ('network' in tabState(p) && ours(tabState(p).network.owner)) { await setNetwork(p, null); done.push('network on'); }
|
package/lib/output.js
CHANGED
|
@@ -65,16 +65,25 @@ function aside(text) {
|
|
|
65
65
|
else console.log(text);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
// A footer is printed after the cut, so it is never cut off itself.
|
|
69
|
+
function printOutput(value, all = false, footer = '') {
|
|
69
70
|
let text;
|
|
70
71
|
if (typeof value === 'string') text = value;
|
|
71
72
|
else text = JSON.stringify(value, null, 2);
|
|
72
73
|
if (text === undefined) text = 'undefined';
|
|
73
74
|
if (!all && text.length > OUTPUT_LIMIT) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
// Cut at a line's end, unless the last one within the limit comes so early (a short line before a
|
|
76
|
+
// long minified one) that little would be shown.
|
|
77
|
+
const end = text.lastIndexOf('\n', OUTPUT_LIMIT);
|
|
78
|
+
if (end > OUTPUT_LIMIT / 2) {
|
|
79
|
+
const shown = text.slice(0, end);
|
|
80
|
+
const at = shown.split('\n').length;
|
|
81
|
+
text = `${shown}\n[truncated at line ${at} of ${text.split('\n').length}; add --all to the command to show all]`;
|
|
82
|
+
} else {
|
|
83
|
+
text = `${text.slice(0, OUTPUT_LIMIT)}\n[truncated; add --all to the command to show all]`;
|
|
84
|
+
}
|
|
77
85
|
}
|
|
86
|
+
log(footer ? `${text}\n${footer}` : text);
|
|
78
87
|
}
|
|
79
88
|
|
|
80
89
|
module.exports = { OUTPUT_LIMIT, log, error, collect, take, notice, aside, onIdlePrint, onSelectedTab, printOutput };
|
package/lib/profile.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// Profiling a tab: a Chrome performance trace of what its main thread did, summed up when it stops.
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { state, onShutdown } = require('./state');
|
|
6
|
+
const out = require('./output');
|
|
7
|
+
const { NO_TAB, clock, hints } = require('./util');
|
|
8
|
+
const { tabState } = require('./tabstate');
|
|
9
|
+
const { targetIdOf } = require('./tabs');
|
|
10
|
+
const { summarize, LONG_TASK } = require('./trace');
|
|
11
|
+
|
|
12
|
+
const MAX_SECONDS = 120;
|
|
13
|
+
const SHOWN = 10;
|
|
14
|
+
// DevTools' Performance panel's categories, less the browser-wide ones the summary does not use.
|
|
15
|
+
const CATEGORIES = ['devtools.timeline', 'disabled-by-default-devtools.timeline', 'disabled-by-default-devtools.timeline.frame',
|
|
16
|
+
'disabled-by-default-devtools.timeline.stack', 'toplevel', 'v8.execute', 'blink.user_timing', 'loading', 'latencyInfo',
|
|
17
|
+
// JS samples, which name the function a long task spent its time in; --cpu samples more often.
|
|
18
|
+
'disabled-by-default-v8.cpu_profiler'];
|
|
19
|
+
const CPU_CATEGORIES = ['disabled-by-default-v8.cpu_profiler.hires'];
|
|
20
|
+
|
|
21
|
+
let prof = null;
|
|
22
|
+
// A profile still being read after it ended by itself (its tab closed, or it reached its limit).
|
|
23
|
+
let finishing = null;
|
|
24
|
+
let last = null;
|
|
25
|
+
let dir = null;
|
|
26
|
+
|
|
27
|
+
function currentProfile() {
|
|
28
|
+
return prof;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function workDir() {
|
|
32
|
+
if (!dir) dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pw-repl-profile-'));
|
|
33
|
+
return dir;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// A trace event naming the tab's frame, from its renderer, so an idle tab is still found in the trace.
|
|
37
|
+
// console.timeStamp prints nothing in the console. A page held by a dialog does not answer: no wait.
|
|
38
|
+
async function mark(page) {
|
|
39
|
+
if (page.isClosed()) return;
|
|
40
|
+
await Promise.race([page.evaluate(() => console.timeStamp('pw-repl profile')).catch(() => {}), new Promise(resolve => setTimeout(resolve, 1000))]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function forget(profile) {
|
|
44
|
+
if (profile?.saveFile) fs.rmSync(profile.saveFile, { force: true });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function startProfile(tokens, quiet = false) {
|
|
48
|
+
if (prof) throw new Error(`Already profiling ${prof.page.url()}; profile off stops it`);
|
|
49
|
+
const usage = `Usage: profile on [seconds] [--cpu] (seconds from 1 to ${MAX_SECONDS})`;
|
|
50
|
+
const cpu = tokens.includes('--cpu');
|
|
51
|
+
const rest = tokens.filter(t => t !== '--cpu');
|
|
52
|
+
if (rest.length > 1 || (rest.length && !/^\d+$/.test(rest[0]))) throw new Error(usage);
|
|
53
|
+
const seconds = rest.length ? Number(rest[0]) : null;
|
|
54
|
+
if (seconds !== null && (seconds < 1 || seconds > MAX_SECONDS)) throw new Error(usage);
|
|
55
|
+
if (!state.page || state.page.isClosed()) throw new Error(NO_TAB);
|
|
56
|
+
await finishing;
|
|
57
|
+
const page = state.page;
|
|
58
|
+
const frameId = await targetIdOf(page.context(), page);
|
|
59
|
+
if (!frameId) throw new Error('Could not tell which tab this is in the browser; profile needs a Chromium tab');
|
|
60
|
+
// The browser's own session, not the tab's: a tab's session goes when the tab closes, and its trace
|
|
61
|
+
// with it. Chrome ends the trace if the session goes, so a REPL that dies leaves none running.
|
|
62
|
+
const session = await state.browser.newBrowserCDPSession();
|
|
63
|
+
try {
|
|
64
|
+
await session.send('Tracing.start', {
|
|
65
|
+
transferMode: 'ReturnAsStream', streamFormat: 'json', streamCompression: 'none', bufferUsageReportingInterval: 500,
|
|
66
|
+
traceConfig: { includedCategories: cpu ? [...CATEGORIES, ...CPU_CATEGORIES] : CATEGORIES, excludedCategories: ['*'] },
|
|
67
|
+
});
|
|
68
|
+
} catch (error) {
|
|
69
|
+
await session.detach().catch(() => {});
|
|
70
|
+
if (/already/i.test(error.message)) throw new Error('The browser is already recording a trace, and records one at a time: DevTools\' Performance panel, or another REPL\'s profile. Stop that one first.');
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
const profile = { page, session, frameId, cpu, owner: state.client, startedAt: Date.now(), url: page.url(), wake: null };
|
|
74
|
+
prof = profile;
|
|
75
|
+
await mark(page);
|
|
76
|
+
// A profile that stops by itself says why in its summary, and in a notice unless a timed profile on
|
|
77
|
+
// is waiting to print it.
|
|
78
|
+
const stop = (why, notice) => {
|
|
79
|
+
profile.stopped = why;
|
|
80
|
+
finishProfile().then(() => { if (!profile.wake) out.notice(`${notice}; profile shows its summary.`, page); }, () => {});
|
|
81
|
+
};
|
|
82
|
+
const onClose = () => {
|
|
83
|
+
if (prof === profile) stop('the tab closed.', 'The profiled tab closed, which ended the profile');
|
|
84
|
+
};
|
|
85
|
+
page.on('close', onClose);
|
|
86
|
+
// Chrome stops recording when its trace buffer fills, as a busy page does in under a minute; the
|
|
87
|
+
// profile stops with it, so it does not run on recording nothing.
|
|
88
|
+
const onUsage = ({ percentFull }) => {
|
|
89
|
+
if (prof !== profile || !(percentFull >= 0.99)) return;
|
|
90
|
+
const after = Math.round((Date.now() - profile.startedAt) / 1000);
|
|
91
|
+
stop(`Chrome's trace buffer filled after ${after}s. Profile a shorter stretch to have all of it.`, `Chrome's trace buffer filled, which stopped the profile after ${after}s`);
|
|
92
|
+
};
|
|
93
|
+
session.on('Tracing.bufferUsage', onUsage);
|
|
94
|
+
profile.unlisten = () => { page.off('close', onClose); session.off('Tracing.bufferUsage', onUsage); };
|
|
95
|
+
profile.cap = setTimeout(() => {
|
|
96
|
+
if (prof === profile) stop(`it reached ${MAX_SECONDS}s, the most a profile records.`, `The profile stopped at ${MAX_SECONDS}s, the most it records`);
|
|
97
|
+
}, MAX_SECONDS * 1000);
|
|
98
|
+
if (!seconds) {
|
|
99
|
+
if (quiet) return;
|
|
100
|
+
out.log(`Profiling the selected tab${cpu ? ' with a JS CPU profile' : ''}; profile off stops it and prints a summary.`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
out.log(`Profiling the selected tab for ${seconds}s...`);
|
|
104
|
+
await new Promise(resolve => {
|
|
105
|
+
const timer = setTimeout(resolve, seconds * 1000);
|
|
106
|
+
profile.wake = () => { clearTimeout(timer); resolve(); };
|
|
107
|
+
});
|
|
108
|
+
// profile off may have ended it first, and printed it; anything else that ended it (its tab closed,
|
|
109
|
+
// the buffer filled) leaves it to this command to print.
|
|
110
|
+
if (profile.stoppedByOff) return;
|
|
111
|
+
await finishProfile();
|
|
112
|
+
await profile.finished;
|
|
113
|
+
printProfile(last);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Ends the trace, reads it from the browser, and sums it up. Once, however many ask.
|
|
117
|
+
function finishProfile() {
|
|
118
|
+
const profile = prof;
|
|
119
|
+
if (!profile) return Promise.resolve();
|
|
120
|
+
prof = null;
|
|
121
|
+
clearTimeout(profile.cap);
|
|
122
|
+
profile.unlisten();
|
|
123
|
+
profile.finished = (async () => {
|
|
124
|
+
await mark(profile.page);
|
|
125
|
+
const endedAt = Date.now();
|
|
126
|
+
const raw = path.join(workDir(), `raw-${process.pid}-${endedAt}.json`);
|
|
127
|
+
try {
|
|
128
|
+
const complete = new Promise(resolve => profile.session.once('Tracing.tracingComplete', resolve));
|
|
129
|
+
await profile.session.send('Tracing.end');
|
|
130
|
+
const { stream } = await complete;
|
|
131
|
+
const file = fs.openSync(raw, 'w', 0o600);
|
|
132
|
+
try {
|
|
133
|
+
for (;;) {
|
|
134
|
+
const chunk = await profile.session.send('IO.read', { handle: stream, size: 1 << 20 });
|
|
135
|
+
fs.writeSync(file, chunk.base64Encoded ? Buffer.from(chunk.data, 'base64') : chunk.data);
|
|
136
|
+
if (chunk.eof) break;
|
|
137
|
+
}
|
|
138
|
+
} finally {
|
|
139
|
+
fs.closeSync(file);
|
|
140
|
+
await profile.session.send('IO.close', { handle: stream }).catch(() => {});
|
|
141
|
+
}
|
|
142
|
+
await profile.session.detach().catch(() => {});
|
|
143
|
+
forget(last);
|
|
144
|
+
const saveFile = path.join(workDir(), `tab-${process.pid}-${endedAt}.json`);
|
|
145
|
+
const summary = await summarize(raw, { frameId: profile.frameId, url: profile.url, saveFile, cpu: profile.cpu });
|
|
146
|
+
last = { ...profile, endedAt, summary, saveFile, drawing: 'drawing' in tabState(profile.page) };
|
|
147
|
+
} catch (error) {
|
|
148
|
+
await profile.session.detach().catch(() => {});
|
|
149
|
+
last = { ...profile, endedAt, summary: null, error: error.message };
|
|
150
|
+
} finally {
|
|
151
|
+
fs.rmSync(raw, { force: true });
|
|
152
|
+
if (profile.wake) profile.wake();
|
|
153
|
+
}
|
|
154
|
+
})();
|
|
155
|
+
finishing = profile.finished;
|
|
156
|
+
return profile.finished;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function secs(us) {
|
|
160
|
+
return `${(us / 1e6).toFixed(2)}s`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// A total in the unit of the items under it: ms under a second.
|
|
164
|
+
function total(us) {
|
|
165
|
+
return us < 1e6 ? ms(us) : secs(us);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function ms(us) {
|
|
169
|
+
return `${Math.round(us / 1000)}ms`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The tab's request with this URL nearest to when the trace saw it, for its number in requests.
|
|
173
|
+
function requestNumber(profile, request) {
|
|
174
|
+
const at = profile.startedAt + (request.sent - profile.summary.t0) / 1000;
|
|
175
|
+
const entries = (tabState(profile.page).log || []).filter(e => e.url === request.url);
|
|
176
|
+
const nearest = entries.sort((a, b) => Math.abs(a.t - at) - Math.abs(b.t - at))[0];
|
|
177
|
+
return nearest && Math.abs(nearest.t - at) < 2000 ? `#${nearest.id} ` : '';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function printProfile(profile, all = false) {
|
|
181
|
+
// So profile off does not show again what has been shown.
|
|
182
|
+
if (finishing && profile === last) finishing.shown = true;
|
|
183
|
+
if (profile.error) { out.log(`The profile from ${clock(profile.startedAt)} could not be read: ${profile.error}`); return; }
|
|
184
|
+
const s = profile.summary;
|
|
185
|
+
const lines = [];
|
|
186
|
+
if (!s) {
|
|
187
|
+
out.log(`Nothing from ${profile.url} was in the trace: its page did no work while it ran, or it closed first.`);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const at = ts => `+${((ts - s.t0) / 1e6).toFixed(2)}s`;
|
|
191
|
+
lines.push(`Profiled ${profile.url} for ${secs(s.duration)}, from ${clock(profile.startedAt)}${profile.cpu ? ', with a JS CPU profile' : ''}`);
|
|
192
|
+
if (profile.stopped) lines.push(`Stopped early: ${profile.stopped}`);
|
|
193
|
+
const split = Object.entries(s.buckets).filter(([, us]) => us >= 5000).map(([b, us]) => `${b} ${secs(us)}`);
|
|
194
|
+
lines.push(`Main thread busy ${secs(s.busy)} (${s.duration ? Math.round((100 * s.busy) / s.duration) : 0}%)${split.length ? `: ${split.join(', ')}` : ''}`);
|
|
195
|
+
if (s.marks.length) lines.push(`Page: ${s.marks.map(([name, ts]) => `${name} ${at(ts)}`).join(', ')}`);
|
|
196
|
+
const longTotal = s.longTasks.reduce((sum, t) => sum + t.dur, 0);
|
|
197
|
+
lines.push(`Long tasks (over ${LONG_TASK / 1000}ms): ${s.longTasks.length ? `${s.longTasks.length}, ${total(longTotal)} in all` : 'none'}`);
|
|
198
|
+
const longest = all ? s.longTasks : [...s.longTasks].sort((a, b) => b.dur - a.dur).slice(0, SHOWN).sort((a, b) => a.ts - b.ts);
|
|
199
|
+
for (const t of longest) {
|
|
200
|
+
const after = t.after ? ` (after ${requestNumber(profile, t.after)}${t.after.method} ${t.after.url}, ${Math.round(t.after.size / 1024)}KB, finished ${at(t.after.finished)})` : '';
|
|
201
|
+
const hot = t.hot ? `; most in ${t.hot.where} (${ms(t.hot.us)})` : '';
|
|
202
|
+
lines.push(` ${at(t.ts).padStart(8)} ${ms(t.dur).padStart(6)} ${[t.kind, t.trigger, t.script].filter(Boolean).join(', ')}${hot}${after}`);
|
|
203
|
+
}
|
|
204
|
+
if (longest.length < s.longTasks.length) lines.push(` (the ${longest.length} longest; profile --all lists them all)`);
|
|
205
|
+
if (s.forced.length) {
|
|
206
|
+
const count = s.forced.reduce((sum, f) => sum + f.count, 0);
|
|
207
|
+
lines.push(`Forced style and layout (work a script waited for, reading a size or style): ${count}, ${ms(s.forced.reduce((sum, f) => sum + f.dur, 0))} in all`);
|
|
208
|
+
for (const f of all ? s.forced : s.forced.slice(0, SHOWN)) lines.push(` ${`${f.count}x`.padStart(5)} ${ms(f.dur).padStart(6)} ${f.where}`);
|
|
209
|
+
}
|
|
210
|
+
if (s.steps.length) {
|
|
211
|
+
lines.push('Steps (input that reached the page, and what it did until 100ms after each step\'s last event):');
|
|
212
|
+
const steps = all ? s.steps : s.steps.slice(0, SHOWN * 2);
|
|
213
|
+
for (const step of steps) {
|
|
214
|
+
const span = step.latency != null
|
|
215
|
+
? `${Math.round(step.latency)}ms to next paint (input delay ${Math.round(step.delay)}ms, handlers ${Math.round(step.handlers)}ms, presentation ${Math.round(step.presentation)}ms)`
|
|
216
|
+
: `${secs(step.end - step.start)}, ${step.events} event${step.events === 1 ? '' : 's'}`;
|
|
217
|
+
const frames = step.frames ? `, frames ${step.frames} drawn, ${step.dropped} dropped` : '';
|
|
218
|
+
lines.push(` ${at(step.start).padStart(8)} ${step.kind} ${span}; busy ${ms(step.busy)}${step.longest ? `, longest task ${ms(step.longest)}` : ''}${frames}`);
|
|
219
|
+
}
|
|
220
|
+
if (steps.length < s.steps.length) lines.push(` (the first ${steps.length} of ${s.steps.length}; profile --all lists them all)`);
|
|
221
|
+
if (profile.drawing && s.steps.some(step => step.frames)) {
|
|
222
|
+
lines.push(' Frames are counted as for a tab in front: pw-repl keeps every tab it uses drawing as if it were. Behind another tab, as the user would have it, Chrome draws far less.');
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (s.cpu) {
|
|
226
|
+
lines.push(s.cpu.length ? 'JS CPU profile, by own time:' : 'JS CPU profile: no JS ran');
|
|
227
|
+
for (const f of all ? s.cpu : s.cpu.slice(0, SHOWN)) lines.push(` ${ms(f.us).padStart(7)} ${f.where}`);
|
|
228
|
+
}
|
|
229
|
+
out.printOutput(lines.join('\n'), true);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const commands = {
|
|
233
|
+
async profile(args, all) {
|
|
234
|
+
const [sub, ...rest] = (args || '').trim().split(/\s+/).filter(Boolean);
|
|
235
|
+
if (sub === 'on') return startProfile(rest);
|
|
236
|
+
if (sub === 'off' && !rest.length) {
|
|
237
|
+
// One that ended by itself is printed once it has been read.
|
|
238
|
+
const ended = !prof && finishing && !finishing.shown;
|
|
239
|
+
if (!prof && !ended) { out.log('Not profiling.'); return; }
|
|
240
|
+
if (ended) out.log('The profile had already stopped:');
|
|
241
|
+
if (prof) prof.stoppedByOff = true;
|
|
242
|
+
await finishProfile();
|
|
243
|
+
await finishing;
|
|
244
|
+
printProfile(last, all);
|
|
245
|
+
out.log(hints([['profile save <file>', 'write the tab\'s trace, for DevTools\' Performance panel']]));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
await finishing;
|
|
249
|
+
if (sub === 'save') return saveProfile(rest.join(' '));
|
|
250
|
+
if (sub === 'reload' && !rest.length) return profileReload(all);
|
|
251
|
+
if (sub) throw new Error(`Usage: profile on [seconds] [--cpu] | off | save <file> | reload`);
|
|
252
|
+
if (prof) {
|
|
253
|
+
out.log(`Profiling ${prof.page.url()} since ${clock(prof.startedAt)}${prof.owner ? `, for ${prof.owner}` : ''}`);
|
|
254
|
+
out.log(hints([['profile off', 'stop and print a summary']]));
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (last) {
|
|
258
|
+
out.log(`Not profiling. The last profile, from ${clock(last.startedAt)}:`);
|
|
259
|
+
printProfile(last, all);
|
|
260
|
+
} else {
|
|
261
|
+
out.log('Not profiling.');
|
|
262
|
+
}
|
|
263
|
+
out.log(hints([
|
|
264
|
+
['profile on [seconds]', 'record what the selected tab\'s main thread does'],
|
|
265
|
+
['profile reload', 'profile the selected tab\'s page as it loads'],
|
|
266
|
+
]));
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
async function profileReload(all) {
|
|
271
|
+
if (prof) throw new Error(`Already profiling ${prof.page.url()}; profile off stops it`);
|
|
272
|
+
if (!state.page || state.page.isClosed()) throw new Error(NO_TAB);
|
|
273
|
+
const page = state.page;
|
|
274
|
+
await startProfile([], true);
|
|
275
|
+
let note = null;
|
|
276
|
+
try {
|
|
277
|
+
await page.reload({ waitUntil: 'load' });
|
|
278
|
+
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => { note = 'Its network was still busy 10s after load; the profile stops there.'; });
|
|
279
|
+
// Its largest paint and the work after load land in the next frames.
|
|
280
|
+
await page.waitForTimeout(500);
|
|
281
|
+
} finally {
|
|
282
|
+
await finishProfile();
|
|
283
|
+
}
|
|
284
|
+
printProfile(last, all);
|
|
285
|
+
if (note) out.log(note);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function saveProfile(file) {
|
|
289
|
+
if (!file) throw new Error('Usage: profile save <file>, e.g. profile save load.json');
|
|
290
|
+
if (prof) throw new Error('Still profiling; profile off stops it first');
|
|
291
|
+
if (!last?.saveFile || !fs.existsSync(last.saveFile)) throw new Error('No profile to save: profile on records one first');
|
|
292
|
+
if (fs.existsSync(file)) throw new Error(`${file} exists already; name another file`);
|
|
293
|
+
if (!fs.existsSync(path.dirname(path.resolve(file)))) throw new Error(`No folder ${path.dirname(path.resolve(file))} to save ${path.basename(file)} in`);
|
|
294
|
+
fs.copyFileSync(last.saveFile, file, fs.constants.COPYFILE_EXCL);
|
|
295
|
+
const kb = Math.round(fs.statSync(file).size / 1024);
|
|
296
|
+
out.log(`Saved the profile of ${last.url} to ${file} (${kb} KB): the tab's events only. Chrome DevTools' Performance panel (Load profile) and ui.perfetto.dev open it.`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// A profile must not outlive the REPL: Chrome traces one at a time, for the whole browser.
|
|
300
|
+
onShutdown(async () => {
|
|
301
|
+
if (prof) {
|
|
302
|
+
const { session } = prof;
|
|
303
|
+
prof = null;
|
|
304
|
+
await session.send('Tracing.end').catch(() => {});
|
|
305
|
+
await session.detach().catch(() => {});
|
|
306
|
+
}
|
|
307
|
+
if (dir) fs.rmSync(dir, { recursive: true, force: true });
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
module.exports = { commands, currentProfile, finishProfile };
|
package/lib/record.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
// Recording a tab's page to a video, from Chrome's screencast of it through ffmpeg.
|
|
2
2
|
const { spawn, execFileSync } = require('child_process');
|
|
3
3
|
const fs = require('fs');
|
|
4
|
-
const os = require('os');
|
|
5
4
|
const path = require('path');
|
|
6
5
|
const { state, onShutdown } = require('./state');
|
|
7
6
|
const out = require('./output');
|
|
8
7
|
const { tabState } = require('./tabstate');
|
|
9
|
-
const { clock, NO_TAB, STOPPED_RECORDING, ENDED_UNSEEN } = require('./util');
|
|
8
|
+
const { clock, NO_TAB, STOPPED_RECORDING, ENDED_UNSEEN, playwrightBrowsersDir } = require('./util');
|
|
10
9
|
const { shellWords, splitSelector } = require('./syntax');
|
|
11
10
|
const { reapplyMetrics } = require('./emulation');
|
|
12
11
|
|
|
@@ -38,7 +37,7 @@ const FORMAT_NAMES = { webm: 'WebM (VP8)', mp4: 'MP4 (H.264)' };
|
|
|
38
37
|
// Playwright's own (wherever PLAYWRIGHT_BROWSERS_PATH puts it), then one on the PATH.
|
|
39
38
|
function ffmpegCandidates() {
|
|
40
39
|
const found = [];
|
|
41
|
-
const dir =
|
|
40
|
+
const dir = playwrightBrowsersDir();
|
|
42
41
|
let names = [];
|
|
43
42
|
try { names = fs.readdirSync(dir).filter(n => /^ffmpeg-\d+$/.test(n)).sort().reverse(); } catch {}
|
|
44
43
|
for (const name of names) {
|
package/lib/runner.js
CHANGED
|
@@ -33,10 +33,10 @@ const READ_ONLY = new Set(['info', 'listeners', 'text', 'html', 'attrs', 'count'
|
|
|
33
33
|
'snapshot', 'screenshot', 'wait', 'sleep', 'requests', 'body', 'console', 'cookies', 'storage', 'capture', 'help']);
|
|
34
34
|
|
|
35
35
|
// Commands that work with no tab selected.
|
|
36
|
-
const NO_TAB_NEEDED = new Set(['tab', 'modes', 'capture', 'record', 'dialog', 'help', 'quit', 'tab-select', 'close']);
|
|
36
|
+
const NO_TAB_NEEDED = new Set(['tab', 'modes', 'capture', 'profile', 'record', 'dialog', 'help', 'quit', 'tab-select', 'close']);
|
|
37
37
|
|
|
38
38
|
// Commands that accept --all, right after the command or at the end, to lift the output limit.
|
|
39
|
-
const INSPECTION = ['info', 'listeners', 'text', 'html', 'attrs', 'links', 'inputs', 'eval', 'cdp', 'cookies', 'storage', 'capture', 'body', 'console', 'snapshot', 'watch'];
|
|
39
|
+
const INSPECTION = ['info', 'listeners', 'text', 'html', 'attrs', 'links', 'inputs', 'eval', 'cdp', 'cookies', 'storage', 'capture', 'profile', 'body', 'console', 'snapshot', 'watch'];
|
|
40
40
|
|
|
41
41
|
let queue = Promise.resolve();
|
|
42
42
|
// The server command running now, so a quit at the prompt can answer it.
|
package/lib/send.js
CHANGED
|
@@ -114,7 +114,8 @@ function commandLine(words) {
|
|
|
114
114
|
return words[n - 1] === '--filename' || !/^(?:\d+|-.*)$/.test(w) ? resolve(w) : w;
|
|
115
115
|
});
|
|
116
116
|
}
|
|
117
|
-
// watch save
|
|
117
|
+
// watch save and profile save write their files.
|
|
118
|
+
if (words[0] === 'profile' && words[1] === 'save' && words.length > 2) resolved = ['profile', 'save', resolve(words.slice(2).join(' '))];
|
|
118
119
|
if (words[0] === 'watch' && words[1] === 'save' && words.length > 2) resolved = ['watch', 'save', resolve(words.slice(2).join(' '))];
|
|
119
120
|
if (words[0] === 'screenshot') {
|
|
120
121
|
resolved = words.map((w, n) => (w.startsWith('--filename=') && w.length > 11 ? `--filename=${resolve(w.slice(11))}` : words[n - 1] === '--filename' ? resolve(w) : w));
|
|
@@ -131,7 +132,7 @@ function commandLine(words) {
|
|
|
131
132
|
|
|
132
133
|
// A file of commands, one per line, run in turn until one fails. A recording it started and did not
|
|
133
134
|
// stop is still on: the next take would fail to start, so it says so.
|
|
134
|
-
const PATHS = /^(?:upload|record|video-start|video-stop|screenshot|watch)$/;
|
|
135
|
+
const PATHS = /^(?:upload|record|video-start|video-stop|screenshot|watch|profile)$/;
|
|
135
136
|
// The values of the {{ PW_NAME }} variables in a command, from send's environment: { vars }, null with
|
|
136
137
|
// none, or { error } for one that is not set or has a line break.
|
|
137
138
|
function variablesFor(command) {
|
package/lib/tabs.js
CHANGED
package/lib/trace.js
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
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
|
+
|
|
14
|
+
const BUCKETS = ['scripting', 'style/layout', 'paint', 'GC', 'other'];
|
|
15
|
+
|
|
16
|
+
// DevTools' categories, by event name; anything else counts as the task's own time, other.
|
|
17
|
+
const SCRIPTING = new Set(['FunctionCall', 'EvaluateScript', 'v8.evaluateModule', 'v8.compile', 'v8.compileModule',
|
|
18
|
+
'V8.CompileCode', 'v8.produceCache', 'v8.run', 'TimerFire', 'EventDispatch', 'FireAnimationFrame', 'FireIdleCallback',
|
|
19
|
+
'RunMicrotasks', 'XHRReadyStateChange', 'XHRLoad', 'v8.callFunction', 'v8.parseOnBackground', 'CppGC.IncrementalSweep']);
|
|
20
|
+
const LAYOUT = new Set(['UpdateLayoutTree', 'RecalculateStyles', 'Layout', 'HitTest', 'IntersectionObserverController::computeIntersections']);
|
|
21
|
+
const PAINT = new Set(['Paint', 'PaintImage', 'PrePaint', 'Layerize', 'UpdateLayer', 'UpdateLayerTree', 'CompositeLayers',
|
|
22
|
+
'Commit', 'Decode Image', 'Draw LazyPixelRef', 'Decode LazyPixelRef', 'ImageDecodeTask']);
|
|
23
|
+
const GC = new Set(['MinorGC', 'MajorGC', 'BlinkGC.AtomicPhase', 'V8.GCScavenger', 'V8.GCFinalizeMC', 'V8.GCIncrementalMarking',
|
|
24
|
+
'V8.GC_MC_BACKGROUND_EVACUATE_COPY', 'BlinkGC.ConcurrentMarking', 'CppGC.AtomicMark']);
|
|
25
|
+
|
|
26
|
+
function bucketOf(name) {
|
|
27
|
+
if (SCRIPTING.has(name)) return 'scripting';
|
|
28
|
+
if (LAYOUT.has(name)) return 'style/layout';
|
|
29
|
+
if (PAINT.has(name)) return 'paint';
|
|
30
|
+
if (GC.has(name) || /^(?:V8|CppGC|BlinkGC)\.GC|GC$/.test(name)) return 'GC';
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// What set a task off, from the first event in it that says.
|
|
35
|
+
const TRIGGERS = { TimerFire: 'timer', FireAnimationFrame: 'animation frame', FireIdleCallback: 'idle callback',
|
|
36
|
+
ParseHTML: 'parsing HTML', XHRLoad: 'XHR load', XHRReadyStateChange: 'XHR', RunMicrotasks: 'promise callbacks' };
|
|
37
|
+
|
|
38
|
+
const RUN_TASK = new Set(['RunTask', 'ThreadControllerImpl::RunTask']);
|
|
39
|
+
|
|
40
|
+
// The main thread's events the summary times by name alone, read without parsing them: most of a busy
|
|
41
|
+
// trace (a style recalculation and a paint per element changed).
|
|
42
|
+
function timedOnly(name) {
|
|
43
|
+
return !DETAILED.has(name) && (RUN_TASK.has(name) || bucketOf(name) || TRIGGERS[name] || name === 'ParseHTML');
|
|
44
|
+
}
|
|
45
|
+
const DETAILED = new Set(['FunctionCall', 'EvaluateScript', 'EventDispatch', 'Layout', 'UpdateLayoutTree']);
|
|
46
|
+
// The rest that collect reads.
|
|
47
|
+
const PARSED = new Set([...DETAILED, 'EventTiming', 'PipelineReporter', 'ResourceSendRequest', 'ResourceFinish', 'MarkDOMContent',
|
|
48
|
+
'MarkLoad', 'navigationStart', 'firstContentfulPaint', 'largestContentfulPaint::Candidate', 'Profile', 'ProfileChunk']);
|
|
49
|
+
|
|
50
|
+
// The trace's events, one at a time: Chrome writes each on a line of its own. A trace runs to hundreds
|
|
51
|
+
// of megabytes, mostly other processes' events: wanted(line) passes over a line before it is parsed.
|
|
52
|
+
async function eachEvent(file, fn, wanted = null) {
|
|
53
|
+
await eachLine(file, line => {
|
|
54
|
+
if (wanted && !wanted(line)) return;
|
|
55
|
+
let event;
|
|
56
|
+
try { event = JSON.parse(line); } catch { return; }
|
|
57
|
+
fn(event, line);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function eachLine(file, fn) {
|
|
62
|
+
const lines = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
|
|
63
|
+
for await (let line of lines) {
|
|
64
|
+
if (!line.startsWith('{"') || line.startsWith('{"traceEvents"')) continue;
|
|
65
|
+
if (line.endsWith(',')) line = line.slice(0, -1);
|
|
66
|
+
// The last event shares its line with the end of the list, and the start of the trace's metadata.
|
|
67
|
+
else line = line.replace(/\](?:,"metadata":.*|\})$/, '');
|
|
68
|
+
fn(line);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function nameOf(line) {
|
|
73
|
+
const at = line.lastIndexOf('"name":"');
|
|
74
|
+
return at < 0 ? '' : line.slice(at + 8, line.indexOf('"', at + 8));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A number field of an event, read from its line without parsing it. Chrome writes the keys in order,
|
|
78
|
+
// args first, so the last one by that name is the event's own.
|
|
79
|
+
function field(line, name) {
|
|
80
|
+
const at = line.lastIndexOf(`"${name}":`);
|
|
81
|
+
return at < 0 ? NaN : parseFloat(line.slice(at + name.length + 3));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function frameOf(e) {
|
|
85
|
+
return e.args?.data?.frame || e.args?.frame || null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function shortUrl(url) {
|
|
89
|
+
if (!url) return '';
|
|
90
|
+
try {
|
|
91
|
+
const u = new URL(url);
|
|
92
|
+
if (u.protocol === 'data:') return 'data:…';
|
|
93
|
+
const file = u.pathname.split('/').filter(Boolean).pop();
|
|
94
|
+
return file || u.host;
|
|
95
|
+
} catch {
|
|
96
|
+
return url.slice(0, 60);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// url:line function, as DevTools names a function. Lines are 1-based in the trace's events and stacks,
|
|
101
|
+
// 0-based in its JS samples' call frames.
|
|
102
|
+
function where(frame, zeroBased = false) {
|
|
103
|
+
if (!frame) return '';
|
|
104
|
+
const line = frame.lineNumber != null && frame.lineNumber >= 0 ? `:${frame.lineNumber + (zeroBased ? 1 : 0)}` : '';
|
|
105
|
+
const fn = frame.functionName || '(anonymous)';
|
|
106
|
+
return `${shortUrl(frame.url)}${line} ${fn}`.trim();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Reads the trace twice: once to find the tab's renderer processes and their threads, then to sum
|
|
110
|
+
// up the tab's events, writing them (with what DevTools needs to open them) to saveFile.
|
|
111
|
+
async function summarize(file, { frameId, url, saveFile, cpu = false }) {
|
|
112
|
+
const pids = new Set();
|
|
113
|
+
const threads = new Map();
|
|
114
|
+
let started = null;
|
|
115
|
+
let firstTs = Infinity;
|
|
116
|
+
let lastTs = 0;
|
|
117
|
+
const firstSeen = new Map();
|
|
118
|
+
await eachEvent(file, e => {
|
|
119
|
+
if (e.name === 'thread_name' && e.ph === 'M') threads.set(`${e.pid}:${e.tid}`, e.args?.name);
|
|
120
|
+
if (e.name === 'TracingStartedInBrowser') started = e;
|
|
121
|
+
if (frameOf(e) === frameId && e.pid) pids.add(e.pid);
|
|
122
|
+
}, line => {
|
|
123
|
+
const ts = field(line, 'ts');
|
|
124
|
+
if (ts && !line.includes('"ph":"M"')) {
|
|
125
|
+
const pid = field(line, 'pid');
|
|
126
|
+
if (!firstSeen.has(pid)) firstSeen.set(pid, ts);
|
|
127
|
+
firstTs = Math.min(firstTs, ts);
|
|
128
|
+
lastTs = Math.max(lastTs, ts + (field(line, 'dur') || 0));
|
|
129
|
+
}
|
|
130
|
+
return line.includes('"thread_name"') || line.includes('TracingStartedInBrowser') || line.includes(frameId);
|
|
131
|
+
});
|
|
132
|
+
const mainOf = new Map();
|
|
133
|
+
const compositorOf = new Map();
|
|
134
|
+
for (const [key, name] of threads) {
|
|
135
|
+
const [pid, tid] = key.split(':').map(Number);
|
|
136
|
+
if (!pids.has(pid)) continue;
|
|
137
|
+
if (name === 'CrRendererMain') mainOf.set(pid, tid);
|
|
138
|
+
if (name === 'Compositor') compositorOf.set(pid, tid);
|
|
139
|
+
}
|
|
140
|
+
// The browser process names the tab's frame too (its navigations): only renderers are the tab's.
|
|
141
|
+
for (const pid of pids) if (!mainOf.has(pid)) pids.delete(pid);
|
|
142
|
+
const t0 = started?.ts ?? firstTs;
|
|
143
|
+
const tracks = { main: new Events(), timings: new Map(), frames: [], requests: new Map(), marks: {}, profiles: new Map() };
|
|
144
|
+
// Written as it is read, in batches: a write stream would hold what the disk has not taken yet.
|
|
145
|
+
const save = saveFile ? { fd: fs.openSync(saveFile, 'w', 0o600), started: false, chunk: [], size: 0 } : null;
|
|
146
|
+
const flush = () => { fs.writeSync(save.fd, save.chunk.join('')); save.chunk = []; save.size = 0; };
|
|
147
|
+
const write = line => {
|
|
148
|
+
save.chunk.push(`${save.started ? ',\n' : '{"traceEvents":[\n'}${line}`);
|
|
149
|
+
save.started = true;
|
|
150
|
+
save.size += line.length;
|
|
151
|
+
if (save.size > 1 << 20) flush();
|
|
152
|
+
};
|
|
153
|
+
// DevTools finds the tab's renderer from these, which a trace of the whole browser leaves out.
|
|
154
|
+
if (save && started) {
|
|
155
|
+
const [first, ...later] = [...pids].map(pid => [pid, firstSeen.get(pid)]).sort((a, b) => a[1] - b[1]);
|
|
156
|
+
if (first) {
|
|
157
|
+
write(JSON.stringify({ ...started, args: { data: { frameTreeNodeId: 0, persistentIds: true, frames: [{ frame: frameId, url, name: '', processId: first[0] }] } } }));
|
|
158
|
+
for (const [pid, ts] of later) {
|
|
159
|
+
write(JSON.stringify({ ...started, name: 'FrameCommittedInBrowser', ts, args: { data: { frame: frameId, url, name: '', processId: pid } } }));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
await eachLine(file, line => {
|
|
165
|
+
const pid = field(line, 'pid');
|
|
166
|
+
if (!pids.has(pid)) return;
|
|
167
|
+
if (save) write(line);
|
|
168
|
+
const name = nameOf(line);
|
|
169
|
+
const main = mainOf.get(pid) === field(line, 'tid');
|
|
170
|
+
if (main && timedOnly(name)) {
|
|
171
|
+
if (line.includes('"ph":"X"')) tracks.main.push(name, field(line, 'ts'), field(line, 'dur') || 0, null);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (!PARSED.has(name)) return;
|
|
175
|
+
let e;
|
|
176
|
+
try { e = JSON.parse(line); } catch { return; }
|
|
177
|
+
collect(e, tracks, { frameId, main, compositor: compositorOf.get(pid) === e.tid });
|
|
178
|
+
});
|
|
179
|
+
if (save) {
|
|
180
|
+
if (!save.started) save.chunk.push('{"traceEvents":[');
|
|
181
|
+
save.chunk.push('\n]}\n');
|
|
182
|
+
flush();
|
|
183
|
+
}
|
|
184
|
+
} finally {
|
|
185
|
+
if (save) fs.closeSync(save.fd);
|
|
186
|
+
}
|
|
187
|
+
if (!pids.size) return null;
|
|
188
|
+
return analyse(tracks, t0, Math.max(lastTs, t0), { cpu });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// The main thread's events, kept compact: a busy page makes hundreds of thousands in a minute (a style
|
|
192
|
+
// recalculation and a paint per element it changes). Their times and names in typed arrays; the few
|
|
193
|
+
// details the summary names (a script, an event's type, a forced layout's stack) beside them.
|
|
194
|
+
class Events {
|
|
195
|
+
constructor() {
|
|
196
|
+
this.n = 0;
|
|
197
|
+
this.ts = new Float64Array(1024);
|
|
198
|
+
this.dur = new Float64Array(1024);
|
|
199
|
+
this.code = new Uint8Array(1024);
|
|
200
|
+
this.names = [];
|
|
201
|
+
this.codes = new Map();
|
|
202
|
+
this.details = new Map();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
push(name, ts, dur, detail) {
|
|
206
|
+
if (this.n === this.ts.length) {
|
|
207
|
+
for (const key of ['ts', 'dur', 'code']) {
|
|
208
|
+
const grown = new this[key].constructor(this.n * 2);
|
|
209
|
+
grown.set(this[key]);
|
|
210
|
+
this[key] = grown;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (!this.codes.has(name)) { this.codes.set(name, this.names.length); this.names.push(name); }
|
|
214
|
+
this.ts[this.n] = ts;
|
|
215
|
+
this.dur[this.n] = dur;
|
|
216
|
+
this.code[this.n] = this.codes.get(name);
|
|
217
|
+
if (detail) this.details.set(this.n, detail);
|
|
218
|
+
this.n += 1;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
name(i) {
|
|
222
|
+
return this.names[this.code[i]];
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function collect(e, tracks, { frameId, main, compositor }) {
|
|
227
|
+
const data = e.args?.data;
|
|
228
|
+
if (main && e.ph === 'X' && (RUN_TASK.has(e.name) || bucketOf(e.name) || TRIGGERS[e.name] || e.name === 'ParseHTML')) {
|
|
229
|
+
let detail = null;
|
|
230
|
+
if (e.name === 'FunctionCall' || e.name === 'EvaluateScript') detail = { frame: { url: data?.url, lineNumber: data?.lineNumber, functionName: data?.functionName } };
|
|
231
|
+
if (e.name === 'EventDispatch') detail = { type: data?.type };
|
|
232
|
+
// A style recalculation or layout with the page's script on the stack: one the script waited for (pw-repl's
|
|
233
|
+
// own scripts have no URL).
|
|
234
|
+
if ((e.name === 'Layout' || e.name === 'UpdateLayoutTree') && e.args?.beginData?.stackTrace?.[0]?.url) detail = { stack: e.args.beginData.stackTrace[0] };
|
|
235
|
+
tracks.main.push(e.name, e.ts, e.dur || 0, detail);
|
|
236
|
+
}
|
|
237
|
+
if (e.name === 'EventTiming' && e.ph === 'b' && data?.interactionId && frameOf(e) === frameId) {
|
|
238
|
+
const list = tracks.timings.get(data.interactionId) || [];
|
|
239
|
+
list.push({ ts: e.ts, ...data });
|
|
240
|
+
tracks.timings.set(data.interactionId, list);
|
|
241
|
+
}
|
|
242
|
+
if (compositor && e.name === 'PipelineReporter' && e.ph === 'b') {
|
|
243
|
+
const state = e.args?.frame_reporter?.state;
|
|
244
|
+
if (state && state !== 'STATE_NO_UPDATE_DESIRED') tracks.frames.push({ ts: e.ts, dropped: state === 'STATE_DROPPED' });
|
|
245
|
+
}
|
|
246
|
+
if (e.name === 'ResourceSendRequest' && data?.requestId) tracks.requests.set(data.requestId, { url: data.url, method: data.requestMethod, sent: e.ts });
|
|
247
|
+
if (e.name === 'ResourceFinish' && data?.requestId) {
|
|
248
|
+
const request = tracks.requests.get(data.requestId);
|
|
249
|
+
// One from the cache has no encoded length.
|
|
250
|
+
if (request) Object.assign(request, { finished: e.ts, size: Math.max(data.encodedDataLength || 0, data.decodedBodyLength || 0), failed: data.didFail });
|
|
251
|
+
}
|
|
252
|
+
const isMain = data?.isOutermostMainFrame ?? data?.isMainFrame;
|
|
253
|
+
if (e.name === 'MarkDOMContent' && isMain && frameOf(e) === frameId) tracks.marks.DOMContentLoaded ??= e.ts;
|
|
254
|
+
if (e.name === 'MarkLoad' && isMain && frameOf(e) === frameId) tracks.marks.load ??= e.ts;
|
|
255
|
+
if (e.name === 'navigationStart' && frameOf(e) === frameId) tracks.marks.navigation ??= e.ts;
|
|
256
|
+
if (e.name === 'firstContentfulPaint' && frameOf(e) === frameId) tracks.marks['first contentful paint'] ??= e.ts;
|
|
257
|
+
if (e.name === 'largestContentfulPaint::Candidate' && frameOf(e) === frameId) tracks.marks['largest contentful paint'] = e.ts;
|
|
258
|
+
// The JS sampling profile of a thread: its call tree and samples, in chunks after a Profile event
|
|
259
|
+
// on the thread it samples.
|
|
260
|
+
if ((e.name === 'Profile' || e.name === 'ProfileChunk') && e.id) {
|
|
261
|
+
const profile = tracks.profiles.get(`${e.pid}:${e.id}`) || { nodes: new Map(), samples: [], deltas: [] };
|
|
262
|
+
if (e.name === 'Profile') Object.assign(profile, { main, startTime: data?.startTime });
|
|
263
|
+
const cpu = data?.cpuProfile;
|
|
264
|
+
for (const node of cpu?.nodes || []) profile.nodes.set(node.id, { ...node.callFrame, parent: node.parent });
|
|
265
|
+
for (const id of cpu?.samples || []) profile.samples.push(id);
|
|
266
|
+
for (const delta of data?.timeDeltas || []) profile.deltas.push(delta);
|
|
267
|
+
tracks.profiles.set(`${e.pid}:${e.id}`, profile);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// The main thread's events nested as they ran: the order they started in, each one's own time (less
|
|
272
|
+
// its children's), and its top-level task. A task's events follow it in that order, up to its end.
|
|
273
|
+
function nest(events) {
|
|
274
|
+
const { n, ts, dur } = events;
|
|
275
|
+
const order = new Uint32Array(n);
|
|
276
|
+
for (let i = 0; i < n; i++) order[i] = i;
|
|
277
|
+
order.sort((a, b) => ts[a] - ts[b] || dur[b] - dur[a]);
|
|
278
|
+
const self = Float64Array.from(dur.subarray(0, n));
|
|
279
|
+
const taskOf = new Int32Array(n).fill(-1);
|
|
280
|
+
const tasks = [];
|
|
281
|
+
const stack = [];
|
|
282
|
+
for (let p = 0; p < n; p++) {
|
|
283
|
+
const i = order[p];
|
|
284
|
+
while (stack.length && ts[stack[stack.length - 1]] + dur[stack[stack.length - 1]] <= ts[i]) stack.pop();
|
|
285
|
+
const parent = stack[stack.length - 1];
|
|
286
|
+
if (parent !== undefined) { self[parent] -= Math.min(dur[i], ts[parent] + dur[parent] - ts[i]); taskOf[i] = taskOf[parent]; }
|
|
287
|
+
else if (RUN_TASK.has(events.name(i))) { taskOf[i] = i; tasks.push({ i, p, ts: ts[i], dur: dur[i] }); }
|
|
288
|
+
stack.push(i);
|
|
289
|
+
}
|
|
290
|
+
for (const task of tasks) {
|
|
291
|
+
let p = task.p + 1;
|
|
292
|
+
while (p < n && ts[order[p]] < task.ts + task.dur) p++;
|
|
293
|
+
task.end = p;
|
|
294
|
+
}
|
|
295
|
+
return { order, self, taskOf, tasks };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// The main thread's JS samples in time order: when each was taken, how long it stands for, and the
|
|
299
|
+
// page's function it was in (an index into keys), -1 for none.
|
|
300
|
+
function mainSamples(profiles) {
|
|
301
|
+
const keys = [];
|
|
302
|
+
const keyIndex = new Map();
|
|
303
|
+
const taken = [];
|
|
304
|
+
for (const { main, startTime, nodes, samples: ids, deltas } of profiles.values()) {
|
|
305
|
+
if (!main || startTime == null) continue;
|
|
306
|
+
const scriptOf = new Map();
|
|
307
|
+
let t = startTime;
|
|
308
|
+
for (let i = 0; i < ids.length; i++) {
|
|
309
|
+
t += deltas[i] ?? 0;
|
|
310
|
+
if (!scriptOf.has(ids[i])) {
|
|
311
|
+
const frame = scriptFrame(nodes, nodes.get(ids[i]));
|
|
312
|
+
const key = frame ? where(frame, true) : null;
|
|
313
|
+
if (key !== null && !keyIndex.has(key)) { keyIndex.set(key, keys.length); keys.push(key); }
|
|
314
|
+
scriptOf.set(ids[i], key === null ? -1 : keyIndex.get(key));
|
|
315
|
+
}
|
|
316
|
+
const frame = nodes.get(ids[i]);
|
|
317
|
+
taken.push(t, scriptOf.get(ids[i]), cpuKey(frame, scriptFrame(nodes, frame), keyIndex, keys));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const count = taken.length / 3;
|
|
321
|
+
const order = new Uint32Array(count);
|
|
322
|
+
for (let i = 0; i < count; i++) order[i] = i;
|
|
323
|
+
order.sort((a, b) => taken[a * 3] - taken[b * 3]);
|
|
324
|
+
const samples = { t: new Float64Array(count), us: new Float64Array(count), script: new Int32Array(count), own: new Int32Array(count), keys };
|
|
325
|
+
for (let k = 0; k < count; k++) {
|
|
326
|
+
samples.t[k] = taken[order[k] * 3];
|
|
327
|
+
samples.script[k] = taken[order[k] * 3 + 1];
|
|
328
|
+
samples.own[k] = taken[order[k] * 3 + 2];
|
|
329
|
+
}
|
|
330
|
+
// Samples can come out of order (a negative delta); each stands for the time until the next one,
|
|
331
|
+
// but no more than a few intervals, so one before a pause does not take the pause.
|
|
332
|
+
for (let k = 0; k < count; k++) samples.us[k] = k + 1 < count ? Math.min(samples.t[k + 1] - samples.t[k], 2000) : 0;
|
|
333
|
+
return samples;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// The function a sample itself was in, for --cpu's list: a built-in with the page's function that
|
|
337
|
+
// called it; -1 for idle, and for code not the page's (pw-repl's own, run in the page, has no URL).
|
|
338
|
+
function cpuKey(frame, script, keyIndex, keys) {
|
|
339
|
+
if (!frame || /^\((?:idle|program|root)\)$/.test(frame.functionName)) return -1;
|
|
340
|
+
let key;
|
|
341
|
+
if (frame.url) key = where(frame, true);
|
|
342
|
+
else if (frame.functionName === '(garbage collector)') key = frame.functionName;
|
|
343
|
+
else if (script) key = `${frame.functionName || '(anonymous)'} (built-in, from ${where(script, true)})`;
|
|
344
|
+
else return -1;
|
|
345
|
+
if (!keyIndex.has(key)) { keyIndex.set(key, keys.length); keys.push(key); }
|
|
346
|
+
return keyIndex.get(key);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// The page's own function a sample was in: a built-in (performance.now, JSON.parse) counts as the
|
|
350
|
+
// function that called it.
|
|
351
|
+
function scriptFrame(nodes, frame) {
|
|
352
|
+
for (let f = frame, depth = 0; f && depth < 200; f = nodes.get(f.parent), depth++) if (f.url) return f;
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// The page's function that ran longest itself between two times, from the samples.
|
|
357
|
+
function hottest(samples, from, to) {
|
|
358
|
+
const self = new Map();
|
|
359
|
+
for (let k = lowerBound(samples.t, from); k < samples.t.length && samples.t[k] < to; k++) {
|
|
360
|
+
if (samples.script[k] < 0) continue;
|
|
361
|
+
self.set(samples.script[k], (self.get(samples.script[k]) || 0) + samples.us[k]);
|
|
362
|
+
}
|
|
363
|
+
const [top] = [...self].sort((a, b) => b[1] - a[1]);
|
|
364
|
+
return top ? { where: samples.keys[top[0]], us: top[1] } : null;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function lowerBound(times, t) {
|
|
368
|
+
let lo = 0;
|
|
369
|
+
let hi = times.length;
|
|
370
|
+
while (lo < hi) { const mid = (lo + hi) >> 1; if (times[mid] < t) lo = mid + 1; else hi = mid; }
|
|
371
|
+
return lo;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function analyse(tracks, t0, tEnd, { cpu }) {
|
|
375
|
+
const events = tracks.main;
|
|
376
|
+
const nested = nest(events);
|
|
377
|
+
const samples = mainSamples(tracks.profiles);
|
|
378
|
+
const buckets = Object.fromEntries(BUCKETS.map(b => [b, 0]));
|
|
379
|
+
for (let i = 0; i < events.n; i++) {
|
|
380
|
+
if (nested.taskOf[i] < 0) continue;
|
|
381
|
+
buckets[bucketOf(events.name(i)) || 'other'] += Math.max(0, nested.self[i]);
|
|
382
|
+
}
|
|
383
|
+
const busy = nested.tasks.reduce((sum, t) => sum + t.dur, 0);
|
|
384
|
+
const big = [...tracks.requests.values()].filter(r => r.finished && !r.failed && r.size >= BIG_REQUEST).sort((a, b) => a.finished - b.finished);
|
|
385
|
+
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) }));
|
|
386
|
+
const forced = new Map();
|
|
387
|
+
for (const [i, detail] of events.details) {
|
|
388
|
+
if (!detail.stack) continue;
|
|
389
|
+
const kind = events.name(i) === 'Layout' ? 'layout' : 'style';
|
|
390
|
+
const key = `${where(detail.stack)} (${kind})`;
|
|
391
|
+
const entry = forced.get(key) || { where: key, count: 0, dur: 0 };
|
|
392
|
+
entry.count += 1;
|
|
393
|
+
entry.dur += events.dur[i];
|
|
394
|
+
forced.set(key, entry);
|
|
395
|
+
}
|
|
396
|
+
return {
|
|
397
|
+
t0, duration: tEnd - t0, busy, buckets,
|
|
398
|
+
marks: Object.entries(tracks.marks).sort((a, b) => a[1] - b[1]),
|
|
399
|
+
longTasks,
|
|
400
|
+
forced: [...forced.values()].sort((a, b) => b.dur - a.dur),
|
|
401
|
+
steps: steps(tracks, events, nested),
|
|
402
|
+
cpu: cpu ? cpuTop(samples) : null,
|
|
403
|
+
frames: tracks.frames.length,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function describeTask(task, events, { order, self }, big) {
|
|
408
|
+
const own = {};
|
|
409
|
+
let trigger = null;
|
|
410
|
+
let script = null;
|
|
411
|
+
for (let p = task.p; p < task.end; p++) {
|
|
412
|
+
const i = order[p];
|
|
413
|
+
const name = events.name(i);
|
|
414
|
+
const b = bucketOf(name) || 'other';
|
|
415
|
+
own[b] = (own[b] || 0) + Math.max(0, self[i]);
|
|
416
|
+
// One task can dispatch several events (pointerdown, mousedown, click): the one that took longest.
|
|
417
|
+
if ((name === 'EventDispatch' || TRIGGERS[name] || name === 'EvaluateScript') && (!trigger || events.dur[i] > events.dur[trigger])) trigger = i;
|
|
418
|
+
if (events.details.get(i)?.frame?.url && (script === null || events.dur[i] > events.dur[script])) script = i;
|
|
419
|
+
}
|
|
420
|
+
const kind = Object.entries(own).sort((a, b) => b[1] - a[1])[0][0];
|
|
421
|
+
const triggerName = trigger === null ? null : events.name(trigger);
|
|
422
|
+
const triggerText = triggerName === null ? null : triggerName === 'EventDispatch' ? `${events.details.get(trigger).type} event` : triggerName === 'EvaluateScript' ? 'script' : TRIGGERS[triggerName];
|
|
423
|
+
const after = big.filter(r => r.finished <= task.ts && task.ts - r.finished < 1000000).pop() || null;
|
|
424
|
+
return { ts: task.ts, dur: task.dur, kind, trigger: triggerText, script: script === null ? null : where(events.details.get(script).frame), after };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Each step of the user's: an interaction (click, key), a burst of wheel or scroll events, or a drag
|
|
428
|
+
// (pointer down, moves, up), with what the page did in it. Interactions inside a drag are its own.
|
|
429
|
+
function steps(tracks, events, { taskOf, tasks }) {
|
|
430
|
+
const found = [];
|
|
431
|
+
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);
|
|
432
|
+
// A step starts with the task its first event ran in.
|
|
433
|
+
const began = e => (e.task >= 0 ? events.ts[e.task] : e.ts);
|
|
434
|
+
let burst = null;
|
|
435
|
+
for (const e of dispatches.filter(d => /^(?:wheel|mousewheel|scroll)$/.test(d.type))) {
|
|
436
|
+
if (burst && e.ts - burst.end <= STEP_GAP) { burst.end = e.ts + e.dur; burst.events += 1; continue; }
|
|
437
|
+
burst = { kind: 'wheel/scroll', start: began(e), end: e.ts + e.dur, events: 1 };
|
|
438
|
+
found.push(burst);
|
|
439
|
+
}
|
|
440
|
+
let down = null;
|
|
441
|
+
let moves = 0;
|
|
442
|
+
for (const e of dispatches) {
|
|
443
|
+
if (/^(?:pointerdown|mousedown|touchstart)$/.test(e.type)) { if (!down) { down = e; moves = 0; } }
|
|
444
|
+
else if (down && /^(?:pointermove|mousemove|touchmove)$/.test(e.type)) moves += 1;
|
|
445
|
+
else if (down && /^(?:pointerup|mouseup|touchend|pointercancel|touchcancel)$/.test(e.type)) {
|
|
446
|
+
if (moves >= 3) found.push({ kind: 'drag', start: began(down), end: e.ts + e.dur, events: moves });
|
|
447
|
+
down = null;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
// An interaction's latency is its slowest event's, to the paint after it, as DevTools has it; a key's
|
|
451
|
+
// keydown and keyup share an interaction but not a frame. It is split over the events shown in that
|
|
452
|
+
// frame (pointerup and click): input delay to the first handler, handlers to the last one's end,
|
|
453
|
+
// presentation from then to the paint.
|
|
454
|
+
const drags = found.filter(s => s.kind === 'drag');
|
|
455
|
+
for (const list of tracks.timings.values()) {
|
|
456
|
+
const ts = Math.min(...list.map(t => t.ts));
|
|
457
|
+
const end = Math.max(...list.map(t => t.ts + t.duration * 1000));
|
|
458
|
+
if (drags.some(s => ts <= s.end && end >= s.start)) continue;
|
|
459
|
+
const slowest = list.reduce((a, b) => (b.duration > a.duration ? b : a));
|
|
460
|
+
const painted = slowest.timeStamp + slowest.duration;
|
|
461
|
+
const frame = list.filter(t => Math.abs(t.timeStamp + t.duration - painted) < 8);
|
|
462
|
+
const startAt = Math.min(...frame.map(t => t.timeStamp));
|
|
463
|
+
const processingStart = Math.min(...frame.map(t => t.processingStart));
|
|
464
|
+
const processingEnd = Math.max(...frame.map(t => t.processingEnd));
|
|
465
|
+
const latency = slowest.duration;
|
|
466
|
+
const type = list.some(t => /^key/.test(t.type)) ? 'key press' : list.some(t => /click|pointer|mouse/.test(t.type)) ? 'click' : list[0].type;
|
|
467
|
+
found.push({
|
|
468
|
+
kind: type, start: ts, end, latency,
|
|
469
|
+
delay: processingStart - startAt, handlers: processingEnd - processingStart,
|
|
470
|
+
presentation: Math.max(0, latency - (processingEnd - startAt)),
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
found.sort((a, b) => a.start - b.start);
|
|
474
|
+
const taskStarts = tasks.map(t => t.ts);
|
|
475
|
+
const frames = tracks.frames.sort((a, b) => a.ts - b.ts);
|
|
476
|
+
const frameTimes = frames.map(f => f.ts);
|
|
477
|
+
for (const [i, step] of found.entries()) {
|
|
478
|
+
// Up to the next step, which what follows is counted in.
|
|
479
|
+
const end = Math.max(step.end, Math.min(step.end + STEP_TAIL, found[i + 1]?.start ?? Infinity));
|
|
480
|
+
step.busy = 0;
|
|
481
|
+
step.longest = 0;
|
|
482
|
+
// Tasks in start order; one that began before the step and runs into it counts its part.
|
|
483
|
+
for (let k = Math.max(0, lowerBound(taskStarts, step.start) - 1); k < tasks.length && tasks[k].ts < end; k++) {
|
|
484
|
+
const t = tasks[k];
|
|
485
|
+
if (t.ts + t.dur <= step.start) continue;
|
|
486
|
+
step.busy += Math.min(t.ts + t.dur, end) - Math.max(t.ts, step.start);
|
|
487
|
+
if (t.ts >= step.start) step.longest = Math.max(step.longest, t.dur);
|
|
488
|
+
}
|
|
489
|
+
step.frames = 0;
|
|
490
|
+
step.dropped = 0;
|
|
491
|
+
for (let k = lowerBound(frameTimes, step.start); k < frames.length && frames[k].ts < end; k++) {
|
|
492
|
+
step.frames += 1;
|
|
493
|
+
if (frames[k].dropped) step.dropped += 1;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return found;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// The functions that ran longest themselves, from the sampling profile's samples.
|
|
500
|
+
function cpuTop(samples) {
|
|
501
|
+
const self = new Map();
|
|
502
|
+
for (let k = 0; k < samples.t.length; k++) {
|
|
503
|
+
if (samples.own[k] < 0) continue;
|
|
504
|
+
self.set(samples.own[k], (self.get(samples.own[k]) || 0) + samples.us[k]);
|
|
505
|
+
}
|
|
506
|
+
return [...self].map(([key, us]) => ({ where: samples.keys[key], us })).sort((a, b) => b.us - a.us);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
module.exports = { summarize, eachEvent, LONG_TASK };
|
package/lib/util.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// Small helpers the commands share: output hints, clipping, times, patterns and number arguments.
|
|
2
2
|
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
3
6
|
const COMMAND_TIMEOUT = 15000;
|
|
4
7
|
|
|
5
8
|
const MAX_EVENT_TEXT = 4000;
|
|
@@ -51,4 +54,16 @@ function clock(t) {
|
|
|
51
54
|
const STOPPED_RECORDING = 'The REPL stopped, which ended a recording';
|
|
52
55
|
const ENDED_UNSEEN = 'A recording ended before the REPL stopped, unseen since';
|
|
53
56
|
|
|
54
|
-
|
|
57
|
+
// Where Playwright installs its browsers and ffmpeg, worked out as playwright-core does it: a cache of
|
|
58
|
+
// each platform's own (~/Library/Caches on macOS), unless PLAYWRIGHT_BROWSERS_PATH says otherwise.
|
|
59
|
+
function playwrightBrowsersDir() {
|
|
60
|
+
const given = process.env.PLAYWRIGHT_BROWSERS_PATH;
|
|
61
|
+
if (given === '0') return path.join(path.dirname(require.resolve('playwright-core/package.json')), '.local-browsers');
|
|
62
|
+
if (given) return path.resolve(process.env.INIT_CWD || process.cwd(), given);
|
|
63
|
+
const cache = process.platform === 'darwin' ? path.join(os.homedir(), 'Library', 'Caches')
|
|
64
|
+
: process.platform === 'win32' ? process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local')
|
|
65
|
+
: process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
|
66
|
+
return path.join(cache, 'ms-playwright');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { STOPPED_RECORDING, ENDED_UNSEEN, COMMAND_TIMEOUT, MAX_EVENT_TEXT, NO_TAB, hints, clipText, numbers, regexOf, patternText, clock, playwrightBrowsersDir };
|
package/package.json
CHANGED
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
|
|
|
@@ -249,7 +254,8 @@ pw-repl send -c <name> close # your modes off; tab close
|
|
|
249
254
|
- `PW_FFMPEG` — the ffmpeg `record` uses, and no other, read by the REPL, not by `send` (default:
|
|
250
255
|
Playwright's own, then one on the `PATH`).
|
|
251
256
|
- Playwright's own Chromium and ffmpeg are found where Playwright installs them:
|
|
252
|
-
`PLAYWRIGHT_BROWSERS_PATH`, or `~/.cache/ms-playwright
|
|
257
|
+
`PLAYWRIGHT_BROWSERS_PATH`, or `~/.cache/ms-playwright` on Linux and `~/Library/Caches/ms-playwright`
|
|
258
|
+
on macOS.
|
|
253
259
|
- `PW_CDP_URL` — CDP endpoint (default `http://localhost:9222`).
|
|
254
260
|
- `PW_SCREENSHOT_DIR` — where the REPL saves screenshots (a folder that exists), read when it starts,
|
|
255
261
|
not by `send`. Without it, a REPL on a socket of its own saves them next to its socket, and any other
|