pw-repl 0.10.0 → 0.11.1
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 -2
- package/README.md +33 -19
- package/bin/pw-repl.js +13 -1
- package/lib/background.js +14 -4
- package/lib/cdp.js +6 -0
- package/lib/cli-names.js +1 -1
- package/lib/client.js +3 -2
- package/lib/commands.js +3 -0
- package/lib/cursor.js +21 -47
- package/lib/elements.js +45 -8
- package/lib/help.js +85 -19
- package/lib/inspect.js +35 -4
- package/lib/modes.js +6 -1
- package/lib/output.js +1 -1
- package/lib/overlay.js +79 -0
- package/lib/record.js +13 -2
- package/lib/runner.js +16 -9
- package/lib/send.js +27 -4
- package/lib/server.js +12 -4
- package/lib/skill.js +1 -1
- package/lib/start.js +4 -1
- package/lib/syntax.js +30 -1
- package/lib/tabs.js +24 -8
- package/lib/toast.js +166 -0
- package/lib/watch.js +210 -29
- package/package.json +1 -1
- package/skill/SKILL.md +61 -44
package/lib/help.js
CHANGED
|
@@ -23,7 +23,7 @@ function wrap(text, width = WIDTH) {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
const OVERVIEW = wrap(`playwright-repl drives Chromium over CDP. Commands act on the selected tab (\`tab\` shows which).
|
|
26
|
-
The browser may be a shared session, with other
|
|
26
|
+
The browser may be a shared session, with other users' tabs open in it.
|
|
27
27
|
|
|
28
28
|
Common tasks:
|
|
29
29
|
where am I? tab, info
|
|
@@ -85,8 +85,9 @@ the selectors it needs (#id, text=..., role=...), not snapshot refs: the take st
|
|
|
85
85
|
page, where the refs are stale.
|
|
86
86
|
|
|
87
87
|
2. Write the take, one command per line, then run it: pw-repl send --file take.txt (pw-repl help, outside
|
|
88
|
-
the REPL, has the rest). Its first
|
|
89
|
-
so the take does not start from the rehearsal's end.
|
|
88
|
+
the REPL, has the rest). Its first lines put the page back as it was before the rehearsal (goto <url>),
|
|
89
|
+
so the take does not start from the rehearsal's end. A toast, the cursor and highlights stay up across
|
|
90
|
+
page loads: turn off any the rehearsal left (toast off, cursor off, highlight off) before record on.
|
|
90
91
|
|
|
91
92
|
goto https://shop.example/support/new
|
|
92
93
|
record on clip.webm
|
|
@@ -100,7 +101,8 @@ so the take does not start from the rehearsal's end.
|
|
|
100
101
|
3. While it records, pw-repl paces it for a viewer (help record): type types at a readable pace (use it,
|
|
101
102
|
not fill, for text the viewer should see typed), each action is followed by a pause, an element out of
|
|
102
103
|
view is scrolled to smoothly, and the video starts and ends on a still page. highlight <selector> draws a
|
|
103
|
-
box to point at something
|
|
104
|
+
box to point at something, and toast <text> a line of text saying what is happening, up for as long as
|
|
105
|
+
the viewer needs to read it. After a step that loads a page, wait for text only the result shows (wait
|
|
104
106
|
text ...), not a heading the page always has, and not wait load: a single-page app loads nothing.
|
|
105
107
|
|
|
106
108
|
record on --steps also saves when and where each step ran, in the video's seconds and pixels, for
|
|
@@ -109,7 +111,7 @@ editing it afterwards.
|
|
|
109
111
|
The user's own screen, with their real pointer, takes a screen recorder they run (OBS, ffmpeg -f
|
|
110
112
|
x11grab, macOS screencapture -v): ask first. playwright-cli itself is a separate tool that records a
|
|
111
113
|
clean browser of its own (here, its video-start is record on).`,
|
|
112
|
-
commands: ['record', 'cursor'],
|
|
114
|
+
commands: ['record', 'cursor', 'toast'],
|
|
113
115
|
},
|
|
114
116
|
network: {
|
|
115
117
|
intro: `requests and console record all the time; a capture records only while it runs.`,
|
|
@@ -150,14 +152,21 @@ const COMMANDS = {
|
|
|
150
152
|
usage: 'tab [<index>|<url-part>|new [url]|close [url-part]]',
|
|
151
153
|
summary: 'list tabs (* is selected); select, open or close one',
|
|
152
154
|
args: [
|
|
153
|
-
['(none)', 'lists the tabs: * marks the selected one, and each says who opened it with tab new.'],
|
|
155
|
+
['(none)', 'lists the tabs: * marks the selected one, (in front) the one the browser shows, and each says who opened it with tab new.'],
|
|
154
156
|
['<index>', 'selects by the number in your latest listing (each client has its own); numbers change when tabs open or close.'],
|
|
155
157
|
['<url-part>', 'selects the one tab whose URL contains it; refused if none or several do.'],
|
|
156
158
|
['new [url]', 'opens a tab behind the one in front, so it does not take the window from whoever is using it, and selects it. The URL is read as goto reads it.'],
|
|
157
159
|
['close [url-part]', 'closes the selected tab, or the one matching url-part.'],
|
|
158
160
|
],
|
|
159
161
|
detail: `Closing the selected tab goes back to the previous tab if tab new opened it; otherwise no tab is
|
|
160
|
-
selected, and commands that need one refuse until one is
|
|
162
|
+
selected, and commands that need one refuse until one is.
|
|
163
|
+
|
|
164
|
+
In front: (in front) marks the tab the browser shows in its window, the one the user sees; (in front,
|
|
165
|
+
focused) when its window has the keyboard too, and it is not in the address bar. A tab a client has
|
|
166
|
+
selected or opened is never marked: pw-repl tells it that it is in front, so that it keeps drawing and
|
|
167
|
+
clicks in it work while it is behind another, and so cannot ask it. The user's other tabs are told
|
|
168
|
+
nothing, and say truly whether they are seen. Each window has a tab in front, so with several windows
|
|
169
|
+
(an incognito one, say) several tabs are marked; focused is the one with the keyboard.`,
|
|
161
170
|
},
|
|
162
171
|
goto: {
|
|
163
172
|
usage: 'goto <url>',
|
|
@@ -190,7 +199,8 @@ afresh.`,
|
|
|
190
199
|
['--modifiers=<keys>', 'keys held down meanwhile, comma-separated: Alt, Control, ControlOrMeta, Meta, Shift.'],
|
|
191
200
|
],
|
|
192
201
|
detail: `Several matches: hidden ones and ones covered by something else (a dialog's overlay) are passed over,
|
|
193
|
-
and it says which it used: Clicked: text=Buy (match 3 of 4; ...)
|
|
202
|
+
and it says which it used: Clicked: text=Buy (match 3 of 4; ...), or names the first when it was the
|
|
203
|
+
one: (the first of 2 matches: <h1> Buy), as it may not be the one meant. If none can be clicked within 5s, it
|
|
194
204
|
says why for each and does nothing. A ref, or a single match, is clicked as it is, waiting up to 5s for
|
|
195
205
|
it to be ready.
|
|
196
206
|
|
|
@@ -252,7 +262,11 @@ once, even while recording: type shows it being typed.`,
|
|
|
252
262
|
The whole text takes its length times the gap, and send waits 20s by default: about 300 characters at
|
|
253
263
|
60ms need a longer send -t, or a shorter --delay. --delay is a gap between keys, not a wait before
|
|
254
264
|
starting like screenshot's. A page that reacts to each key, such as search as you type, may react to
|
|
255
|
-
each one
|
|
265
|
+
each one.
|
|
266
|
+
|
|
267
|
+
Date and time inputs: they take keys part by part, in the order they show them (10/02/2026 in en-US),
|
|
268
|
+
from the first part with a selector, or from the part a click landed on. type says what the input then
|
|
269
|
+
holds. Their ISO form (2026-10-02) is refused, since typed it comes out mangled: fill sets it.`,
|
|
256
270
|
},
|
|
257
271
|
press: { usage: 'press <key> | press <selector> <key>', summary: 'press a key (Enter, Escape, Control+A; any case), maybe on an element' },
|
|
258
272
|
select: {
|
|
@@ -299,21 +313,28 @@ new snapshot after it does.
|
|
|
299
313
|
ref of the element holding it, so snapshot <that ref> shows what sits beside it, such as the value next
|
|
300
314
|
to the label.
|
|
301
315
|
|
|
316
|
+
Passwords: a password field's value is shown as (a password: not shown), --full too, as is that of a
|
|
317
|
+
field whose autocomplete is for a password, a one-time code or a card number; html and attrs show its
|
|
318
|
+
value attribute so too. A field a Show password button made plain text, with no such autocomplete,
|
|
319
|
+
shows its value.
|
|
320
|
+
|
|
302
321
|
Output over 60 lines ends with a line count.`,
|
|
303
322
|
},
|
|
304
323
|
watch: {
|
|
305
|
-
usage: 'watch on [
|
|
306
|
-
summary: 'record what happens in the tab; show
|
|
324
|
+
usage: 'watch on [options] | off | [n|new] | save <file>',
|
|
325
|
+
summary: 'record what happens in the tab; show or save the steps',
|
|
307
326
|
args: [
|
|
308
327
|
['on', 'starts recording the tab\'s steps; after watch off, a new recording.'],
|
|
309
328
|
['off', 'stops it; anyone\'s watch off stops it, since a watch belongs to the tab.'],
|
|
310
329
|
['(none) | <n>', 'says whether it is on, and shows the last 20 steps, or the last n.'],
|
|
311
330
|
['new', 'only the steps not shown yet, and requests that have since arrived for the last one.'],
|
|
331
|
+
['save <file>', 'writes the steps as commands, a file that pw-repl send --file <file> runs again; it refuses a file that exists.'],
|
|
312
332
|
],
|
|
313
333
|
optionsFor: 'watch on',
|
|
314
334
|
options: [
|
|
315
335
|
['--changes', 'also what each step changed on the page once it settled, in up to 5 lines: + added, - removed, ~ changed.'],
|
|
316
336
|
['--live', 'also prints each step in the REPL window once it settles (never in a command\'s answer), marked [watch], or [watch <url>] for a tab that is not the selected one.'],
|
|
337
|
+
['--no-values', 'records that something was typed, not what.'],
|
|
317
338
|
['--next-tab [part]', 'returns at once, then watches the next tab someone opens, or the next whose URL contains part, from its first page, and selects it for you. tab new, a client\'s own, does not count.'],
|
|
318
339
|
],
|
|
319
340
|
detail: `What a step is: a click, typing (once it pauses), Enter or Escape, a form change, a submit or a
|
|
@@ -322,11 +343,22 @@ each with its status as in requests (body <#> for one), leaving out scripts and
|
|
|
322
343
|
REPL's own fill shows as type. A double-click shows as two clicks; hovering and moving the mouse are not
|
|
323
344
|
recorded.
|
|
324
345
|
|
|
325
|
-
Privacy:
|
|
326
|
-
|
|
346
|
+
Privacy: what is typed is recorded, and watch and watch save show it, except in a password field: one
|
|
347
|
+
that was ever type=password, or whose autocomplete is for a password, a one-time code or a card number.
|
|
348
|
+
--no-values leaves out every value. --changes leaves out field values, but what the page itself shows
|
|
349
|
+
(e.g. "Hello <name>") is shown. watch save's file can be read by its owner only.
|
|
350
|
+
|
|
351
|
+
Saving (watch save): a goto to where the watch started, then each step as a command, under a # line
|
|
352
|
+
describing it: click, fill, select, check, press, and wait load after a step that loaded a page. An
|
|
353
|
+
element is found by its id, data-testid or name, its text, or else its place in the page, which a change
|
|
354
|
+
to the page can break. A select step chooses the option by its value. What was typed into a password
|
|
355
|
+
field (or any field, with --no-values) is a variable named after the field, e.g. fill '#password'
|
|
356
|
+
"{{ PW_PASSWORD }}": run it with PW_PASSWORD=... pw-repl send --file login.txt, and send passes the
|
|
357
|
+
value along for the REPL to put in, which shows the line as written. A step that cannot be a command (a value with a line break, an element in a frame, or
|
|
358
|
+
one no selector finds alone) stays a comment saying why, and the save says how many there are.
|
|
327
359
|
|
|
328
360
|
Shared: every client sees the same steps, and watch new's place is shared.`,
|
|
329
|
-
moreAbout: 'bodies kept, --live timing, what --changes misses,
|
|
361
|
+
moreAbout: 'bodies kept, --live timing, what --changes misses, one --next-tab at a time, and what watch save leaves out',
|
|
330
362
|
more: `From its first watch on, a tab keeps response bodies of up to 100 KB, so body <#> still shows one
|
|
331
363
|
after the tab navigates, even when the page navigates as soon as the request answers.
|
|
332
364
|
|
|
@@ -336,13 +368,17 @@ under the next step.
|
|
|
336
368
|
--changes: each element once, with the first names inside it. Not for navigations: what a page changes
|
|
337
369
|
on its own after a step settles (data arriving later) shows under the next step.
|
|
338
370
|
|
|
339
|
-
--next-tab: it takes --changes and --
|
|
371
|
+
--next-tab: it takes --changes, --live and --no-values too; watch off stops waiting, and modes lists it. One tab is
|
|
340
372
|
waited for at a time: while one client waits, another's watch on --next-tab is refused, and its watch
|
|
341
|
-
off leaves the wait on
|
|
373
|
+
off leaves the wait on.
|
|
374
|
+
|
|
375
|
+
watch save: a submit is left out, since the click or Enter that caused it is there. So is a URL the app
|
|
376
|
+
changed without loading a page (history.pushState), as a comment: the step before it changes it again.
|
|
377
|
+
In an open shadow root, an element's selector is its host's, then its own; a closed one is out of reach.`,
|
|
342
378
|
},
|
|
343
379
|
text: { usage: 'text [--all] <selector>', summary: 'visible text of the first match' },
|
|
344
|
-
html: { usage: 'html [--all] <selector>', summary: 'outer HTML of the first match' },
|
|
345
|
-
attrs: { usage: 'attrs [--all] <selector>', summary: 'attributes of the first match' },
|
|
380
|
+
html: { usage: 'html [--all] <selector>', summary: 'outer HTML of the first match; no password values' },
|
|
381
|
+
attrs: { usage: 'attrs [--all] <selector>', summary: 'attributes of the first match; no password values' },
|
|
346
382
|
listeners: {
|
|
347
383
|
usage: 'listeners <selector>|document|window',
|
|
348
384
|
summary: 'the page\'s event listeners on an element',
|
|
@@ -412,6 +448,36 @@ It brings the tab to the front of its window first: Chrome draws only the tab in
|
|
|
412
448
|
at CSS pixel size, one image pixel per CSS pixel. While emulate mobile is on, it is what the phone
|
|
413
449
|
shows: a page laid out wider than the phone (no viewport meta tag) comes out shrunk to fit. An element bigger
|
|
414
450
|
than the viewport is taken with the page's scrollbar hidden for a moment, so that it does not move over.`,
|
|
451
|
+
},
|
|
452
|
+
toast: {
|
|
453
|
+
usage: 'toast <text> [options] | toast off',
|
|
454
|
+
summary: 'show a line of text over the page, to narrate a video',
|
|
455
|
+
args: [
|
|
456
|
+
['<text>', 'the rest of the line, quotes around all of it removed; it wraps at 80% of the page\'s width.'],
|
|
457
|
+
['off', 'fades it out.'],
|
|
458
|
+
['(none)', 'says whether one is up, and what it says.'],
|
|
459
|
+
],
|
|
460
|
+
options: [
|
|
461
|
+
['--duration=<time>', 'hides it after that long: 3s, 1.5s, or milliseconds (1500). Default: stays until toast off or the next toast.'],
|
|
462
|
+
['--read-time=<time>', 'while the tab records, the least time it stays up (below). Default: 300ms a word, at least 1.5s; 0 none.'],
|
|
463
|
+
['--position=<where>', 'top, center or bottom. Default: bottom.'],
|
|
464
|
+
['--size=<px>', 'the text\'s size. Default: 24.'],
|
|
465
|
+
['--align=<how>', 'left, center or right, for text over more than one line. Default: center.'],
|
|
466
|
+
['--color=<css>', 'the text\'s color. Default: #fff.'],
|
|
467
|
+
['--background=<css>', 'its box\'s color. Default: rgba(20, 20, 20, .85).'],
|
|
468
|
+
['--opacity=<0-1>', 'of the whole toast. Default: 1.'],
|
|
469
|
+
],
|
|
470
|
+
detail: `It stays up until toast off or the next toast, which replaces it: the old one fades out, then the new
|
|
471
|
+
one fades in. It returns once it has faded in, so a screenshot right after it shows it whole. The
|
|
472
|
+
commands after it run while it is up, so it can say what they are doing.
|
|
473
|
+
|
|
474
|
+
Read time (--read-time): while the tab records, a toast stays up for its read time before the next
|
|
475
|
+
toast, toast off or record off takes it down; each waits out what is left of it, and says so. toast
|
|
476
|
+
says what its read time is, and past 15s, the send -t the command that takes it down needs: send waits
|
|
477
|
+
20s by default. A toast with --duration has none: it ends by itself. Not recording, nothing waits.
|
|
478
|
+
|
|
479
|
+
It is a mode of its tab: modes lists it and modes off turns it off. It stays up across page loads, and
|
|
480
|
+
shows in screenshots and recordings. Selectors, snapshot and text don't see it, and it takes no clicks.`,
|
|
415
481
|
},
|
|
416
482
|
cursor: {
|
|
417
483
|
usage: 'cursor [on [<selector> | <x> <y>] | off]',
|
|
@@ -806,7 +872,7 @@ Names both have (goto, click, fill, snapshot, eval, ...) take playwright-cli's f
|
|
|
806
872
|
Its other options are refused, never read as part of a selector or a value.
|
|
807
873
|
|
|
808
874
|
Different on purpose, since the browser may be someone else's too:
|
|
809
|
-
routes, network and emulation per tab, not the whole browser, so other
|
|
875
|
+
routes, network and emulation per tab, not the whole browser, so other users' tabs are untouched
|
|
810
876
|
close turns off your modes; tabs stay open (tab close <url-part> closes one)
|
|
811
877
|
upload <selector> <file> names the input: file pickers are not caught, as the user's would be
|
|
812
878
|
no snapshot after each command the user reads the pane too; snapshot when you need one
|
package/lib/inspect.js
CHANGED
|
@@ -77,6 +77,24 @@ async function elementShot(locator, type) {
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// A password field's value shows in a snapshot as typed, and in html and attrs as its value attribute,
|
|
81
|
+
// which some pages (React's) keep in step with it. It is left out, as watch leaves it out, so a password
|
|
82
|
+
// filled in with {{ PW_PASSWORD }} stays out of the pane and the log too. These are the fields
|
|
83
|
+
// watch counts as secret, but one only shown as text for now (Show password) unless its autocomplete
|
|
84
|
+
// says so. A field that cannot be checked any more is taken to be one.
|
|
85
|
+
const SECRET_FIELD = ['input[type=password]', ...['current-password', 'new-password', 'one-time-code', 'cc-number', 'cc-csc']
|
|
86
|
+
.map(kind => `[autocomplete~="${kind}"]`)].join(',');
|
|
87
|
+
async function hideSecrets(page, text) {
|
|
88
|
+
const lines = text.split('\n');
|
|
89
|
+
for (const [i, line] of lines.entries()) {
|
|
90
|
+
const field = /^(\s*- (?:textbox|searchbox|spinbutton|combobox)\b.*\[ref=(\w+)\][^:]*): .+$/.exec(line);
|
|
91
|
+
if (!field) continue;
|
|
92
|
+
const secret = await page.locator(`aria-ref=${field[2]}`).evaluate((el, selector) => el.matches(selector), SECRET_FIELD, { timeout: 1000 }).catch(() => true);
|
|
93
|
+
if (secret) lines[i] = `${field[1]}: (a password: not shown)`;
|
|
94
|
+
}
|
|
95
|
+
return lines.join('\n');
|
|
96
|
+
}
|
|
97
|
+
|
|
80
98
|
// Unnamed generic nodes are layout wrappers (mostly divs): they add depth and
|
|
81
99
|
// nothing to read. Drop them, lift their children, and drop cursor hints.
|
|
82
100
|
function compactSnapshot(text) {
|
|
@@ -160,16 +178,23 @@ const commands = {
|
|
|
160
178
|
|
|
161
179
|
async html(args, all) {
|
|
162
180
|
const selector = soleSelector(args, 'html <selector>');
|
|
163
|
-
|
|
181
|
+
// A copy in a document of its own, where the page's code (a custom element's) does not run.
|
|
182
|
+
printOutput(await onElement(selector, () => state.page.$eval(selector, (el, secret) => {
|
|
183
|
+
const copy = document.implementation.createHTMLDocument('').importNode(el, true);
|
|
184
|
+
for (const field of [copy, ...copy.querySelectorAll('*')]) {
|
|
185
|
+
if (field.matches(secret) && field.hasAttribute('value')) field.setAttribute('value', '(a password: not shown)');
|
|
186
|
+
}
|
|
187
|
+
return copy.outerHTML;
|
|
188
|
+
}, SECRET_FIELD)), all);
|
|
164
189
|
},
|
|
165
190
|
|
|
166
191
|
async attrs(args, all) {
|
|
167
192
|
const selector = soleSelector(args, 'attrs <selector>');
|
|
168
|
-
const result = await onElement(selector, () => state.page.$eval(selector, el => {
|
|
193
|
+
const result = await onElement(selector, () => state.page.$eval(selector, (el, secret) => {
|
|
169
194
|
const out = {};
|
|
170
|
-
for (const attr of el.attributes) out[attr.name] = attr.value;
|
|
195
|
+
for (const attr of el.attributes) out[attr.name] = attr.name === 'value' && el.matches(secret) ? '(a password: not shown)' : attr.value;
|
|
171
196
|
return out;
|
|
172
|
-
}));
|
|
197
|
+
}, SECRET_FIELD));
|
|
173
198
|
printOutput(result, all);
|
|
174
199
|
},
|
|
175
200
|
|
|
@@ -358,6 +383,7 @@ const commands = {
|
|
|
358
383
|
} else {
|
|
359
384
|
raw = await state.page.ariaSnapshot({ mode: 'ai', timeout: 5000 });
|
|
360
385
|
}
|
|
386
|
+
raw = await hideSecrets(state.page, raw);
|
|
361
387
|
const text = full ? raw : compactSnapshot(raw);
|
|
362
388
|
if (needle !== null) {
|
|
363
389
|
const hits = grepSnapshot(text, needle);
|
|
@@ -452,6 +478,11 @@ const commands = {
|
|
|
452
478
|
const value = await withTimeout(state.page.evaluate(expression), 'Page evaluation', COMMAND_TIMEOUT);
|
|
453
479
|
// An empty string is shown as one, not as no output at all.
|
|
454
480
|
printOutput(value === '' ? '""' : value, all);
|
|
481
|
+
// Quotes around the whole of it, as a shell's quoting sent on, make it a string, not code to run:
|
|
482
|
+
// one plain literal only, not "a" + b or a template with ${...} in it, which ran.
|
|
483
|
+
if (typeof value === 'string' && value && /^(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\$]|\\.|\$(?!\{))*`)$/s.test(args.trim())) {
|
|
484
|
+
out.log('(That is a string: the JavaScript was in quotes, which eval keeps. Without them, it runs: eval document.title)');
|
|
485
|
+
}
|
|
455
486
|
},
|
|
456
487
|
|
|
457
488
|
async cdp(args, all) {
|
package/lib/modes.js
CHANGED
|
@@ -11,6 +11,7 @@ const { currentCapture, endCapture } = require('./capture');
|
|
|
11
11
|
const { nextTabWanted, waitingText, stopWaitingForNextTab, stopWatching } = require('./watch');
|
|
12
12
|
const { hideHighlights, setLabels } = require('./elements');
|
|
13
13
|
const { cursorOf, cursorOff } = require('./cursor');
|
|
14
|
+
const { toastOf, toastOff } = require('./toast');
|
|
14
15
|
|
|
15
16
|
// The modes turned on in a tab, in the order the prompt shows them.
|
|
16
17
|
function activeModes(p) {
|
|
@@ -28,6 +29,7 @@ function activeModes(p) {
|
|
|
28
29
|
if (highlights) modes.push(`highlight:${highlights}`);
|
|
29
30
|
if (tabState(p).labelsOn) modes.push('labels');
|
|
30
31
|
if (cursorOf(p)) modes.push('cursor');
|
|
32
|
+
if (toastOf(p)) modes.push('toast');
|
|
31
33
|
if (currentCapture()?.page === p) modes.push('capture');
|
|
32
34
|
if (recordingOf(p)) modes.push('record');
|
|
33
35
|
return modes;
|
|
@@ -43,7 +45,7 @@ function listModes() {
|
|
|
43
45
|
if (!on.length) {
|
|
44
46
|
out.log('No modes are on in any tab.');
|
|
45
47
|
out.log(hints([
|
|
46
|
-
['watch on', 'record what the
|
|
48
|
+
['watch on', 'record what the user at the browser does'],
|
|
47
49
|
['capture on', 'record requests and console messages together'],
|
|
48
50
|
['route <url-glob> <status> <json-body>', 'fake a response'],
|
|
49
51
|
['network off', 'cut the tab\'s network'],
|
|
@@ -81,6 +83,8 @@ function modeOwners(p) {
|
|
|
81
83
|
if (labels) owners.push([labels.owner ?? null, 'labels']);
|
|
82
84
|
const cursor = cursorOf(p);
|
|
83
85
|
if (cursor) owners.push([cursor.owner ?? null, 'cursor']);
|
|
86
|
+
const toast = toastOf(p);
|
|
87
|
+
if (toast) owners.push([toast.owner ?? null, 'toast']);
|
|
84
88
|
const capture = currentCapture();
|
|
85
89
|
if (capture?.page === p) owners.push([capture.owner ?? null, 'capture']);
|
|
86
90
|
const recording = recordingOf(p);
|
|
@@ -119,6 +123,7 @@ async function allModesOff(mine = false) {
|
|
|
119
123
|
if (hidden) done.push(`${hidden} highlight${hidden === 1 ? '' : 's'} hidden`);
|
|
120
124
|
if (tabState(p).labelsOn && ours(tabState(p).labelsOn.owner)) { await setLabels(p, false); done.push('labels off'); }
|
|
121
125
|
if (cursorOf(p) && ours(cursorOf(p).owner) && await cursorOff(p)) done.push('cursor off');
|
|
126
|
+
if (toastOf(p) && ours(toastOf(p).owner) && await toastOff(p)) done.push('toast off');
|
|
122
127
|
// Before the page's size is reset, which the recording would otherwise end with.
|
|
123
128
|
const recording = recordingOf(p);
|
|
124
129
|
if (recording && ours(recording.owner)) done.push(`record off (${(await finishRecording(recording)).replace(/\n/g, '; ')})`);
|
package/lib/output.js
CHANGED
|
@@ -58,7 +58,7 @@ function notice(text, page = null) {
|
|
|
58
58
|
else log(text);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
// Like notice, but never part of a command's answer: for what the
|
|
61
|
+
// Like notice, but never part of a command's answer: for what the user at
|
|
62
62
|
// the browser does, which is not the output of the command running now.
|
|
63
63
|
function aside(text) {
|
|
64
64
|
if (!sink && idlePrinter && idlePrinter.idle()) idlePrinter.print(String(text));
|
package/lib/overlay.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// What the REPL draws over a page (the cursor, a toast), each as a layer of one element of its own.
|
|
2
|
+
const { tabState } = require('./tabstate');
|
|
3
|
+
|
|
4
|
+
// Runs in the page. An element under <html>, not <body>, which frameworks replace, with a closed shadow
|
|
5
|
+
// root, so the page's selectors, the snapshot and text do not find what is in it. It takes no pointer
|
|
6
|
+
// events, so what is under it is clicked, and it is built with the DOM and inline styles, which a page's
|
|
7
|
+
// Trusted Types or style-src rules do not block. It is shown as a popover, in the top layer, above the
|
|
8
|
+
// page's own dialogs, as Playwright's highlight is. Kept on the document under a symbol, so each call
|
|
9
|
+
// finds it again, and one the page removed is drawn again. Each layer is a box over the whole viewport,
|
|
10
|
+
// in a fixed order (the cursor above a toast), whatever order they were drawn in; the element goes with
|
|
11
|
+
// its last layer.
|
|
12
|
+
function pageOverlay(create) {
|
|
13
|
+
const KEY = Symbol.for('pw-repl-overlay');
|
|
14
|
+
const ORDER = ['toast', 'cursor'];
|
|
15
|
+
// Important on the element in the page, whose own rules could reach it; inside, the page's cannot, and
|
|
16
|
+
// an important transform would win over an animation.
|
|
17
|
+
const set = (el, styles, important = '') => { for (const [name, value] of Object.entries(styles)) el.style.setProperty(name, value, important); };
|
|
18
|
+
let overlay = document[KEY];
|
|
19
|
+
if (!overlay || !overlay.host.isConnected) {
|
|
20
|
+
if (!create) return null;
|
|
21
|
+
const host = document.createElement('pw-repl-overlay');
|
|
22
|
+
host.setAttribute('aria-hidden', 'true');
|
|
23
|
+
host.setAttribute('popover', 'manual');
|
|
24
|
+
set(host, {
|
|
25
|
+
display: 'block', position: 'fixed', inset: '0', width: '100%', height: '100%', 'max-width': 'none',
|
|
26
|
+
'max-height': 'none', margin: '0', padding: '0', border: 'none', background: 'transparent',
|
|
27
|
+
overflow: 'visible', 'pointer-events': 'none', 'z-index': '2147483647',
|
|
28
|
+
}, 'important');
|
|
29
|
+
const root = host.attachShadow({ mode: 'closed' });
|
|
30
|
+
document.documentElement.append(host);
|
|
31
|
+
const layers = {};
|
|
32
|
+
overlay = document[KEY] = {
|
|
33
|
+
host,
|
|
34
|
+
set,
|
|
35
|
+
// A layer's box, made the first time it is asked for.
|
|
36
|
+
layer(name) {
|
|
37
|
+
if (layers[name]) return layers[name];
|
|
38
|
+
const box = document.createElement('div');
|
|
39
|
+
set(box, { position: 'absolute', inset: '0', overflow: 'visible' });
|
|
40
|
+
const after = ORDER.slice(ORDER.indexOf(name) + 1).map(n => layers[n]).find(Boolean);
|
|
41
|
+
root.insertBefore(box, after || null);
|
|
42
|
+
return (layers[name] = box);
|
|
43
|
+
},
|
|
44
|
+
has: name => !!layers[name],
|
|
45
|
+
drop(name) {
|
|
46
|
+
layers[name]?.remove();
|
|
47
|
+
delete layers[name];
|
|
48
|
+
if (Object.keys(layers).length) return;
|
|
49
|
+
host.remove();
|
|
50
|
+
delete document[KEY];
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// Shown again, so it is above a dialog the page opened since.
|
|
55
|
+
try { overlay.host.hidePopover(); overlay.host.showPopover(); } catch {}
|
|
56
|
+
return overlay;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Runs fn(overlay, args) in the page, fn a function of its own that uses nothing from here. A string,
|
|
60
|
+
// so the two functions go to the page as one call; evaluated over CDP, it is not the page's own eval,
|
|
61
|
+
// which a page's script-src may forbid. With create false, nothing is drawn if the overlay is not there.
|
|
62
|
+
function inOverlay(p, fn, args = null, create = true) {
|
|
63
|
+
return p.evaluate(`(() => { const overlay = (${pageOverlay})(${create}); return overlay ? (${fn})(overlay, ${JSON.stringify(args)}) : undefined; })()`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A new document drops the overlay: each layer kept on the tab is drawn again by its redraw.
|
|
67
|
+
function keepAcrossPages(p, name, redraw) {
|
|
68
|
+
if (!tabState(p).overlayKept) {
|
|
69
|
+
tabState(p).overlayKept = new Map();
|
|
70
|
+
p.on('domcontentloaded', () => { for (const again of tabState(p).overlayKept.values()) again().catch(() => {}); });
|
|
71
|
+
}
|
|
72
|
+
tabState(p).overlayKept.set(name, redraw);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function forget(p, name) {
|
|
76
|
+
tabState(p).overlayKept?.delete(name);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { inOverlay, keepAcrossPages, forget };
|
package/lib/record.js
CHANGED
|
@@ -401,6 +401,8 @@ function stepLabel(cmd, args) {
|
|
|
401
401
|
const parsed = /^mouse/.test(cmd) ? null : splitSelector(words);
|
|
402
402
|
return `${cmd} ${parsed ? [parsed.word, parsed.rest].filter(Boolean).join(' ') : words}`.trim();
|
|
403
403
|
}
|
|
404
|
+
// A toast's text is what it said in the video, for whoever edits it.
|
|
405
|
+
if (cmd === 'toast') return `toast ${args.trim()}`.trim();
|
|
404
406
|
if (!TARGET_FIRST.has(cmd)) return cmd;
|
|
405
407
|
const rest = cmd === 'type' ? args.replace(/^\s*--delay(?:=|\s+)\S*\s*/, '') : args;
|
|
406
408
|
const parsed = splitSelector(rest);
|
|
@@ -448,6 +450,13 @@ function cursorStep(p, what, point, ms) {
|
|
|
448
450
|
rec.steps.push({ rec, t0, t1: t0 + ms / 1000, command: what.replace(/-?\d+(?:\.\d+)?/g, n => String(Math.round(Number(n) * rec.scale))), box: null, point: { x: scaled, y: Math.round(point.y * rec.scale) }, offSize: rec.resizedAt !== null, failed: false });
|
|
449
451
|
}
|
|
450
452
|
|
|
453
|
+
// Run by record off before it ends a recording, each with the recorded tab: for what the video must
|
|
454
|
+
// still show first. Registered by the modules that know, which record cannot require.
|
|
455
|
+
const beforeEnding = [];
|
|
456
|
+
function beforeRecordingEnds(wait) {
|
|
457
|
+
beforeEnding.push(wait);
|
|
458
|
+
}
|
|
459
|
+
|
|
451
460
|
// The pause after an action on a recording tab, for its result to be seen (record on --pause).
|
|
452
461
|
const ACTIONS = new Set([...WHOLE, ...TARGET_FIRST]);
|
|
453
462
|
async function pauseAfter(p, cmd) {
|
|
@@ -616,7 +625,9 @@ const commands = {
|
|
|
616
625
|
// Any client with the tab selected stops it, as it turns off any mode; both are told whose it was.
|
|
617
626
|
const other = (rec.owner ?? null) !== (state.client ?? null);
|
|
618
627
|
const clientName = c => c ?? 'the unnamed client';
|
|
619
|
-
//
|
|
628
|
+
// What must be seen before the end (a toast's read time), then the tail: still page after the last
|
|
629
|
+
// action. Only record off has them; the seconds limit is a hard stop.
|
|
630
|
+
if (!rec.finishing) for (const wait of beforeEnding) await wait(state.page);
|
|
620
631
|
if (!rec.finishing) await sleep(rec.pacing.tail);
|
|
621
632
|
const text = await finishRecording(rec, other ? `${clientName(state.client)}'s record off ended ${clientName(rec.owner)}'s recording` : null);
|
|
622
633
|
if (rec.failed) { lastEnded.get(rec.owner ?? null).reported = true; throw new Error(text); }
|
|
@@ -681,4 +692,4 @@ const commands = {
|
|
|
681
692
|
},
|
|
682
693
|
};
|
|
683
694
|
|
|
684
|
-
module.exports = { commands, recordingOf, stepsOf, pauseAfter, finishRecording, endedByTabClose, findFfmpeg, jpegSize, stepStarted, stepEnded, stepTarget, cursorStep, stepLabel };
|
|
695
|
+
module.exports = { commands, recordingOf, beforeRecordingEnds, stepsOf, pauseAfter, finishRecording, endedByTabClose, findFfmpeg, jpegSize, stepStarted, stepEnded, stepTarget, cursorStep, stepLabel };
|
package/lib/runner.js
CHANGED
|
@@ -9,6 +9,7 @@ const { activeModes } = require('./modes');
|
|
|
9
9
|
const { NO_TAB } = require('./util');
|
|
10
10
|
const { stepStarted, stepEnded, stepLabel, pauseAfter } = require('./record');
|
|
11
11
|
const cliNames = require('./cli-names');
|
|
12
|
+
const { expandVariables } = require('./syntax');
|
|
12
13
|
|
|
13
14
|
// playwright-cli names already explained, per client: each client is told once.
|
|
14
15
|
const explained = new Set();
|
|
@@ -186,8 +187,14 @@ function enqueue(line) {
|
|
|
186
187
|
// Server commands share the prompt's queue and are echoed to the pane, so
|
|
187
188
|
// someone watching sees everything an agent does.
|
|
188
189
|
// client names the sender: its commands show as [server:<client>] and use its own selected tab.
|
|
189
|
-
|
|
190
|
-
|
|
190
|
+
// vars: values for the {{ PW_NAME }} variables in text (from send), filled in to run it; text is shown
|
|
191
|
+
// as written, so a password in one is not in the command as the pane, the log and the prompt's history show it.
|
|
192
|
+
function submit(text, client = null, vars = null) {
|
|
193
|
+
let run = text;
|
|
194
|
+
if (vars) {
|
|
195
|
+
try { run = expandVariables(text, vars); } catch (error) { return Promise.resolve({ status: 'error', output: `Error: ${error.message}` }); }
|
|
196
|
+
}
|
|
197
|
+
if (DIALOG.test(run)) return answerDialog(text, client, run);
|
|
191
198
|
if (CDP_ANSWER.test(text)) {
|
|
192
199
|
console.log(`[server${client ? `:${client}` : ''}] ${text}\nError: ${CDP_REFUSED}`);
|
|
193
200
|
return Promise.resolve({ status: 'error', output: `Error: ${CDP_REFUSED}` });
|
|
@@ -195,11 +202,11 @@ function submit(text, client = null) {
|
|
|
195
202
|
return new Promise(resolve => {
|
|
196
203
|
queue = queue.then(async () => {
|
|
197
204
|
if (state.stopping) return resolve({ status: 'error', output: 'The REPL is shutting down.' });
|
|
198
|
-
// Printed above whatever the
|
|
205
|
+
// Printed above whatever the user at the prompt is typing, which is
|
|
199
206
|
// redrawn afterwards rather than broken up.
|
|
200
207
|
if (process.stdout.isTTY) { readline.clearLine(process.stdout, 0); readline.cursorTo(process.stdout, 0); }
|
|
201
208
|
out.log(`[server${client ? `:${client}` : ''}] ${text}`);
|
|
202
|
-
// So the
|
|
209
|
+
// So the user at the prompt can bring an agent's command back with up-arrow.
|
|
203
210
|
if (state.rl?.history && state.rl.history[0] !== text) {
|
|
204
211
|
state.rl.history.unshift(text);
|
|
205
212
|
state.rl.history.length = Math.min(state.rl.history.length, 1000);
|
|
@@ -211,7 +218,7 @@ function submit(text, client = null) {
|
|
|
211
218
|
let result;
|
|
212
219
|
const flight = { text, resolve, answered: false };
|
|
213
220
|
inFlight = flight;
|
|
214
|
-
const output = await out.collect(async () => { result = await execute(
|
|
221
|
+
const output = await out.collect(async () => { result = await execute(run, client); });
|
|
215
222
|
if (inFlight === flight) inFlight = null;
|
|
216
223
|
if (!flight.answered) resolve({ status: result.status, output, uncertain: result.uncertain });
|
|
217
224
|
}
|
|
@@ -222,12 +229,12 @@ function submit(text, client = null) {
|
|
|
222
229
|
|
|
223
230
|
// Outside the queue, and so outside out.collect, whose single sink belongs to
|
|
224
231
|
// the command that is waiting on the dialog.
|
|
225
|
-
async function answerDialog(text, client) {
|
|
232
|
+
async function answerDialog(text, client, run = text) {
|
|
226
233
|
console.log(`[server${client ? `:${client}` : ''}] ${text}`);
|
|
227
|
-
const translated = cliNames.translate(
|
|
228
|
-
const note = translated && explain(
|
|
234
|
+
const translated = cliNames.translate(run);
|
|
235
|
+
const note = translated && explain(run, translated, client);
|
|
229
236
|
let result;
|
|
230
|
-
try { result = { status: 'ok', output: await dialogCommand((translated?.text ||
|
|
237
|
+
try { result = { status: 'ok', output: await dialogCommand((translated?.text || run).slice(6), clientRecord(client).page) }; }
|
|
231
238
|
catch (error) { result = { status: 'error', output: `Error: ${error.message}` }; }
|
|
232
239
|
if (note) result.output = `${note}\n${result.output}`;
|
|
233
240
|
console.log(result.output);
|
package/lib/send.js
CHANGED
|
@@ -6,7 +6,7 @@ const fs = require('fs');
|
|
|
6
6
|
const crypto = require('crypto');
|
|
7
7
|
const { execFileSync } = require('child_process');
|
|
8
8
|
const client = require('./client');
|
|
9
|
-
const { shellWords } = require('./syntax');
|
|
9
|
+
const { shellWords, variablesIn } = require('./syntax');
|
|
10
10
|
|
|
11
11
|
// The prompt, after the modes on in the selected tab if any: (watch network:off) pw[serve]>
|
|
12
12
|
const PROMPT = /^(?:\([^()]*\) )?pw(\[serve\])?>\s*$/;
|
|
@@ -113,6 +113,8 @@ function commandLine(words) {
|
|
|
113
113
|
return words[n - 1] === '--filename' || !/^(?:\d+|-.*)$/.test(w) ? resolve(w) : w;
|
|
114
114
|
});
|
|
115
115
|
}
|
|
116
|
+
// watch save writes its file.
|
|
117
|
+
if (words[0] === 'watch' && words[1] === 'save' && words.length > 2) resolved = ['watch', 'save', resolve(words.slice(2).join(' '))];
|
|
116
118
|
if (words[0] === 'screenshot') {
|
|
117
119
|
resolved = words.map((w, n) => (w.startsWith('--filename=') && w.length > 11 ? `--filename=${resolve(w.slice(11))}` : words[n - 1] === '--filename' ? resolve(w) : w));
|
|
118
120
|
}
|
|
@@ -128,7 +130,18 @@ function commandLine(words) {
|
|
|
128
130
|
|
|
129
131
|
// A file of commands, one per line, run in turn until one fails. A recording it started and did not
|
|
130
132
|
// stop is still on: the next take would fail to start, so it says so.
|
|
131
|
-
const PATHS = /^(?:upload|record|video-start|video-stop|screenshot)$/;
|
|
133
|
+
const PATHS = /^(?:upload|record|video-start|video-stop|screenshot|watch)$/;
|
|
134
|
+
// The values of the {{ PW_NAME }} variables in a command, from send's environment: { vars }, null with
|
|
135
|
+
// none, or { error } for one that is not set or has a line break.
|
|
136
|
+
function variablesFor(command) {
|
|
137
|
+
const names = variablesIn(command);
|
|
138
|
+
const unset = names.filter(name => process.env[name] === undefined);
|
|
139
|
+
const broken = names.filter(name => /[\r\n]/.test(process.env[name] || ''));
|
|
140
|
+
if (unset.length) return { error: `${unset.join(', ')} ${unset.length === 1 ? 'is' : 'are'} not set; set ${unset.length === 1 ? 'it' : 'them'} for send (${unset[0]}=... pw-repl send ...), or --no-vars sends {{ ${unset[0]} }} as it is` };
|
|
141
|
+
if (broken.length) return { error: `${broken.join(', ')} ${broken.length === 1 ? 'has' : 'have'} a line break, which a line cannot hold` };
|
|
142
|
+
return names.length ? { vars: Object.fromEntries(names.map(name => [name, process.env[name]])) } : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
132
145
|
async function sendFile(options) {
|
|
133
146
|
let text;
|
|
134
147
|
try { text = fs.readFileSync(options.file, 'utf8'); } catch (error) { return fail(`cannot read ${options.file} (${error.code || error.message})`); }
|
|
@@ -141,7 +154,9 @@ async function sendFile(options) {
|
|
|
141
154
|
// Read as the prompt reads it; a file it reads or writes is the sender's, as on send's command line.
|
|
142
155
|
const words = shellWords(line);
|
|
143
156
|
const command = PATHS.test(words[0]) ? commandLine(words) : line;
|
|
144
|
-
const
|
|
157
|
+
const found = options.noVars ? null : variablesFor(command);
|
|
158
|
+
if (found?.error) { console.error(`pw-repl: stopped at line ${i + 1} of ${options.file} (${line}): ${found.error}`); return 1; }
|
|
159
|
+
const status = await send({ ...options, file: null, command, vars: found?.vars || null });
|
|
145
160
|
if (status !== 0) {
|
|
146
161
|
console.error(`pw-repl: stopped at line ${i + 1} of ${options.file} (${line}), exit status ${status}`);
|
|
147
162
|
if (recording) console.error('pw-repl: record off was not reached, so the recording is still on; pw-repl send record off ends it');
|
|
@@ -159,14 +174,22 @@ async function send(options) {
|
|
|
159
174
|
if (!command) return fail('no command given');
|
|
160
175
|
// A newline would reach the REPL as a second, unmarked command.
|
|
161
176
|
if (/[\r\n]/.test(command)) return fail('the command must be a single line');
|
|
177
|
+
// A command of its own, not a file's line, which sendFile has read its variables for already.
|
|
178
|
+
if (options.vars === undefined && !options.noVars) {
|
|
179
|
+
const found = variablesFor(command);
|
|
180
|
+
if (found?.error) return fail(found.error);
|
|
181
|
+
options = { ...options, vars: found?.vars || null };
|
|
182
|
+
}
|
|
162
183
|
const timeoutMs = options.timeout * 1000;
|
|
163
184
|
const way = route(options);
|
|
164
185
|
const name = options.client || process.env.PW_CLIENT || null;
|
|
165
186
|
if (name && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$/.test(name)) return fail('a client name is 1-40 letters, digits, dots, dashes and underscores');
|
|
166
187
|
// The pane has one prompt, so a command typed there has no client of its own.
|
|
167
188
|
if (way.kind === 'tmux' && options.client) return fail('-c needs the command server (pw-repl serve); a REPL in tmux has no clients');
|
|
189
|
+
// Typed into the pane, a variable's value would be on screen and in its scrollback.
|
|
190
|
+
if (way.kind === 'tmux' && options.vars) return fail(`{{ ${Object.keys(options.vars)[0]} }} needs the command server (pw-repl serve), which fills it in without showing it; --no-vars sends it as it is`);
|
|
168
191
|
if (way.kind === 'tmux') return viaTmux(way.session, command, timeoutMs);
|
|
169
|
-
const answer = await client.request(way.endpoint, command, timeoutMs, name);
|
|
192
|
+
const answer = await client.request(way.endpoint, command, timeoutMs, name, options.vars || null);
|
|
170
193
|
if (answer.result?.starting) return fail(`${answer.result.output} (pw-repl where says when it serves)`);
|
|
171
194
|
if (answer.result) {
|
|
172
195
|
if (answer.result.output) console.log(answer.result.output);
|