pw-repl 0.12.7 → 0.12.8
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 +3 -1
- package/README.md +76 -74
- package/bin/pw-repl.js +56 -33
- package/lib/cli-names.js +3 -3
- package/lib/elements.js +115 -26
- package/lib/emulation.js +2 -1
- package/lib/help.js +114 -47
- package/lib/inspect.js +22 -5
- package/lib/launch.js +3 -1
- package/lib/record.js +1 -1
- package/lib/runner.js +11 -0
- package/lib/send.js +16 -4
- package/lib/start.js +7 -2
- package/lib/syntax.js +11 -5
- package/package.json +1 -1
- package/skill/SKILL.md +27 -40
package/lib/help.js
CHANGED
|
@@ -22,36 +22,50 @@ function wrap(text, width = WIDTH) {
|
|
|
22
22
|
}).join('\n\n');
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
// The common tasks, as the README's table lists them too (a unit test keeps the two the same).
|
|
26
|
+
const TASKS = [
|
|
27
|
+
['see where I am', 'tab, info'],
|
|
28
|
+
['see what is on the page', 'snapshot, screenshot'],
|
|
29
|
+
['do something on it', 'click, fill, type, press, drag'],
|
|
30
|
+
['click what has no selector (a canvas)', 'mouseclick <x> <y>, mousedblclick <x> <y>'],
|
|
31
|
+
['run some JavaScript in the page', 'eval document.title; eval --file=check.js for a long one'],
|
|
32
|
+
['wait for a page or element', 'wait load, wait <selector> [--gone], wait text <text>'],
|
|
33
|
+
['choose a file in a file input', 'upload <selector> <file>'],
|
|
34
|
+
['see it as a phone, or in dark mode', 'emulate mobile, emulate dark'],
|
|
35
|
+
['see what the page requested', 'requests, then body <#> for what one got back'],
|
|
36
|
+
['see console messages and errors', 'console'],
|
|
37
|
+
['show an agent what I do', 'watch on, click around, then watch'],
|
|
38
|
+
['see each step as I click', 'watch on --live'],
|
|
39
|
+
['watch a tab someone will open', 'watch on --next-tab [url-part]'],
|
|
40
|
+
['replay what I clicked through', 'watch save flow.txt, then pw-repl send --file flow.txt'],
|
|
41
|
+
['record requests and console together', 'capture on, then capture off'],
|
|
42
|
+
['find what makes the page slow', 'profile on, then profile off; profile reload for a load'],
|
|
43
|
+
['fake a backend response', 'route <glob> <status> <json>, route <glob> abort'],
|
|
44
|
+
['change or slow an API response', 'route <glob> patch <json>, route <glob> delay <secs>'],
|
|
45
|
+
['cut or slow the whole network', 'network off, network slow, then network on'],
|
|
46
|
+
['record a video of the page', 'record on, then record off (help video for a take)'],
|
|
47
|
+
['show a pointer where the agent acts', 'cursor on, then cursor off'],
|
|
48
|
+
['say what is happening on screen', 'toast <text>, then toast off'],
|
|
49
|
+
['point something out on the page', 'highlight <ref>, then highlight off'],
|
|
50
|
+
['clean up', 'modes off'],
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
// The commands that turn on a mode, as the README lists them too.
|
|
54
|
+
const MODES = 'watch, capture, profile, record, route, network off or slow, emulate, viewport, highlight, cursor and toast';
|
|
55
|
+
|
|
25
56
|
const OVERVIEW = wrap(`pw-repl drives Chromium over CDP. Commands act on the selected tab (\`tab\` shows which).
|
|
26
57
|
The browser may be a shared session, with other users' tabs open in it.
|
|
27
58
|
|
|
28
59
|
Common tasks:
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
choose a file in a file input upload <selector> <file>
|
|
34
|
-
see it as a phone, or dark emulate mobile, emulate dark
|
|
35
|
-
what did the page request? requests, then body <#> for what one got back
|
|
36
|
-
console messages and errors console
|
|
37
|
-
show an agent what I do watch on, click around, then watch
|
|
38
|
-
see each step as I click watch on --live
|
|
39
|
-
watch a tab someone will open watch on --next-tab [url-part]
|
|
40
|
-
requests and console together capture on, then capture off
|
|
41
|
-
a video of what I do help video
|
|
42
|
-
break the backend on purpose route <glob> <status> <json>, route <glob> abort, network off
|
|
43
|
-
change or slow an API response route <glob> patch <json>, route <glob> delay <secs>
|
|
44
|
-
slow the whole network network slow
|
|
45
|
-
clean up modes off
|
|
46
|
-
|
|
47
|
-
Modes (watch, capture, record, route, network off or slow, emulate, viewport) stay on until turned off;
|
|
48
|
-
the prompt shows the selected tab's: (watch routes:1) pw>. tab, watch, capture, record, route, network,
|
|
49
|
-
emulate and modes on their own show their state and what you can run next.
|
|
60
|
+
${TASKS.map(([task, commands]) => ` ${task.padEnd(38)} ${commands}`).join('\n')}
|
|
61
|
+
|
|
62
|
+
Modes (${MODES}) stay on until turned off; the prompt shows the selected tab's: (watch routes:1) pw>.
|
|
63
|
+
Each of them on its own, and modes, shows its state and what you can run next.
|
|
50
64
|
|
|
51
65
|
Topics (help <topic>):
|
|
52
66
|
tabs open, select, close, navigate network requests, bodies, console, fakes, network off
|
|
53
67
|
interact click, fill, type, press, select devtools eval, CDP commands, cookie and storage names
|
|
54
|
-
inspect snapshot, watch,
|
|
68
|
+
inspect snapshot, watch, wait, screenshots session modes, server, clients, quitting
|
|
55
69
|
video record a take, with a pointer
|
|
56
70
|
|
|
57
71
|
help <command> for usage and caveats (e.g. help route), help <command> --all for all of it; help --all
|
|
@@ -71,7 +85,7 @@ tab on its own lists them all.`,
|
|
|
71
85
|
Commands use the first match (click, dblclick and hover the first they can use: help click); see help
|
|
72
86
|
fill for selectors with spaces. mousemove and the other mouse commands act at a point instead. An action
|
|
73
87
|
scrolls its element into view first, as Playwright does; profile counts that scroll in the action's step.`,
|
|
74
|
-
commands: ['click', 'dblclick', 'hover', 'fill', 'type', 'press', 'select', 'check', 'uncheck', 'upload', 'mousemove', 'mouseclick', 'mousedown', 'mouseup', 'mousewheel'],
|
|
88
|
+
commands: ['click', 'dblclick', 'hover', 'drag', 'fill', 'type', 'press', 'select', 'check', 'uncheck', 'upload', 'mousemove', 'mouseclick', 'mousedblclick', 'mousedown', 'mouseup', 'mousewheel'],
|
|
75
89
|
},
|
|
76
90
|
inspect: {
|
|
77
91
|
intro: `Output is capped; --all right after the command, or at the end, shows everything (text --all body).`,
|
|
@@ -124,12 +138,7 @@ still show sensitive data (cdp Network.getCookies shows cookie values).`,
|
|
|
124
138
|
commands: ['eval', 'cdp', 'cookies', 'storage'],
|
|
125
139
|
},
|
|
126
140
|
session: {
|
|
127
|
-
intro: `
|
|
128
|
-
|
|
129
|
-
Unknown outcome: a command that timed out after it began acting on the page may or may not have done
|
|
130
|
-
it. It says so (pw-repl send exits 2), and the REPL carries on.
|
|
131
|
-
|
|
132
|
-
Server: pw-repl serve also takes commands on /tmp/pw-repl.sock; they show here as [server] lines.
|
|
141
|
+
intro: `Server: pw-repl serve also takes commands on /tmp/pw-repl.sock; they show here as [server] lines.
|
|
133
142
|
|
|
134
143
|
Clients: pw-repl send -c <name> (or $PW_CLIENT) sends as a client of its own. It has a selected tab of
|
|
135
144
|
its own, so clients do not move each other's; its commands show as [server:<name>] lines. modes and tab
|
|
@@ -143,7 +152,12 @@ selected tab, if any: (watch network:off routes:2 capture) pw>. modes lists them
|
|
|
143
152
|
|
|
144
153
|
Times (requests, console, watch) are local, with their UTC offset: 18:16:15.721-06:00.
|
|
145
154
|
|
|
146
|
-
Dialogs are reported and never answered on their own; dialog accept or dismiss answers one, from any client, whoever opened it
|
|
155
|
+
Dialogs are reported and never answered on their own; dialog accept or dismiss answers one, from any client, whoever opened it.
|
|
156
|
+
|
|
157
|
+
Unknown outcome: a command that timed out after it began acting on the page may or may not have done
|
|
158
|
+
it. It says so (pw-repl send exits 2), and the REPL carries on.
|
|
159
|
+
|
|
160
|
+
Completion: @<id> <command> makes the REPL print [[pw-done:<id>:ok|error]] when it finishes.`,
|
|
147
161
|
commands: ['modes', 'dialog', 'help', 'quit'],
|
|
148
162
|
},
|
|
149
163
|
};
|
|
@@ -197,6 +211,8 @@ afresh.`,
|
|
|
197
211
|
['[button]', 'left, right or middle, after a ref or a quoted selector: click e5 right. Default: left.'],
|
|
198
212
|
],
|
|
199
213
|
options: [
|
|
214
|
+
['--button=<button>', 'the button, after any selector: click #menu --button=right. Over send, the shell takes the quotes, so pw-repl send click \'#menu\' right clicks a selector "#menu right": use --button, or quote it twice (\'"#menu"\').'],
|
|
215
|
+
['--click-count=<n>', 'clicks this many times at once, as Playwright\'s clickCount: 2 is a double-click (as dblclick), 3 a triple-click, which selects a line of text or all of a field\'s. 1 to 3. Default: 1.'],
|
|
200
216
|
['--modifiers=<keys>', 'keys held down meanwhile, comma-separated: Alt, Control, ControlOrMeta, Meta, Shift.'],
|
|
201
217
|
],
|
|
202
218
|
detail: `Several matches: hidden ones and ones covered by something else (a dialog's overlay) are passed over,
|
|
@@ -208,8 +224,23 @@ it to be ready.
|
|
|
208
224
|
Timed out after it began acting, it says what stopped it (e.g. <div class="overlay"> intercepts pointer
|
|
209
225
|
events) and that the outcome is unknown; Playwright's full call log goes to the REPL's pane or log.`,
|
|
210
226
|
},
|
|
211
|
-
dblclick: {
|
|
227
|
+
dblclick: {
|
|
228
|
+
usage: 'dblclick <selector> [button]',
|
|
229
|
+
summary: 'double-click, as click does, with its options',
|
|
230
|
+
detail: `click --click-count=2, as Playwright's dblclick is. For a triple-click: click --click-count=3.`,
|
|
231
|
+
},
|
|
212
232
|
hover: { usage: 'hover <selector>', summary: 'hover, choosing the match as click does' },
|
|
233
|
+
drag: {
|
|
234
|
+
usage: 'drag <from> <to>',
|
|
235
|
+
summary: 'drag one element onto another',
|
|
236
|
+
args: [
|
|
237
|
+
['<from> <to>', 'selectors or snapshot refs, each quoted if it has spaces: drag e5 e9, drag "text=Item 1" "#done ul".'],
|
|
238
|
+
],
|
|
239
|
+
detail: `As Playwright's dragTo: it hovers the first match of <from>, presses the left button, hovers the
|
|
240
|
+
first match of <to>, and releases, waiting up to 5s in all for each to be ready. An HTML5 drag (a
|
|
241
|
+
draggable element) gets dragstart, dragover and drop; any other gets pointer and mouse events. A drag
|
|
242
|
+
through points of its own: mousemove, mousedown, mousemove, mouseup.`,
|
|
243
|
+
},
|
|
213
244
|
mousemove: {
|
|
214
245
|
usage: 'mousemove <x> <y>',
|
|
215
246
|
summary: 'move the mouse to a point in the viewport',
|
|
@@ -220,7 +251,7 @@ events) and that the outcome is unknown; Playwright's full call log goes to the
|
|
|
220
251
|
eval (r => [r.x + r.width / 2, r.y + r.height / 2])(document.querySelector("#map")
|
|
221
252
|
.getBoundingClientRect())
|
|
222
253
|
|
|
223
|
-
A click at a point: mouseclick <x> <y>. A drag: mousedown, then mousemove to another point, then mouseup.`,
|
|
254
|
+
A click at a point: mouseclick <x> <y>; a double-click: mousedblclick <x> <y>. A drag: mousedown, then mousemove to another point, then mouseup.`,
|
|
224
255
|
},
|
|
225
256
|
mouseclick: {
|
|
226
257
|
usage: 'mouseclick <x> <y> [button]',
|
|
@@ -230,14 +261,30 @@ A click at a point: mouseclick <x> <y>. A drag: mousedown, then mousemove to ano
|
|
|
230
261
|
['[button]', 'left, right or middle. Default: left.'],
|
|
231
262
|
],
|
|
232
263
|
options: [
|
|
264
|
+
['--button=<button>', 'the button, as click\'s: mouseclick 400 300 --button=right.'],
|
|
265
|
+
['--click-count=<n>', 'as click\'s: 2 is a double-click (as mousedblclick), 3 a triple-click. 1 to 3. Default: 1.'],
|
|
233
266
|
['--delay=<ms>', 'holds the button down this long between press and release, as Playwright\'s mouse.click delay. Default: 0.'],
|
|
234
267
|
],
|
|
235
268
|
detail: `For what has no selector, such as a marker drawn on a canvas; anything a selector finds is better
|
|
236
269
|
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
|
|
237
270
|
it. The cursor (help cursor) moves there first and shows a ring at the press.`,
|
|
238
271
|
},
|
|
239
|
-
|
|
240
|
-
|
|
272
|
+
mousedblclick: {
|
|
273
|
+
usage: 'mousedblclick <x> <y> [button]',
|
|
274
|
+
summary: 'double-click at a point in the viewport',
|
|
275
|
+
args: [
|
|
276
|
+
['<x> <y>', 'CSS pixels from the top left of the tab\'s viewport, as for mousemove.'],
|
|
277
|
+
['[button]', 'left, right or middle. Default: left.'],
|
|
278
|
+
],
|
|
279
|
+
options: [
|
|
280
|
+
['--button=<button>', 'the button, as click\'s.'],
|
|
281
|
+
],
|
|
282
|
+
detail: `mouseclick --click-count=2, as Playwright's mouse.dblclick is: the page gets two clicks and a
|
|
283
|
+
dblclick. Two mouseclicks are two single clicks, which fire no dblclick. For a triple-click: mouseclick
|
|
284
|
+
<x> <y> --click-count=3.`,
|
|
285
|
+
},
|
|
286
|
+
mousedown: { usage: 'mousedown [button]', summary: 'press a mouse button at the mouse (left, right, middle)', detail: 'The button is a word or --button=<button>, as for mouseclick.' },
|
|
287
|
+
mouseup: { usage: 'mouseup [button]', summary: 'release a mouse button where the mouse is', detail: 'The button is a word or --button=<button>, as for mouseclick.' },
|
|
241
288
|
mousewheel: {
|
|
242
289
|
usage: 'mousewheel <dx> <dy>',
|
|
243
290
|
summary: 'turn the wheel where the mouse is',
|
|
@@ -259,14 +306,19 @@ scrolled or redrawn. While recording, it scrolls in eased steps over up to 1s (h
|
|
|
259
306
|
['--submit', 'at the end: presses Enter in it afterwards.'],
|
|
260
307
|
],
|
|
261
308
|
detail: `Examples: fill #name Ada Lovelace, fill "text=Your name" Ada, fill e7 Ada. It sets the whole value at
|
|
262
|
-
once, even while recording: type shows it being typed
|
|
309
|
+
once, even while recording: type shows it being typed.
|
|
310
|
+
|
|
311
|
+
At the prompt and in a send --file line, \\n, \\t and \\r in double quotes are a newline, a tab and a
|
|
312
|
+
carriage return (fill #notes "line 1\\nline 2"), and \\\\ is a backslash. Over send, the shell's quoting comes
|
|
313
|
+
first, and its "a\\nb" is a literal \\n, typed as a backslash and an n: give it a real newline, as
|
|
314
|
+
$'line 1\\nline 2'.`,
|
|
263
315
|
},
|
|
264
316
|
type: {
|
|
265
317
|
usage: 'type [--delay=<ms>] [<selector>] <text> [--submit]',
|
|
266
318
|
summary: 'type key by key after what the field holds',
|
|
267
319
|
args: [
|
|
268
320
|
['[<selector>]', 'as for fill. Without it (one word, or one quoted), into what has focus: type "garden hose".'],
|
|
269
|
-
['<text>', 'the rest of the line, as fill\'s value.'],
|
|
321
|
+
['<text>', 'the rest of the line, as fill\'s value: a newline in it presses Enter, which in a textarea starts a new line (help fill says how to give one).'],
|
|
270
322
|
],
|
|
271
323
|
options: [
|
|
272
324
|
['--delay=<ms>', 'right after type: the gap between keys. Default: none, or 60 while the tab records.'],
|
|
@@ -324,6 +376,9 @@ all.`,
|
|
|
324
376
|
to the tab whose snapshot gave it, and can change when the page changes (e102 may become f4e98): take a
|
|
325
377
|
new snapshot after it does.
|
|
326
378
|
|
|
379
|
+
Quotes: some names and text are quoted, YAML-style, as one with a quote mark in it is, and their quotes
|
|
380
|
+
are escaped: paragraph: "Say \\"hi\\"" is the text Say "hi" on the page, with no backslashes.
|
|
381
|
+
|
|
327
382
|
--grep: a hit with no named element around it prints without a path. A hit on text (a label) keeps the
|
|
328
383
|
ref of the element holding it, so snapshot <that ref> shows what sits beside it, such as the value next
|
|
329
384
|
to the label.
|
|
@@ -381,7 +436,14 @@ one no selector finds alone) stays a comment saying why, and the save says how m
|
|
|
381
436
|
ends with the last step, not with what it brought: to end on a result that comes later (search results
|
|
382
437
|
loading), add a wait for it at the end (wait text ...).
|
|
383
438
|
|
|
384
|
-
|
|
439
|
+
Watching someone: read a response body over 100 KB that matters as soon as it shows in requests, since
|
|
440
|
+
the browser drops it once the tab navigates. watch leaves out images and other static files, failed ones
|
|
441
|
+
too; console error and requests --all show what failed. Without a url-part, --next-tab takes the first
|
|
442
|
+
tab anyone opens: check tab and info once it fires, since another tab opening later does not show in
|
|
443
|
+
watch.
|
|
444
|
+
|
|
445
|
+
Shared: every client sees the same steps, and watch new's place is shared: when another client watches
|
|
446
|
+
the tab too, read it with watch <n>, since each one's watch new would hide steps from the other.`,
|
|
385
447
|
moreAbout: 'bodies kept, --live timing, what --changes misses, one --next-tab at a time, and what watch save leaves out',
|
|
386
448
|
more: `From its first watch on, a tab keeps response bodies of up to 100 KB, so body <#> still shows one
|
|
387
449
|
after the tab navigates, even when the page navigates as soon as the request answers.
|
|
@@ -526,9 +588,9 @@ shows in screenshots and recordings. Selectors, snapshot and text don't see it,
|
|
|
526
588
|
],
|
|
527
589
|
detail: `The REPL's clicks and typing don't move a pointer on screen, so a recording shows things happening
|
|
528
590
|
with nothing pointing at them. With the cursor on, these commands move it smoothly to their element or
|
|
529
|
-
point before they act: click, dblclick, hover, fill, type <selector>, press <selector>, select, check,
|
|
530
|
-
uncheck, upload, mousemove and
|
|
531
|
-
records), and the command waits for it. Clicks (click, dblclick, check, uncheck, mouseclick, mousedown, and upload
|
|
591
|
+
point before they act: click, dblclick, hover, drag (to each end), fill, type <selector>, press <selector>, select, check,
|
|
592
|
+
uncheck, upload, mousemove, mouseclick and mousedblclick. Each move takes 150 to 600ms by distance (300 to 1000ms while the tab
|
|
593
|
+
records), and the command waits for it. Clicks (click, dblclick, check, uncheck, mouseclick, mousedblclick, mousedown, and upload
|
|
532
594
|
when it clicks a button) show a ring where they land.
|
|
533
595
|
|
|
534
596
|
Only the drawing moves: no mouse events are sent along the way, so the page gets the same input as with
|
|
@@ -607,13 +669,15 @@ goes on recording. There is no --mp4 or --webm: the file's ending picks the form
|
|
|
607
669
|
summary: 'show, set or unset the viewport size',
|
|
608
670
|
args: [
|
|
609
671
|
['<W>x<H>', 'sets the selected tab\'s page to that size, until viewport off, modes off or the REPL exits. The window is not resized.'],
|
|
610
|
-
['off', 'back to the window\'s
|
|
611
|
-
['(none)', 'shows the size, and whether it was set or is the window\'s.'],
|
|
672
|
+
['off', 'back to the window\'s page area.'],
|
|
673
|
+
['(none)', 'shows the size, and whether it was set or is the window\'s page area.'],
|
|
612
674
|
],
|
|
613
675
|
detail: `emulate mobile sets a phone's size instead, and each refuses while the other is on.
|
|
614
676
|
|
|
615
|
-
The window's
|
|
677
|
+
The window's page area is the page's size without a viewport set. Chromium's --window-size flag sizes the whole
|
|
616
678
|
window, frame included, so the page is smaller: --window-size=1280,900 gives 1280x757 in a headless one.
|
|
679
|
+
--launch starts its Chromium at --window-size=1280,800 (a page of 1280x657 headless), unless the flags
|
|
680
|
+
after -- give one; a profile it was given and has used before may keep its own last size.
|
|
617
681
|
For a page of an exact size, use viewport <W>x<H>.`,
|
|
618
682
|
},
|
|
619
683
|
emulate: {
|
|
@@ -832,9 +896,10 @@ in front of it usually holds the request open, so the page spins instead of fail
|
|
|
832
896
|
summary: 'evaluate JavaScript in the selected tab and print the result',
|
|
833
897
|
args: [
|
|
834
898
|
['<JavaScript>', 'the rest of the line. Promises are awaited, await works at the top level, and a function (() => document.title) is called.'],
|
|
835
|
-
['<function> <ref>', 'calls it with that element: eval "el => el.textContent" e5.'],
|
|
899
|
+
['<function> <ref>', 'calls it with that element: eval "el => el.textContent" e5. Quoted, its \\n is a newline and its \\" a quote: write \\\\n for a JS string\'s \\n.'],
|
|
836
900
|
],
|
|
837
901
|
options: [
|
|
902
|
+
['--file=<path>', 'the JavaScript is the file\'s text: anything the line takes, over many lines and with // comments. With a ref or a selector after the path, it is a function called with that element (the first match). Over send, a relative path is from your shell\'s current folder.'],
|
|
838
903
|
['--all', 'the whole result, not capped.'],
|
|
839
904
|
],
|
|
840
905
|
detail: `eval fetch(...) sends a new request, which can change state on the server; it cannot read one that
|
|
@@ -990,9 +1055,11 @@ Different on purpose, since the browser may be someone else's too:
|
|
|
990
1055
|
if (Object.hasOwn(TOPICS, topicOrCommand)) {
|
|
991
1056
|
const topic = TOPICS[topicOrCommand];
|
|
992
1057
|
const text = [topic.intro, '', ...commandLines(topic.commands)].join('\n');
|
|
993
|
-
// A topic can share its name with a command (network)
|
|
994
|
-
return Object.hasOwn(COMMANDS, topicOrCommand) ? `${text}\n\n${
|
|
1058
|
+
// A topic can share its name with a command (network): the topic, and where the command's help is.
|
|
1059
|
+
return Object.hasOwn(COMMANDS, topicOrCommand) ? `${text}\n\n${topicOrCommand} is also a command: help ${topicOrCommand} --command` : text;
|
|
995
1060
|
}
|
|
1061
|
+
const asCommand = /^(\S+)\s+--command$/.exec(topicOrCommand);
|
|
1062
|
+
if (asCommand && Object.hasOwn(COMMANDS, asCommand[1])) return renderCommand(asCommand[1]);
|
|
996
1063
|
if (Object.hasOwn(COMMANDS, topicOrCommand)) return renderCommand(topicOrCommand);
|
|
997
1064
|
// Not a topic: the mouse commands are in interact, with the others that act on the page.
|
|
998
1065
|
if (topicOrCommand === 'mouse') {
|
|
@@ -1020,4 +1087,4 @@ function notFound(topic) {
|
|
|
1020
1087
|
return `No help for ${topic}. Topics: ${Object.keys(TOPICS).join(', ')}`;
|
|
1021
1088
|
}
|
|
1022
1089
|
|
|
1023
|
-
module.exports = { render, notFound, TOPICS, COMMANDS };
|
|
1090
|
+
module.exports = { render, notFound, TOPICS, COMMANDS, TASKS, MODES };
|
package/lib/inspect.js
CHANGED
|
@@ -484,15 +484,32 @@ const commands = {
|
|
|
484
484
|
|
|
485
485
|
async eval(args, all) {
|
|
486
486
|
if (!args) throw new Error('Usage: eval <js-expression>');
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
487
|
+
let ref = null;
|
|
488
|
+
// eval --file=<path> [<ref or selector>]: the file's text is what the line would be, over as many lines as
|
|
489
|
+
// it likes. The rest of the line can only be the element, so any selector goes there, not only a ref.
|
|
490
|
+
const fromFile = /^--file(?:=|\s+)("(?:[^"\\]|\\.)*"|'[^']*'|\S+)(?:\s+([\s\S]*))?$/.exec(args.trim());
|
|
491
|
+
if (/^--file(?:[=\s]|$)/.test(args.trim())) {
|
|
492
|
+
if (!fromFile) throw new Error('Usage: eval --file=<path> [<ref or selector>]: the JavaScript is in the file');
|
|
493
|
+
const file = path.resolve(unquote(fromFile[1]));
|
|
494
|
+
const target = fromFile[2]?.trim() || null;
|
|
495
|
+
try { args = fs.readFileSync(file, 'utf8').trim(); } catch (error) {
|
|
496
|
+
// eval --file=my dir/x.js reads "my" as the file and "dir/x.js" as the element.
|
|
497
|
+
throw new Error(`eval --file: cannot read ${file} (${error.code || error.message})${target ? '; quote a path with spaces' : ''}`);
|
|
498
|
+
}
|
|
499
|
+
if (!args) throw new Error(`eval --file: ${file} is empty`);
|
|
500
|
+
ref = target && unquote(target);
|
|
501
|
+
} else {
|
|
502
|
+
// eval <function> <ref> calls the function with that element, as playwright-cli's eval does.
|
|
503
|
+
const onRef = /^([\s\S]+?)\s+((?:f\d+)?e\d+)$/.exec(args.trim());
|
|
504
|
+
if (onRef && isFunctionText(unquote(onRef[1]))) [args, ref] = [unquote(onRef[1]), onRef[2]];
|
|
505
|
+
}
|
|
506
|
+
if (ref) {
|
|
507
|
+
const selector = toSelector(ref);
|
|
491
508
|
const el = await onElement(selector, () => state.page.locator(selector).first().elementHandle({ timeout: 5000 }));
|
|
492
509
|
try {
|
|
493
510
|
// Made from its text by the debugger, as eval is, so a page's CSP cannot
|
|
494
511
|
// refuse it, and in the element's own frame, which may be an iframe.
|
|
495
|
-
const fn = await evaluated((await el.ownerFrame()).evaluateHandle(
|
|
512
|
+
const fn = await evaluated((await el.ownerFrame()).evaluateHandle(args));
|
|
496
513
|
try {
|
|
497
514
|
printOutput(await el.evaluate((e, f) => f(e), fn), all);
|
|
498
515
|
} finally {
|
package/lib/launch.js
CHANGED
|
@@ -139,7 +139,9 @@ async function launch({ headed = false, extraArgs = [] }) {
|
|
|
139
139
|
const onExit = () => { if (group) killGroup(group); try { removeProfile(); } catch {} };
|
|
140
140
|
process.on('exit', onExit);
|
|
141
141
|
const onSpawn = proc => { group = proc.pid; };
|
|
142
|
-
|
|
142
|
+
// A laptop's window, rather than Chromium's headless 800x600 or so, unless the flags after -- size it.
|
|
143
|
+
const sized = extraArgs.some(a => a.startsWith('--window-size=')) ? [] : ['--window-size=1280,800'];
|
|
144
|
+
const ours = [...(headed ? [] : ['--headless=new']), ...sized, '--remote-debugging-port=0', ...(given ? [] : [`--user-data-dir=${profile}`]),
|
|
143
145
|
'--no-first-run', '--no-default-browser-check', ...(smallShm() ? ['--disable-dev-shm-usage'] : [])];
|
|
144
146
|
// A profile already used has tabs to restore; a blank tab of its own each time would pile up there.
|
|
145
147
|
// Local State is written at its root on the first run, whichever --profile-directory is used.
|
package/lib/record.js
CHANGED
|
@@ -391,7 +391,7 @@ function videoTime(rec) {
|
|
|
391
391
|
// What a step is, for its line: the command and what it acts on, never what it types or chooses, the
|
|
392
392
|
// code it runs or the URL it goes to, which can hold a password or a token. A steps file goes where the
|
|
393
393
|
// video goes, and the video shows a password as dots.
|
|
394
|
-
const WHOLE = new Set(['click', 'dblclick', 'hover', 'check', 'uncheck', 'mousemove', 'mouseclick', 'mousedown', 'mouseup', 'mousewheel']);
|
|
394
|
+
const WHOLE = new Set(['click', 'dblclick', 'hover', 'drag', 'check', 'uncheck', 'mousemove', 'mouseclick', 'mousedblclick', 'mousedown', 'mouseup', 'mousewheel']);
|
|
395
395
|
const TARGET_FIRST = new Set(['fill', 'type', 'select', 'press', 'upload']);
|
|
396
396
|
function stepLabel(cmd, args) {
|
|
397
397
|
if (WHOLE.has(cmd)) {
|
package/lib/runner.js
CHANGED
|
@@ -38,6 +38,8 @@ const NO_TAB_NEEDED = new Set(['tab', 'modes', 'capture', 'profile', 'record', '
|
|
|
38
38
|
// Commands that accept --all, right after the command or at the end, to lift the output limit.
|
|
39
39
|
const INSPECTION = ['info', 'listeners', 'text', 'html', 'attrs', 'links', 'inputs', 'eval', 'cdp', 'cookies', 'storage', 'capture', 'profile', 'body', 'console', 'snapshot', 'watch'];
|
|
40
40
|
|
|
41
|
+
const SUBCOMMANDS = ['run', 'serve', 'attach', 'stop', 'send', 'where', 'skill'];
|
|
42
|
+
|
|
41
43
|
let queue = Promise.resolve();
|
|
42
44
|
// The server command running now, so a quit at the prompt can answer it.
|
|
43
45
|
let inFlight = null;
|
|
@@ -100,6 +102,11 @@ async function executeCommand(text) {
|
|
|
100
102
|
state.currentCommandAt = Date.now();
|
|
101
103
|
const run = Object.hasOwn(commands, cmd) ? commands[cmd] : translated && Object.hasOwn(cliCommands, cmd) ? cliCommands[cmd] : null;
|
|
102
104
|
if (!run) {
|
|
105
|
+
// An agent fresh from the skill tries send where; those are the command line's, not the REPL's.
|
|
106
|
+
if (SUBCOMMANDS.includes(cmd)) {
|
|
107
|
+
out.error(`Error: ${cmd} is a pw-repl subcommand, run at the shell: pw-repl ${cmd}${args ? ' ...' : ''}`);
|
|
108
|
+
return { status: 'error', cmd };
|
|
109
|
+
}
|
|
103
110
|
out.log(`Unknown command: ${cmd}. Type 'help' for commands.`);
|
|
104
111
|
return { status: 'error', cmd };
|
|
105
112
|
}
|
|
@@ -117,6 +124,10 @@ async function executeCommand(text) {
|
|
|
117
124
|
stepEnded(step, true);
|
|
118
125
|
// Playwright colours its call log; the codes are noise anywhere but a terminal.
|
|
119
126
|
e.message = String(e.message || '').replace(/\x1b\[[0-9;]*m/g, '');
|
|
127
|
+
// A page's own error (eval's SyntaxError, a cdp method not found) without Playwright's method name
|
|
128
|
+
// or the stack of its injected script, which point at Playwright, not at the line that was sent.
|
|
129
|
+
e.message = e.message.split('\n').filter(line => !/^\s+at /.test(line)).join('\n')
|
|
130
|
+
.replace(/^(?:page|frame|elementHandle|jsHandle|cdpSession)\.(?:evaluate|evaluateHandle|send): /, '');
|
|
120
131
|
out.error(`Error: ${e.message}`);
|
|
121
132
|
const uncertain = !READ_ONLY.has(cmd) && isUncertain(e);
|
|
122
133
|
if (uncertain) out.error('Outcome unknown: it timed out after it began acting on the page. Check the page before trying again.');
|
package/lib/send.js
CHANGED
|
@@ -88,6 +88,11 @@ async function viaTmux(session, command, timeoutMs) {
|
|
|
88
88
|
return 2;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
// A word in double quotes, as the REPL reads them (unescapeDouble): every other character goes as it is.
|
|
92
|
+
function doubleQuote(word) {
|
|
93
|
+
return `"${word.replace(/[\\"]/g, '\\$&').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t')}"`;
|
|
94
|
+
}
|
|
95
|
+
|
|
91
96
|
// A command's words as send was given them, made into the line the REPL reads: quoted again where the
|
|
92
97
|
// shell had kept a word whole, and with the files it reads or writes made absolute from the sender's
|
|
93
98
|
// folder, since the REPL's may be another.
|
|
@@ -100,7 +105,7 @@ function commandLine(words) {
|
|
|
100
105
|
// So does playwright-cli's storage key (localstorage-set "my key" v).
|
|
101
106
|
// And record's file, which may have spaces in it, and wait's text, whose last word would otherwise be
|
|
102
107
|
// taken as the seconds when it is a number (wait text 'Showing 1').
|
|
103
|
-
const requote = words.length > 1 && (SELECTOR_FIRST.has(words[0]) || /^(?:local|session)storage-/.test(words[0]) || words[0] === 'record' || words[0] === 'wait' || /^video-(?:start|stop)$/.test(words[0]));
|
|
108
|
+
const requote = words.length > 1 && (SELECTOR_FIRST.has(words[0]) || words[0] === 'drag' || /^(?:local|session)storage-/.test(words[0]) || words[0] === 'record' || words[0] === 'wait' || /^video-(?:start|stop)$/.test(words[0]));
|
|
104
109
|
// An empty word (fill #name "") is the empty value.
|
|
105
110
|
// upload's files are read by the REPL, whose folder may not be this one.
|
|
106
111
|
// So are the files upload reads and screenshot --filename writes.
|
|
@@ -117,15 +122,21 @@ function commandLine(words) {
|
|
|
117
122
|
// watch save and profile save write their files.
|
|
118
123
|
if (words[0] === 'profile' && words[1] === 'save' && words.length > 2) resolved = ['profile', 'save', resolve(words.slice(2).join(' '))];
|
|
119
124
|
if (words[0] === 'watch' && words[1] === 'save' && words.length > 2) resolved = ['watch', 'save', resolve(words.slice(2).join(' '))];
|
|
125
|
+
// So does the file eval --file reads.
|
|
126
|
+
if (words[0] === 'eval' && /^--file(?:=|$)/.test(words[1] || '')) {
|
|
127
|
+
// As one word, --file=<path>, which is quoted below when the path has spaces.
|
|
128
|
+
const [file, rest] = words[1] === '--file' ? [words[2], words.slice(3)] : [words[1].slice(7), words.slice(2)];
|
|
129
|
+
if (file) resolved = ['eval', `--file=${resolve(file)}`, ...rest];
|
|
130
|
+
}
|
|
120
131
|
if (words[0] === 'screenshot') {
|
|
121
132
|
resolved = words.map((w, n) => (w.startsWith('--filename=') && w.length > 11 ? `--filename=${resolve(w.slice(11))}` : words[n - 1] === '--filename' ? resolve(w) : w));
|
|
122
133
|
}
|
|
123
|
-
const quoted = requote ? resolved.map(w => (w === '' || /[\s"']/.test(w) ?
|
|
134
|
+
const quoted = requote ? resolved.map(w => (w === '' || /[\s"']/.test(w) ? doubleQuote(w) : w)) : resolved;
|
|
124
135
|
// An option's value the shell kept whole stays whole too: --device='iPhone 15'.
|
|
125
136
|
// Single quotes escape nothing, so a backslash (a regexp's \d) reaches it as typed.
|
|
126
137
|
for (const [n, w] of quoted.entries()) {
|
|
127
138
|
const option = /^(--[A-Za-z][\w-]*=)([\s\S]*\s[\s\S]*)$/.exec(w);
|
|
128
|
-
if (n && option && !requote) quoted[n] = `${option[1]}${option[2].includes("'") ?
|
|
139
|
+
if (n && option && !requote) quoted[n] = `${option[1]}${option[2].includes("'") ? doubleQuote(option[2]) : `'${option[2]}'`}`;
|
|
129
140
|
}
|
|
130
141
|
return quoted.join(' ').trim();
|
|
131
142
|
}
|
|
@@ -155,7 +166,8 @@ async function sendFile(options) {
|
|
|
155
166
|
console.log(`> ${line}`);
|
|
156
167
|
// Read as the prompt reads it; a file it reads or writes is the sender's, as on send's command line.
|
|
157
168
|
const words = shellWords(line);
|
|
158
|
-
|
|
169
|
+
// eval's own line keeps its quotes; only eval --file names a file.
|
|
170
|
+
const command = PATHS.test(words[0]) || (words[0] === 'eval' && /^--file(?:=|$)/.test(words[1] || '')) ? commandLine(words) : line;
|
|
159
171
|
const found = options.noVars ? null : variablesFor(command);
|
|
160
172
|
if (found?.error) { console.error(`pw-repl: stopped at line ${i + 1} of ${options.file} (${line}): ${found.error}`); return 1; }
|
|
161
173
|
const status = await send({ ...options, file: null, command, vars: found?.vars || null });
|
package/lib/start.js
CHANGED
|
@@ -5,6 +5,7 @@ const { state, withTimeout, shutdown, onBeforeExit, beforeExit } = require('./st
|
|
|
5
5
|
const out = require('./output');
|
|
6
6
|
const { watchPage, complete } = require('./commands');
|
|
7
7
|
const { listTabs, openTab, markStartTab } = require('./tabs');
|
|
8
|
+
const { tabState } = require('./tabstate');
|
|
8
9
|
const runner = require('./runner');
|
|
9
10
|
|
|
10
11
|
// A browser --launch starts has its own address instead.
|
|
@@ -88,10 +89,14 @@ async function start(options) {
|
|
|
88
89
|
|
|
89
90
|
if (START_URL) {
|
|
90
91
|
state.startingStep = 'opening the start URL';
|
|
91
|
-
|
|
92
|
+
// A Chromium the REPL launched has only its first blank tab, which nobody else is using: the start
|
|
93
|
+
// URL goes there, rather than into a second tab behind a blank one.
|
|
94
|
+
const first = state.launched && pages.length === 1 && pages[0].url() === 'about:blank' ? pages[0] : null;
|
|
95
|
+
if (first) Object.assign(tabState(first), { opened: true, owner: state.client });
|
|
96
|
+
state.page = first || await openTab();
|
|
92
97
|
markStartTab(state.page);
|
|
93
98
|
await state.page.goto(START_URL, { waitUntil: 'networkidle', timeout: 15000 });
|
|
94
|
-
out.log(`Opened ${state.page.url()} in a new tab`);
|
|
99
|
+
out.log(`Opened ${state.page.url()}${first ? '' : ' in a new tab'}`);
|
|
95
100
|
}
|
|
96
101
|
|
|
97
102
|
out.log('');
|
package/lib/syntax.js
CHANGED
|
@@ -11,25 +11,31 @@ function toSelector(word) {
|
|
|
11
11
|
return REF.test(word) ? `aria-ref=${word}` : word;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
//
|
|
14
|
+
// Inside double quotes, \n, \t and \r are a newline, tab and carriage return, so a value send quotes
|
|
15
|
+
// keeps them on one line; any other \X is X (\" a quote, \\ a backslash).
|
|
16
|
+
function unescapeDouble(text) {
|
|
17
|
+
return text.replace(/\\(.)/g, (_, c) => (c === 'n' ? '\n' : c === 't' ? '\t' : c === 'r' ? '\r' : c));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Quotes around the whole of a value are removed.
|
|
15
21
|
function unquote(text) {
|
|
16
22
|
const match = /^"((?:[^"\\]|\\.)*)"$|^'([^']*)'$/.exec(text);
|
|
17
23
|
if (!match) return text;
|
|
18
|
-
return match[1] !== undefined ? match[1]
|
|
24
|
+
return match[1] !== undefined ? unescapeDouble(match[1]) : match[2];
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
// The words of a line as a shell reads them: quoted parts may hold spaces, and lose their quotes
|
|
22
28
|
// (--filename="my dir/a.webm" is one word, --filename=my dir/a.webm).
|
|
23
29
|
function shellWords(text) {
|
|
24
30
|
return [...(text || '').matchAll(/(?:"(?:[^"\\]|\\.)*"|'[^']*'|[^\s"']+|["'])+/g)]
|
|
25
|
-
.map(m => m[0].replace(/"((?:[^"\\]|\\.)*)"|'([^']*)'/g, (_, double, single) => (double !== undefined ? double
|
|
31
|
+
.map(m => m[0].replace(/"((?:[^"\\]|\\.)*)"|'([^']*)'/g, (_, double, single) => (double !== undefined ? unescapeDouble(double) : single)));
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
// The first word of args, or a quoted selector with spaces in it, and the rest of the line.
|
|
29
35
|
function splitSelector(args) {
|
|
30
36
|
const match = /^(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+))(?:\s+([\s\S]*))?$/.exec((args || '').trim());
|
|
31
37
|
if (!match) return null;
|
|
32
|
-
const word = match[1] !== undefined ? match[1]
|
|
38
|
+
const word = match[1] !== undefined ? unescapeDouble(match[1]) : match[2] !== undefined ? match[2] : match[3];
|
|
33
39
|
return { word, selector: toSelector(word), rest: match[4] };
|
|
34
40
|
}
|
|
35
41
|
|
|
@@ -83,4 +89,4 @@ function expandVariables(line, values) {
|
|
|
83
89
|
});
|
|
84
90
|
}
|
|
85
91
|
|
|
86
|
-
module.exports = { SELECTOR_FIRST, REF, toSelector, unquote, shellWords, splitSelector, keyName, variablesIn, expandVariables };
|
|
92
|
+
module.exports = { SELECTOR_FIRST, REF, toSelector, unescapeDouble, unquote, shellWords, splitSelector, keyName, variablesIn, expandVariables };
|