pw-repl 0.12.5 → 0.12.7
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 +1 -1
- package/README.md +1 -1
- package/bin/pw-repl.js +98 -32
- package/lib/elements.js +7 -2
- package/lib/emulation.js +3 -2
- package/lib/help.js +22 -2
- package/lib/inspect.js +17 -5
- package/lib/profile.js +15 -6
- package/lib/start.js +2 -1
- package/lib/trace.js +24 -3
- package/lib/util.js +4 -1
- package/package.json +1 -1
- package/skill/SKILL.md +9 -8
package/AGENTS.md
CHANGED
|
@@ -104,7 +104,7 @@ Commits follow [Conventional Commits](https://www.conventionalcommits.org/): `fe
|
|
|
104
104
|
|
|
105
105
|
1. `npm test` passes and the working tree is clean.
|
|
106
106
|
2. `npm outdated` and `npm audit` show nothing that needs doing first. The lockfile holds playwright-core
|
|
107
|
-
at 1.63: on 1.64, a click on a link to a 204 waits out its timeout instead of returning (the test
|
|
107
|
+
at 1.63: on 1.64 (and 1.64.1), a click on a link to a 204 waits out its timeout instead of returning (the test
|
|
108
108
|
"waits for the page to load, counting a navigation that just began" fails). Update it once a version
|
|
109
109
|
passes `npm test`.
|
|
110
110
|
3. `npm version <patch|minor|major> -m "chore: release %s"` sets the version in `package.json` and
|
package/README.md
CHANGED
|
@@ -126,7 +126,7 @@ example the Docker bridge gateway, `ip route | awk '/default/ {print $3}'`).
|
|
|
126
126
|
it. `pw-repl where` says what is running and how `send` will reach it.
|
|
127
127
|
- For `send` to reach `run`, start it in tmux: `tmux new -s pw-repl`, then `pw-repl run` there.
|
|
128
128
|
- A URL after the command opens in a new tab: `pw-repl serve http://localhost:3000`.
|
|
129
|
-
- `--launch` works with each of them. Flags after `--` go to that Chromium.
|
|
129
|
+
- `--launch` works with each of them. Flags after `--` go to that Chromium (flags only; a start URL goes before `--`).
|
|
130
130
|
- A REPL of your own goes on a socket of its own: `-e /tmp/mine.sock` on every command, `serve`
|
|
131
131
|
included, or set `PW_SOCKET`. `serve <port>` listens on TCP, on 127.0.0.1 only.
|
|
132
132
|
|
package/bin/pw-repl.js
CHANGED
|
@@ -38,7 +38,12 @@ $PW_ENDPOINT, then $PW_SOCKET, then the default.
|
|
|
38
38
|
send -c <client> (or $PW_CLIENT) sends as a client of the REPL's with a selected tab of its own, so
|
|
39
39
|
several agents can share one REPL; pw-repl help session has the rest.
|
|
40
40
|
|
|
41
|
-
send
|
|
41
|
+
send's options can also follow the command, as in pw-repl send open <url> -t 30; after a -- before the
|
|
42
|
+
command, every word is the command's, -t 30 included.
|
|
43
|
+
|
|
44
|
+
send exit status: 0 ok, 1 command error, 2 completion not confirmed, 64 usage or unreachable. send -t is
|
|
45
|
+
how long send waits for the answer (default 20s); it does not lengthen a command's own limits, such as
|
|
46
|
+
the 5s an action waits for its element.
|
|
42
47
|
|
|
43
48
|
send --file <file> runs each line of a file as a command, in order, as if typed at the REPL's prompt:
|
|
44
49
|
quoted as the prompt reads it (no shell), with a file it records, uploads or saves relative to this
|
|
@@ -62,7 +67,7 @@ The browser must be running with --remote-debugging-port (default http://localho
|
|
|
62
67
|
--launch is the quick start instead: it starts a Chromium of the REPL's own (headless unless --headed, in
|
|
63
68
|
a temporary profile, on a free port of its own, which pw-repl where names), prints the command it ran,
|
|
64
69
|
and stops it with the REPL (Ctrl-C while it starts too); stop returns once that Chromium has exited.
|
|
65
|
-
Flags after -- go to it; a --user-data-dir=<dir> among them is used instead of the temporary profile, and
|
|
70
|
+
Flags after -- go to it, and only flags; a --user-data-dir=<dir> among them is used instead of the temporary profile, and
|
|
66
71
|
kept. PW_CHROME picks which one. To set a browser up your own way, start it yourself and use PW_CDP_URL.
|
|
67
72
|
|
|
68
73
|
In a tmux session named pw-repl, pw-repl send reaches pw-repl run without the server.`;
|
|
@@ -72,15 +77,49 @@ const REPL_HELP = `The REPL's own commands: help at the pw> prompt, or pw-repl s
|
|
|
72
77
|
pw-repl help <topic | command [--all] | --all> shows one part of it; pw-repl help video and pw-repl
|
|
73
78
|
help playwright-cli are guides.`;
|
|
74
79
|
|
|
75
|
-
|
|
76
|
-
|
|
80
|
+
const SUBCOMMANDS = ['run', 'serve', 'attach', 'stop', 'send', 'where', 'help', 'skill'];
|
|
81
|
+
|
|
82
|
+
// What is wrong, then only the usage of the subcommand it is about: the whole of it would bury the line
|
|
83
|
+
// that matters. With no message, the whole usage (-h after a subcommand).
|
|
84
|
+
function usage(message, subcommand) {
|
|
85
|
+
if (!message) {
|
|
86
|
+
console.error(USAGE);
|
|
87
|
+
process.exit(64);
|
|
88
|
+
}
|
|
89
|
+
console.error(`pw-repl: ${message}`);
|
|
90
|
+
const lines = subcommand && USAGE.split('\n\n').find(block => new RegExp(`^(?:Usage:\\n)? pw-repl ${subcommand}(?: |\\n|$)`).test(block));
|
|
91
|
+
if (lines) console.error(`Usage:\n${lines.replace(/^Usage:\n/, '')}`);
|
|
92
|
+
console.error(`pw-repl --help has the ${lines ? 'rest' : 'usage'}.`);
|
|
77
93
|
process.exit(64);
|
|
78
94
|
}
|
|
79
95
|
|
|
80
|
-
//
|
|
81
|
-
function
|
|
96
|
+
// A command line to copy, quoted as a shell reads it.
|
|
97
|
+
function shellLine(words) {
|
|
98
|
+
return words.map(w => (/^[\w@%+=:,./-]+$/.test(w) ? w : `'${w.replace(/'/g, "'\\''")}'`)).join(' ');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// -e, -s, -c and -t, then the command words (unquoted words are one command), and send's options again
|
|
102
|
+
// at the end, as an agent types them (send open <url> -t 30); after --, every word is the command.
|
|
103
|
+
function parseSendArgs(args, subcommand) {
|
|
104
|
+
const allowCommand = subcommand === 'send';
|
|
82
105
|
const options = { endpoint: null, session: null, client: null, timeout: 20, command: '', file: null };
|
|
106
|
+
const given = new Set();
|
|
107
|
+
const take = (arg, value, after) => {
|
|
108
|
+
if (value === undefined) usage(`${arg} needs a value`, subcommand);
|
|
109
|
+
// Given before the command and after it, neither silently wins.
|
|
110
|
+
if (after && given.has(arg)) usage(`${arg} is given twice, ${value === true ? arg : `${arg} ${value}`} after the command; give it once`, subcommand);
|
|
111
|
+
given.add(arg);
|
|
112
|
+
if (arg === '--no-vars') options.noVars = true;
|
|
113
|
+
if (arg === '-e') options.endpoint = value;
|
|
114
|
+
if (arg === '-s') options.session = value;
|
|
115
|
+
if (arg === '-c') options.client = value;
|
|
116
|
+
if (arg === '-t') {
|
|
117
|
+
if (!/^\d+$/.test(value) || Number(value) < 1) usage(`-t takes whole seconds, 1 or more (got ${value})`, subcommand);
|
|
118
|
+
options.timeout = Number(value);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
83
121
|
let i = 0;
|
|
122
|
+
let dashes = false;
|
|
84
123
|
for (; i < args.length; i++) {
|
|
85
124
|
const arg = args[i];
|
|
86
125
|
// playwright-cli's --raw: the output here is only the command's already.
|
|
@@ -95,22 +134,22 @@ function parseSendArgs(args, allowCommand) {
|
|
|
95
134
|
// A file of commands, one per line, as typed at the prompt.
|
|
96
135
|
if (arg.startsWith('--file=') && arg.length > 7) { options.file = require('path').resolve(arg.slice(7)); continue; }
|
|
97
136
|
if (arg === '--file') {
|
|
98
|
-
if (args[i + 1] === undefined) usage();
|
|
137
|
+
if (args[i + 1] === undefined) usage('--file needs a file of commands', subcommand);
|
|
99
138
|
options.file = require('path').resolve(args[++i]);
|
|
100
139
|
continue;
|
|
101
140
|
}
|
|
102
|
-
if (arg === '--no-vars')
|
|
103
|
-
if (arg === '-e' || arg === '-s' || arg === '-c' || arg === '-t')
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
141
|
+
if (arg === '--no-vars') take(arg, true);
|
|
142
|
+
else if (arg === '-e' || arg === '-s' || arg === '-c' || arg === '-t') take(arg, args[++i]);
|
|
143
|
+
else if (arg === '--') { i++; dashes = true; break; } else break;
|
|
144
|
+
}
|
|
145
|
+
const words = args.slice(i);
|
|
146
|
+
// A word the shell kept whole ("a -t 3") is never one of these; an option with a bad value is still
|
|
147
|
+
// send's, refused, not the end of a URL or a selector.
|
|
148
|
+
while (allowCommand && !dashes) {
|
|
149
|
+
const last = words.length - 1;
|
|
150
|
+
if (last > 0 && words[last] === '--raw') words.pop();
|
|
151
|
+
else if (last > 0 && words[last] === '--no-vars') take(words.pop(), true, true);
|
|
152
|
+
else if (last > 1 && /^-[ecst]$/.test(words[last - 1])) { const value = words.pop(); take(words.pop(), value, true); } else break;
|
|
114
153
|
}
|
|
115
154
|
// An address that is refused (not loopback) is a usage error, said once, not a stack trace.
|
|
116
155
|
const endpoint = options.endpoint || process.env.PW_ENDPOINT;
|
|
@@ -122,9 +161,9 @@ function parseSendArgs(args, allowCommand) {
|
|
|
122
161
|
process.exit(64);
|
|
123
162
|
}
|
|
124
163
|
}
|
|
125
|
-
|
|
126
|
-
if (!allowCommand &&
|
|
127
|
-
if (options.file && words.length) usage();
|
|
164
|
+
if (!allowCommand && options.file) usage(`--file is send's; ${subcommand} runs no commands`, subcommand);
|
|
165
|
+
if (!allowCommand && words.length) usage(`${subcommand} takes no command (got ${shellLine(words)})`, subcommand);
|
|
166
|
+
if (options.file && words.length) usage(`a command or --file, not both (got --file and ${shellLine(words)})`, subcommand);
|
|
128
167
|
options.command = require('../lib/send').commandLine(words);
|
|
129
168
|
return options;
|
|
130
169
|
}
|
|
@@ -139,17 +178,35 @@ function startOptions(args, serve) {
|
|
|
139
178
|
if (serve) options.background = flag('--background');
|
|
140
179
|
options.launch = flag('--launch');
|
|
141
180
|
options.headed = flag('--headed');
|
|
142
|
-
|
|
181
|
+
const subcommand = serve ? 'serve' : 'run';
|
|
182
|
+
if (options.headed && !options.launch) usage('--headed is for the Chromium --launch starts; add --launch', subcommand);
|
|
183
|
+
if (options.chromeArgs.length && !options.launch) usage('flags after -- go to the Chromium --launch starts; add --launch', subcommand);
|
|
184
|
+
// Chromium takes any word that is not a --switch as a page to open, and a headless one refuses a second.
|
|
185
|
+
const n = options.chromeArgs.findIndex(word => !word.startsWith('-'));
|
|
186
|
+
if (n !== -1) {
|
|
187
|
+
const [page, before] = [options.chromeArgs[n], options.chromeArgs[n - 1]];
|
|
188
|
+
// Most likely a switch's value given as a word of its own (--user-data-dir /tmp/p); a URL after a
|
|
189
|
+
// switch is a URL (--disable-gpu http://...).
|
|
190
|
+
if (before && /^--[^=]+$/.test(before) && !page.includes('://')) usage(`${page} after -- would go to Chromium as a page to open: it takes a switch's value as ${before}=${page}`, subcommand);
|
|
191
|
+
usage(`${page} after -- would go to Chromium as a page to open; only flags go after --. A start URL goes before --; for a browser already running, leave out --launch and set PW_CDP_URL`, subcommand);
|
|
192
|
+
}
|
|
143
193
|
// -e <endpoint>, as send, attach, stop and where take it, or the endpoint as the first word.
|
|
144
194
|
const e = rest.indexOf('-e');
|
|
145
195
|
if (serve && e !== -1 && rest[e + 1] && looksLikeEndpoint(rest[e + 1])) {
|
|
146
196
|
options.endpoint = rest.splice(e, 2)[1];
|
|
147
197
|
// Two endpoints, one with -e: neither silently wins.
|
|
148
|
-
if (rest[0] && looksLikeEndpoint(rest[0])) usage();
|
|
198
|
+
if (rest[0] && looksLikeEndpoint(rest[0])) usage(`two endpoints, -e ${options.endpoint} and ${rest[0]}: give one`, subcommand);
|
|
149
199
|
} else if (serve && rest[0] && looksLikeEndpoint(rest[0])) options.endpoint = rest.shift();
|
|
150
200
|
// Otherwise the socket send would look for: PW_ENDPOINT, then PW_SOCKET, then the default.
|
|
151
201
|
if (serve && !options.endpoint) options.endpoint = process.env.PW_ENDPOINT || process.env.PW_SOCKET || null;
|
|
152
|
-
|
|
202
|
+
const option = rest.find(word => word.startsWith('-'));
|
|
203
|
+
if (option === '-h' || option === '--help') usage();
|
|
204
|
+
if (option === '-e') {
|
|
205
|
+
usage(serve ? `-e takes an endpoint: a socket (/tmp/<name>.sock), a port, or 127.0.0.1:port (got ${rest[rest.indexOf('-e') + 1] ?? 'nothing'})` : 'run has no command server, so no endpoint; pw-repl serve has one', subcommand);
|
|
206
|
+
}
|
|
207
|
+
if (option === '--background') usage('--background is serve\'s: pw-repl serve --background', subcommand);
|
|
208
|
+
if (option) usage(`unknown option ${option}`, subcommand);
|
|
209
|
+
if (rest.length > 1) usage(`one start URL at most (got ${shellLine(rest)})`, subcommand);
|
|
153
210
|
options.startUrl = rest[0] || null;
|
|
154
211
|
return options;
|
|
155
212
|
}
|
|
@@ -183,15 +240,15 @@ async function main() {
|
|
|
183
240
|
}
|
|
184
241
|
case 'attach':
|
|
185
242
|
case 'stop': {
|
|
186
|
-
const options = parseSendArgs(args,
|
|
187
|
-
if (options.session) usage();
|
|
243
|
+
const options = parseSendArgs(args, subcommand);
|
|
244
|
+
if (options.session) usage(`-s is send's tmux session; ${subcommand} takes -e <endpoint>`, subcommand);
|
|
188
245
|
// The same socket send would use when there is no -e: PW_ENDPOINT, then PW_SOCKET.
|
|
189
246
|
const endpoint = options.endpoint || process.env.PW_ENDPOINT || process.env.PW_SOCKET || null;
|
|
190
247
|
process.exit(await require('../lib/background')[subcommand]({ endpoint }));
|
|
191
248
|
}
|
|
192
249
|
// falls through never: process.exit above
|
|
193
250
|
case 'send': {
|
|
194
|
-
const options = parseSendArgs(args,
|
|
251
|
+
const options = parseSendArgs(args, 'send');
|
|
195
252
|
// help is fixed text, so it needs no browser: answer it here.
|
|
196
253
|
const asksHelp = /^help(?:\s+(.*))?$/.exec(options.command);
|
|
197
254
|
if (asksHelp) return help((asksHelp[1] || '').trim());
|
|
@@ -199,7 +256,7 @@ async function main() {
|
|
|
199
256
|
}
|
|
200
257
|
// falls through never: process.exit above
|
|
201
258
|
case 'where':
|
|
202
|
-
process.exit(await require('../lib/send').where(parseSendArgs(args,
|
|
259
|
+
process.exit(await require('../lib/send').where(parseSendArgs(args, 'where')));
|
|
203
260
|
// falls through never
|
|
204
261
|
case 'help': {
|
|
205
262
|
const topic = args.join(' ').trim();
|
|
@@ -207,7 +264,7 @@ async function main() {
|
|
|
207
264
|
return console.log(`${USAGE}\n\n${REPL_HELP}`);
|
|
208
265
|
}
|
|
209
266
|
case 'skill':
|
|
210
|
-
if (args.length) usage();
|
|
267
|
+
if (args.length) usage(`skill takes no arguments (got ${shellLine(args)})`, 'skill');
|
|
211
268
|
return skill();
|
|
212
269
|
case '-v':
|
|
213
270
|
case '--version':
|
|
@@ -215,8 +272,17 @@ async function main() {
|
|
|
215
272
|
case '-h':
|
|
216
273
|
case '--help':
|
|
217
274
|
return console.log(`${USAGE}\n\n${REPL_HELP}`);
|
|
218
|
-
default:
|
|
219
|
-
|
|
275
|
+
default: {
|
|
276
|
+
// An option before the subcommand (pw-repl -t 30 send ...): say where it goes, with the line fixed.
|
|
277
|
+
if (subcommand.startsWith('-')) {
|
|
278
|
+
const all = [subcommand, ...args];
|
|
279
|
+
const named = all.find(word => SUBCOMMANDS.includes(word));
|
|
280
|
+
const fixed = named ? shellLine(['pw-repl', named, ...all.filter((word, n) => n !== all.indexOf(named))]) : `pw-repl send ${shellLine(all)} <command>`;
|
|
281
|
+
return usage(`${subcommand} is an option of a subcommand, and goes after it: ${fixed}`, named || 'send');
|
|
282
|
+
}
|
|
283
|
+
const url = /^[a-z][\w+.-]*:/i.test(subcommand) && !looksLikeEndpoint(subcommand) ? `; a start URL goes after run or serve: pw-repl run ${subcommand}` : '';
|
|
284
|
+
return usage(`${subcommand} is not a command (they are ${SUBCOMMANDS.join(', ')})${url}`);
|
|
285
|
+
}
|
|
220
286
|
}
|
|
221
287
|
}
|
|
222
288
|
|
package/lib/elements.js
CHANGED
|
@@ -5,7 +5,7 @@ const { state, onShutdown } = require('./state');
|
|
|
5
5
|
const out = require('./output');
|
|
6
6
|
const { takeOptions } = require('./cli-names');
|
|
7
7
|
const { unquote, toSelector, keyName, splitSelector, REF } = require('./syntax');
|
|
8
|
-
const { numbers, hints } = require('./util');
|
|
8
|
+
const { numbers, hints, BUSY_PAGE } = require('./util');
|
|
9
9
|
const { tabState } = require('./tabstate');
|
|
10
10
|
const { cursorOf, aiming, aimAt, glideTo, showClick, showSelect } = require('./cursor');
|
|
11
11
|
const { recordingOf, stepTarget, pauseToShow } = require('./record');
|
|
@@ -95,8 +95,13 @@ async function onElement(selector, action, budget = 5000) {
|
|
|
95
95
|
// or the check the element kept failing.
|
|
96
96
|
function lastBlocker(log) {
|
|
97
97
|
const lines = log.split('\n').map(line => line.trim().replace(/^-\s*/, ''));
|
|
98
|
+
// Its last step was the action itself: it was sent, and the page stopped answering, as when the click
|
|
99
|
+
// sets off a long task. A profile started now would miss that task: Chrome traces only what begins
|
|
100
|
+
// after it starts.
|
|
101
|
+
const sent = /^performing (\w+) action$/.exec(lines.filter(Boolean).pop() || '');
|
|
102
|
+
if (sent) return `the ${sent[1]} was sent, and may have landed, but the page stopped answering after it, likely busy with what it set off. A profile shows that only if it is on before the action: profile on, then the action again, if repeating it is safe`;
|
|
98
103
|
const line = lines.reverse().find(l => /intercepts pointer events|element is not |element is outside|not attached|detached/.test(l));
|
|
99
|
-
if (!line) return
|
|
104
|
+
if (!line) return `it never became ready to act on; ${BUSY_PAGE}`;
|
|
100
105
|
return line.length > 240 ? `${line.slice(0, 240)}…` : line;
|
|
101
106
|
}
|
|
102
107
|
|
package/lib/emulation.js
CHANGED
|
@@ -175,9 +175,10 @@ const commands = {
|
|
|
175
175
|
out.log(`Viewport off: ${await viewportText()}`);
|
|
176
176
|
return;
|
|
177
177
|
}
|
|
178
|
-
const match = /^(\d+)x(\d+)
|
|
178
|
+
const match = /^(\d+)x(\d+)$/i.exec(arg);
|
|
179
179
|
const [w, h] = match ? [Number(match[1]), Number(match[2])] : [];
|
|
180
|
-
if (!
|
|
180
|
+
if (!match) throw new Error(`A size is <width>x<height>, e.g. viewport 1280x900 (got ${arg}). Usage: viewport [<width>x<height> | off]`);
|
|
181
|
+
if (!w || !h) throw new Error(`A width and a height are 1 or more (got ${arg})`);
|
|
181
182
|
// Both set the screen size; whichever came last would win without saying so.
|
|
182
183
|
if (tabState(state.page).emulation?.device) throw new Error('emulate mobile sets the size of the selected tab; emulate mobile off first');
|
|
183
184
|
await setViewport(state.page, { width: w, height: h });
|
package/lib/help.js
CHANGED
|
@@ -69,7 +69,8 @@ tab on its own lists them all.`,
|
|
|
69
69
|
interact: {
|
|
70
70
|
intro: `Selectors are Playwright selectors (CSS, text=..., role=...) or snapshot refs such as e5.
|
|
71
71
|
Commands use the first match (click, dblclick and hover the first they can use: help click); see help
|
|
72
|
-
fill for selectors with spaces. mousemove and the other mouse commands act at a point instead
|
|
72
|
+
fill for selectors with spaces. mousemove and the other mouse commands act at a point instead. An action
|
|
73
|
+
scrolls its element into view first, as Playwright does; profile counts that scroll in the action's step.`,
|
|
73
74
|
commands: ['click', 'dblclick', 'hover', 'fill', 'type', 'press', 'select', 'check', 'uncheck', 'upload', 'mousemove', 'mouseclick', 'mousedown', 'mouseup', 'mousewheel'],
|
|
74
75
|
},
|
|
75
76
|
inspect: {
|
|
@@ -609,7 +610,11 @@ goes on recording. There is no --mp4 or --webm: the file's ending picks the form
|
|
|
609
610
|
['off', 'back to the window\'s size.'],
|
|
610
611
|
['(none)', 'shows the size, and whether it was set or is the window\'s.'],
|
|
611
612
|
],
|
|
612
|
-
detail: `emulate mobile sets a phone's size instead, and each refuses while the other is on
|
|
613
|
+
detail: `emulate mobile sets a phone's size instead, and each refuses while the other is on.
|
|
614
|
+
|
|
615
|
+
The window's size here is the size of the page in it. Chromium's --window-size flag sizes the whole
|
|
616
|
+
window, frame included, so the page is smaller: --window-size=1280,900 gives 1280x757 in a headless one.
|
|
617
|
+
For a page of an exact size, use viewport <W>x<H>.`,
|
|
613
618
|
},
|
|
614
619
|
emulate: {
|
|
615
620
|
usage: 'emulate [<what> [off] | off]',
|
|
@@ -734,6 +739,14 @@ each click or key press with its time to the next paint (input delay, handlers,
|
|
|
734
739
|
burst of wheel or scroll events and each drag, with the main thread's busy time and the frames drawn and
|
|
735
740
|
dropped. Times are from when it started. --all lists every long task and step, not the first ones.
|
|
736
741
|
|
|
742
|
+
Long tasks read kind, what set it off, script: scripting, click event, app.js:120 handleClick. The kind
|
|
743
|
+
is what most of its time went to (GC, style/layout); what set it off is the event or callback that began
|
|
744
|
+
it (timer, promise callbacks); promise callbacks a click handler queued run in the click's task, so
|
|
745
|
+
show as click event. No script is named when none of its code came from a page URL.
|
|
746
|
+
|
|
747
|
+
A page that freezes: profile on, then do what freezes it. Your commands wait for the page meanwhile, and
|
|
748
|
+
may time out, but the profile records it all; profile off once the page answers again.
|
|
749
|
+
|
|
737
750
|
Size: a busy page fills Chrome's trace buffer in under a minute (one that restyles many elements as it
|
|
738
751
|
draws, sooner), which stops the profile there, and the summary says so. Stopping a long one takes a
|
|
739
752
|
while, as Chrome writes out its trace: give send time, pw-repl send -t 60 profile off. If send gives up
|
|
@@ -755,6 +768,13 @@ small hot function called in a loop can show as the loop's function.
|
|
|
755
768
|
A wheel-zoom or drag the page handles in its own script (a map) shows as a step from its events; one the
|
|
756
769
|
browser handles alone, with no listener, may show only as frames.
|
|
757
770
|
|
|
771
|
+
A page gets scroll events for any scroll, input or not. Those next to a click or key press, with no wheel
|
|
772
|
+
event (an action's scroll into view, or a handler's scrollTo), are that step's: it says the page
|
|
773
|
+
scrolled. A burst of scroll events alone may also be the page's own script scrolling.
|
|
774
|
+
|
|
775
|
+
A step under way when the profile began (an action still running at profile on) reads before, without
|
|
776
|
+
its busy time: the trace has nothing from before it began.
|
|
777
|
+
|
|
758
778
|
profile on and off each run console.timeStamp in the page, which prints nothing, so the tab is found in
|
|
759
779
|
the trace even when its page did nothing. The trace is kept in a temporary folder until the next
|
|
760
780
|
profile, or until the REPL stops.`,
|
package/lib/inspect.js
CHANGED
|
@@ -6,7 +6,7 @@ const out = require('./output');
|
|
|
6
6
|
const { printOutput } = out;
|
|
7
7
|
const { devices } = require('playwright-core');
|
|
8
8
|
const { toSelector, REF, unquote } = require('./syntax');
|
|
9
|
-
const { regexOf, COMMAND_TIMEOUT } = require('./util');
|
|
9
|
+
const { regexOf, COMMAND_TIMEOUT, BUSY_PAGE } = require('./util');
|
|
10
10
|
const { tabState } = require('./tabstate');
|
|
11
11
|
const { keptSession } = require('./cdp');
|
|
12
12
|
const { CDP_REFUSED } = require('./dialogs');
|
|
@@ -127,6 +127,13 @@ async function waitGone(locator, what, timeout) {
|
|
|
127
127
|
|
|
128
128
|
const SNAPSHOT_HINT_LINES = 60;
|
|
129
129
|
|
|
130
|
+
function evaluated(promise) {
|
|
131
|
+
return withTimeout(promise, 'Page evaluation', COMMAND_TIMEOUT).catch(error => {
|
|
132
|
+
if (error.message === 'Page evaluation timed out') error.message += ` after ${COMMAND_TIMEOUT / 1000}s; ${BUSY_PAGE}`;
|
|
133
|
+
throw error;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
130
137
|
// Each matching line (any part of it: role, name, flags such as [disabled]),
|
|
131
138
|
// with the named elements it sits in, so a match can be placed without the
|
|
132
139
|
// rest of the tree.
|
|
@@ -435,7 +442,12 @@ const commands = {
|
|
|
435
442
|
const onlyWhat = (tokens[0] === 'text' || tokens[0] === 'request') && tokens.length === 2;
|
|
436
443
|
const tookSeconds = tokens.length > 1 && !onlyWhat && /^\d+$/.test(tokens[tokens.length - 1]);
|
|
437
444
|
if (tookSeconds) seconds = Number(tokens.pop());
|
|
438
|
-
if (!tokens.length
|
|
445
|
+
if (!tokens.length) throw new Error(`${usage} (1-${WAIT_MAX})`);
|
|
446
|
+
if (seconds < 1 || seconds > WAIT_MAX) {
|
|
447
|
+
// Most likely text that ends in a number, not seconds at all.
|
|
448
|
+
const text = tokens[0] === 'text' && !/^["']/.test(tokens[1]) ? `; for text that ends in a number, quote it: wait text "${tokens.slice(1).join(' ')} ${seconds}"` : '';
|
|
449
|
+
throw new Error(`wait takes 1-${WAIT_MAX} seconds (got ${seconds})${text}`);
|
|
450
|
+
}
|
|
439
451
|
const timeout = seconds * 1000;
|
|
440
452
|
// Said plainly, not as Playwright's call log.
|
|
441
453
|
const plainly = message => error => { throw /Timeout \d+ms exceeded/.test(error.message) ? new Error(message) : error; };
|
|
@@ -480,7 +492,7 @@ const commands = {
|
|
|
480
492
|
try {
|
|
481
493
|
// Made from its text by the debugger, as eval is, so a page's CSP cannot
|
|
482
494
|
// refuse it, and in the element's own frame, which may be an iframe.
|
|
483
|
-
const fn = await
|
|
495
|
+
const fn = await evaluated((await el.ownerFrame()).evaluateHandle(unquote(onRef[1])));
|
|
484
496
|
try {
|
|
485
497
|
printOutput(await el.evaluate((e, f) => f(e), fn), all);
|
|
486
498
|
} finally {
|
|
@@ -502,13 +514,13 @@ const commands = {
|
|
|
502
514
|
const call = `const value = (${args}\n); return typeof value === 'function' ? value() : value;`;
|
|
503
515
|
if (awaits && !single && compiles(AsyncFunction, args)) {
|
|
504
516
|
const session = await keptSession(state.page);
|
|
505
|
-
const { result, exceptionDetails } = await
|
|
517
|
+
const { result, exceptionDetails } = await evaluated(session.send('Runtime.evaluate', { expression: args, replMode: true, awaitPromise: true, returnByValue: true }));
|
|
506
518
|
if (exceptionDetails) throw new Error(exceptionDetails.exception?.description || exceptionDetails.text);
|
|
507
519
|
printOutput(result.value !== undefined ? result.value : result.description ?? 'undefined', all);
|
|
508
520
|
return;
|
|
509
521
|
}
|
|
510
522
|
const expression = single ? `(${awaits ? 'async ' : ''}() => { ${call} })()` : args;
|
|
511
|
-
const value = await
|
|
523
|
+
const value = await evaluated(state.page.evaluate(expression));
|
|
512
524
|
// An empty string is shown as one, not as no output at all.
|
|
513
525
|
printOutput(value === '' ? '""' : value, all);
|
|
514
526
|
// Quotes around the whole of it, as a shell's quoting sent on, make it a string, not code to run:
|
package/lib/profile.js
CHANGED
|
@@ -35,9 +35,15 @@ function workDir() {
|
|
|
35
35
|
|
|
36
36
|
// A trace event naming the tab's frame, from its renderer, so an idle tab is still found in the trace.
|
|
37
37
|
// console.timeStamp prints nothing in the console. A page held by a dialog does not answer: no wait.
|
|
38
|
-
|
|
38
|
+
// At the end it waits two frames and 100ms more first: a click's timing reaches the trace only after the
|
|
39
|
+
// paint that follows it, and its step takes in what the page did for 100ms after it.
|
|
40
|
+
async function mark(page, { last = false } = {}) {
|
|
39
41
|
if (page.isClosed()) return;
|
|
40
|
-
|
|
42
|
+
const marked = page.evaluate(async settle => {
|
|
43
|
+
if (settle) await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(resolve, 100))));
|
|
44
|
+
console.timeStamp('pw-repl profile');
|
|
45
|
+
}, last).catch(() => {});
|
|
46
|
+
await Promise.race([marked, new Promise(resolve => setTimeout(resolve, 1000))]);
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
function forget(profile) {
|
|
@@ -121,7 +127,7 @@ function finishProfile() {
|
|
|
121
127
|
clearTimeout(profile.cap);
|
|
122
128
|
profile.unlisten();
|
|
123
129
|
profile.finished = (async () => {
|
|
124
|
-
await mark(profile.page);
|
|
130
|
+
await mark(profile.page, { last: true });
|
|
125
131
|
const endedAt = Date.now();
|
|
126
132
|
const raw = path.join(workDir(), `raw-${process.pid}-${endedAt}.json`);
|
|
127
133
|
try {
|
|
@@ -212,10 +218,13 @@ function printProfile(profile, all = false) {
|
|
|
212
218
|
const steps = all ? s.steps : s.steps.slice(0, SHOWN * 2);
|
|
213
219
|
for (const step of steps) {
|
|
214
220
|
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)`
|
|
221
|
+
? `${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)${step.scrolled ? ', the page scrolled' : ''}`
|
|
216
222
|
: `${secs(step.end - step.start)}, ${step.events} event${step.events === 1 ? '' : 's'}`;
|
|
217
223
|
const frames = step.frames ? `, frames ${step.frames} drawn, ${step.dropped} dropped` : '';
|
|
218
|
-
|
|
224
|
+
// One under way at profile on (an action still running): what ran before it began is not in the
|
|
225
|
+
// trace, so its busy time would be only the end of it.
|
|
226
|
+
const busy = step.start < s.t0 ? 'began before the profile: its busy time was not recorded' : `busy ${ms(step.busy)}${step.longest ? `, longest task ${ms(step.longest)}` : ''}`;
|
|
227
|
+
lines.push(` ${(step.start < s.t0 ? 'before' : at(step.start)).padStart(8)} ${step.kind} ${span}; ${busy}${frames}`);
|
|
219
228
|
}
|
|
220
229
|
if (steps.length < s.steps.length) lines.push(` (the first ${steps.length} of ${s.steps.length}; profile --all lists them all)`);
|
|
221
230
|
if (profile.drawing && s.steps.some(step => step.frames)) {
|
|
@@ -307,4 +316,4 @@ onShutdown(async () => {
|
|
|
307
316
|
if (dir) fs.rmSync(dir, { recursive: true, force: true });
|
|
308
317
|
});
|
|
309
318
|
|
|
310
|
-
module.exports = { commands, currentProfile, finishProfile };
|
|
319
|
+
module.exports = { commands, currentProfile, finishProfile, printProfile };
|
package/lib/start.js
CHANGED
|
@@ -148,7 +148,8 @@ async function start(options) {
|
|
|
148
148
|
rl.on('close', () => {
|
|
149
149
|
if (state.stopping) return;
|
|
150
150
|
endPromptLine();
|
|
151
|
-
|
|
151
|
+
// With no terminal (an agent's shell, a pipe), input ends at once: say what runs without one.
|
|
152
|
+
out.error(process.stdin.isTTY ? 'Input ended; disconnecting.' : 'Input ended (stdin is not a terminal); disconnecting. pw-repl serve --background runs it without one.');
|
|
152
153
|
runner.drained().then(() => shutdown(), () => shutdown())
|
|
153
154
|
.then(beforeExit).then(() => process.exit(process.exitCode || 0));
|
|
154
155
|
});
|
package/lib/trace.js
CHANGED
|
@@ -10,6 +10,9 @@ const BIG_REQUEST = 100 * 1024;
|
|
|
10
10
|
const STEP_GAP = 300000;
|
|
11
11
|
// A step's frames and busy time include this long after its last input, for what it drew.
|
|
12
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;
|
|
13
16
|
|
|
14
17
|
const BUCKETS = ['scripting', 'style/layout', 'paint', 'GC', 'other'];
|
|
15
18
|
|
|
@@ -433,8 +436,9 @@ function steps(tracks, events, { taskOf, tasks }) {
|
|
|
433
436
|
const began = e => (e.task >= 0 ? events.ts[e.task] : e.ts);
|
|
434
437
|
let burst = null;
|
|
435
438
|
for (const e of dispatches.filter(d => /^(?:wheel|mousewheel|scroll)$/.test(d.type))) {
|
|
436
|
-
|
|
437
|
-
burst
|
|
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 };
|
|
438
442
|
found.push(burst);
|
|
439
443
|
}
|
|
440
444
|
let down = null;
|
|
@@ -470,6 +474,16 @@ function steps(tracks, events, { taskOf, tasks }) {
|
|
|
470
474
|
presentation: Math.max(0, latency - (processingEnd - startAt)),
|
|
471
475
|
});
|
|
472
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
|
+
}
|
|
473
487
|
found.sort((a, b) => a.start - b.start);
|
|
474
488
|
const taskStarts = tasks.map(t => t.ts);
|
|
475
489
|
const frames = tracks.frames.sort((a, b) => a.ts - b.ts);
|
|
@@ -488,7 +502,14 @@ function steps(tracks, events, { taskOf, tasks }) {
|
|
|
488
502
|
}
|
|
489
503
|
step.frames = 0;
|
|
490
504
|
step.dropped = 0;
|
|
491
|
-
|
|
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++) {
|
|
492
513
|
step.frames += 1;
|
|
493
514
|
if (frames[k].dropped) step.dropped += 1;
|
|
494
515
|
}
|
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
package/skill/SKILL.md
CHANGED
|
@@ -54,12 +54,12 @@ run one; each takes an optional start URL, which opens in a new tab.
|
|
|
54
54
|
- Add `--launch` to `run` or `serve` (with or without `--background`) to have it start a Chromium of its
|
|
55
55
|
own instead of connecting to one: headless unless `--headed`, in a temporary profile, on a free port
|
|
56
56
|
of its own (never 9222). It stops with the REPL, Ctrl-C while it starts included, and `stop` returns
|
|
57
|
-
once it has exited. It prints the command it ran; flags after `--` are passed to that Chromium
|
|
58
|
-
`--user-data-dir=<dir>` among them is used instead of the
|
|
59
|
-
in a kept profile, Chromium picks up where it left off, as
|
|
60
|
-
tabs come back, and session cookies with them, so a login
|
|
61
|
-
attached, so `requests` and `console` have only what
|
|
62
|
-
record.
|
|
57
|
+
once it has exited. It prints the command it ran; flags after `--` are passed to that Chromium (only
|
|
58
|
+
flags: a start URL goes before `--`), and a `--user-data-dir=<dir>` among them is used instead of the
|
|
59
|
+
temporary profile, and kept. Started again in a kept profile, Chromium picks up where it left off, as
|
|
60
|
+
it would for the user, headless too: its tabs come back, and session cookies with them, so a login
|
|
61
|
+
stays. They start loading before the REPL is attached, so `requests` and `console` have only what
|
|
62
|
+
came after, often nothing: `reload` for a full record.
|
|
63
63
|
- `pw-repl run` runs it in a terminal with no server; `send` then reaches it through tmux, if it runs in
|
|
64
64
|
the tmux session `pw-repl`:
|
|
65
65
|
|
|
@@ -112,8 +112,9 @@ of a REPL of your own, which `send` refuses. The words after `send` are the comm
|
|
|
112
112
|
`select`, `press`, `upload` and `wait`, a word quoted in your shell stays one word
|
|
113
113
|
(`pw-repl send fill "text=Your name" Ada`, `pw-repl send wait text "Showing 1"`); other commands get the words as they are, joined by
|
|
114
114
|
spaces (`pw-repl send eval "document.title + ' x'"`). A command that takes only a selector takes the
|
|
115
|
-
whole line, spaces and all: `pw-repl send click "text=Your name"`.
|
|
116
|
-
|
|
115
|
+
whole line, spaces and all: `pw-repl send click "text=Your name"`. `send`'s own options (`-t`, `-c`, `-e`,
|
|
116
|
+
`-s`, `--no-vars`) go before the command or after it (`pw-repl send open <url> -t 30`); after `--`, every
|
|
117
|
+
word is the command's. It works however the REPL was started:
|
|
117
118
|
|
|
118
119
|
- `run` (in tmux): `send` types the command into the tmux pane and reads the result back off the screen.
|
|
119
120
|
It types only when the pane's last line is a bare prompt, so nothing lands in a shell or in the middle
|