pw-repl 0.12.8 → 0.14.0
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 +19 -37
- package/README.md +9 -4
- package/bin/pw-repl.js +12 -10
- package/lib/background.js +2 -2
- package/lib/capture.js +11 -12
- package/lib/cli-names.js +1 -1
- package/lib/commands.js +3 -0
- package/lib/elements.js +21 -6
- package/lib/help.js +75 -33
- package/lib/inspect.js +95 -38
- package/lib/output.js +10 -3
- package/lib/profile.js +16 -14
- package/lib/record.js +17 -13
- package/lib/routes.js +8 -9
- package/lib/runner.js +28 -9
- package/lib/send.js +19 -8
- package/lib/server.js +4 -1
- package/lib/state.js +26 -9
- package/lib/syntax.js +4 -1
- package/lib/tabs.js +6 -0
- package/lib/toast.js +1 -6
- package/lib/util.js +25 -1
- package/lib/watch.js +4 -2
- package/lib/written.js +67 -0
- package/package.json +1 -1
- package/skill/SKILL.md +12 -4
package/lib/help.js
CHANGED
|
@@ -41,7 +41,7 @@ const TASKS = [
|
|
|
41
41
|
['record requests and console together', 'capture on, then capture off'],
|
|
42
42
|
['find what makes the page slow', 'profile on, then profile off; profile reload for a load'],
|
|
43
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 <
|
|
44
|
+
['change or slow an API response', 'route <glob> patch <json>, route <glob> delay <time>'],
|
|
45
45
|
['cut or slow the whole network', 'network off, network slow, then network on'],
|
|
46
46
|
['record a video of the page', 'record on, then record off (help video for a take)'],
|
|
47
47
|
['show a pointer where the agent acts', 'cursor on, then cursor off'],
|
|
@@ -62,6 +62,8 @@ ${TASKS.map(([task, commands]) => ` ${task.padEnd(38)} ${commands}`).join('\n'
|
|
|
62
62
|
Modes (${MODES}) stay on until turned off; the prompt shows the selected tab's: (watch routes:1) pw>.
|
|
63
63
|
Each of them on its own, and modes, shows its state and what you can run next.
|
|
64
64
|
|
|
65
|
+
Times are in milliseconds, as in Playwright, or in seconds with an s: wait .done 30000, or wait .done 30s.
|
|
66
|
+
|
|
65
67
|
Topics (help <topic>):
|
|
66
68
|
tabs open, select, close, navigate network requests, bodies, console, fakes, network off
|
|
67
69
|
interact click, fill, type, press, select devtools eval, CDP commands, cookie and storage names
|
|
@@ -120,7 +122,7 @@ box to point at something, and toast <text> a line of text saying what is happen
|
|
|
120
122
|
the viewer needs to read it. After a step that loads a page, wait for text only the result shows (wait
|
|
121
123
|
text ...), not a heading the page always has, and not wait load: a single-page app loads nothing.
|
|
122
124
|
|
|
123
|
-
record on --steps also saves when and where each step ran, in the video
|
|
125
|
+
record on --steps also saves when and where each step ran, in seconds into the video and its pixels, for
|
|
124
126
|
editing it afterwards.
|
|
125
127
|
|
|
126
128
|
The user's own screen, with their real pointer, takes a screen recorder they run (OBS, ffmpeg -f
|
|
@@ -135,7 +137,7 @@ clean browser of its own (here, its video-start is record on).`,
|
|
|
135
137
|
devtools: {
|
|
136
138
|
intro: `Cookie and storage listings omit values. eval, cdp, html, screenshots, recordings and URLs can
|
|
137
139
|
still show sensitive data (cdp Network.getCookies shows cookie values).`,
|
|
138
|
-
commands: ['eval', 'cdp', 'cookies', 'storage'],
|
|
140
|
+
commands: ['eval', 'cdp', 'clipboard', 'cookies', 'storage'],
|
|
139
141
|
},
|
|
140
142
|
session: {
|
|
141
143
|
intro: `Server: pw-repl serve also takes commands on /tmp/pw-repl.sock; they show here as [server] lines.
|
|
@@ -214,6 +216,7 @@ afresh.`,
|
|
|
214
216
|
['--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
217
|
['--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.'],
|
|
216
218
|
['--modifiers=<keys>', 'keys held down meanwhile, comma-separated: Alt, Control, ControlOrMeta, Meta, Shift.'],
|
|
219
|
+
['--force', 'skips Playwright\'s actionability checks (enabled, stable, not covered), as Playwright\'s force. For an element marked aria-disabled, which Playwright counts as disabled, that a page keeps clickable (a greyed menu item that says why). Several matches are still chosen from as below; a covered ref or single match is clicked where it is, so what covers it gets the click.'],
|
|
217
220
|
],
|
|
218
221
|
detail: `Several matches: hidden ones and ones covered by something else (a dialog's overlay) are passed over,
|
|
219
222
|
and it says which it used: Clicked: text=Buy (match 3 of 4; ...), or names the first when it was the
|
|
@@ -221,8 +224,10 @@ one: (the first of 2 matches: <h1> Buy), as it may not be the one meant. If none
|
|
|
221
224
|
says why for each and does nothing. A ref, or a single match, is clicked as it is, waiting up to 5s for
|
|
222
225
|
it to be ready.
|
|
223
226
|
|
|
224
|
-
Timed out
|
|
225
|
-
|
|
227
|
+
Timed out, it says what stopped it, and Playwright's full call log goes to the REPL's pane or log. Stopped by
|
|
228
|
+
one of Playwright's checks before the click is sent, it did nothing: the element is not visible, enabled
|
|
229
|
+
or stable, or <div class="overlay"> intercepts pointer events (something covers it). Stopped after it was
|
|
230
|
+
sent, as when the page stops answering, the outcome is unknown.`,
|
|
226
231
|
},
|
|
227
232
|
dblclick: {
|
|
228
233
|
usage: 'dblclick <selector> [button]',
|
|
@@ -372,7 +377,7 @@ all.`,
|
|
|
372
377
|
['--grep <text>', 'only the lines containing text (role, name or a flag such as [disabled], any case), each with the named elements around it.'],
|
|
373
378
|
['--regex <pattern>', '--grep by a regular expression: snapshot --regex "Total: \\d+".'],
|
|
374
379
|
],
|
|
375
|
-
detail: `Refs: a ref (e3,
|
|
380
|
+
detail: `Refs: a ref (e3; f1e3, f2e3 once the page has loaded again, when the old refs stop working) works as a selector in any command: click e3. It belongs
|
|
376
381
|
to the tab whose snapshot gave it, and can change when the page changes (e102 may become f4e98): take a
|
|
377
382
|
new snapshot after it does.
|
|
378
383
|
|
|
@@ -527,15 +532,15 @@ circle). That changes the page, so put it back afterwards, as it was if it had a
|
|
|
527
532
|
eval document.querySelector("#rate-3").style.outline = ""`,
|
|
528
533
|
},
|
|
529
534
|
screenshot: {
|
|
530
|
-
usage: 'screenshot [<ref>] [--full] [-d <
|
|
535
|
+
usage: 'screenshot [<ref>] [--full] [-d <time>] [name]',
|
|
531
536
|
summary: 'save a PNG of the viewport, --full page or an element',
|
|
532
537
|
args: [
|
|
533
538
|
['[<ref>]', 'only that element, from a snapshot ref: screenshot e5.'],
|
|
534
|
-
['[name]', 'saved as screenshot-<name>.png; letters, digits, underscores and hyphens. Default: a timestamp.'],
|
|
539
|
+
['[name]', 'saved as screenshot-<name>.png; letters, digits, underscores and hyphens. Default: a timestamp. A word with a / or ending in .png, .jpg or .jpeg is a file instead, as --filename: x.png is the file x.png, not screenshot-x.png. At the prompt, a path with a space goes in --filename="<file>"; through pw-repl send, either works.'],
|
|
535
540
|
],
|
|
536
541
|
options: [
|
|
537
542
|
['--full', 'the whole page, not only the viewport; --full-page too.'],
|
|
538
|
-
['-d, --delay <
|
|
543
|
+
['-d, --delay <time>', 'counts down out loud first, in seconds, up to 60s, so someone can hold a hover or open a menu.'],
|
|
539
544
|
['--filename=<file>', 'saves it there instead (a JPEG if it ends in .jpg). Relative to the REPL\'s folder; through pw-repl send, the sender\'s. It never replaces a file.'],
|
|
540
545
|
],
|
|
541
546
|
detail: `Where: $PW_SCREENSHOT_DIR (a folder that exists); without it, next to the REPL's socket when it serves on one of its own, or
|
|
@@ -603,19 +608,19 @@ the same spot, and shows in screenshots and recordings. Selectors, snapshot and
|
|
|
603
608
|
takes no clicks, and it points at elements inside frames too.`,
|
|
604
609
|
},
|
|
605
610
|
record: {
|
|
606
|
-
usage: 'record [on [file] [
|
|
611
|
+
usage: 'record [on [file] [time] [options] | off [file]]',
|
|
607
612
|
summary: 'record the selected tab\'s page to a video',
|
|
608
613
|
args: [
|
|
609
614
|
['on | off', 'on starts recording the selected tab; off stops it, saves the file and says its path, length, size and bytes. Neither: says whether it is recording, and how your last recording ended.'],
|
|
610
615
|
['[file]', 'clip.webm or clip.mp4; its ending picks the format. Relative to the REPL\'s folder; through pw-repl send, the sender\'s. Default: recording-<timestamp>.webm where screenshot saves. On record off: saves it under that name instead.'],
|
|
611
|
-
['[
|
|
616
|
+
['[time]', 'stops by itself after it, 1s or more. Default: 1 hour (3600s).'],
|
|
612
617
|
],
|
|
613
618
|
optionsFor: 'record on',
|
|
614
619
|
options: [
|
|
615
620
|
['--steps', 'also save clip.steps.txt next to clip.webm: when and where each command ran (Steps, below). Default: off.'],
|
|
616
621
|
['--pause=<ms>', 'a pause after each action, and each highlight drawn, so the viewer sees it. Default: 750; 0 none.'],
|
|
617
|
-
['--lead=<ms>', 'still page recorded before record on returns. Default: 1000; counts toward
|
|
618
|
-
['--tail=<ms>', 'still page recorded after record off, before it stops. Default: 1000; none when it stops at its
|
|
622
|
+
['--lead=<ms>', 'still page recorded before record on returns. Default: 1000; counts toward its time.'],
|
|
623
|
+
['--tail=<ms>', 'still page recorded after record off, before it stops. Default: 1000; none when it stops at its time.'],
|
|
619
624
|
['--filename=<file>', 'the file, as playwright-cli writes it.'],
|
|
620
625
|
],
|
|
621
626
|
detail: `What it records: the page only, from inside the browser, at the viewport's size in CSS pixels (set
|
|
@@ -656,7 +661,7 @@ height loses a pixel), scrollbars included; a phone's under emulate mobile is it
|
|
|
656
661
|
is 412 wide, less sharp than the phone draws it.
|
|
657
662
|
|
|
658
663
|
How it ended: record on its own, or record off when nothing is recording, says how your last recording
|
|
659
|
-
ended (record off, its
|
|
664
|
+
ended (record off, its time, its tab closing or the REPL stopping) with what record off would have
|
|
660
665
|
said. tab close says what became of a recording it ended. A REPL stopped with pw-repl stop cannot be
|
|
661
666
|
asked: stop prints what became of each recording it ended, and of one that ended by itself that record
|
|
662
667
|
has not shown since.
|
|
@@ -702,14 +707,14 @@ mouse, and sends no touch events. Dark, light and timezone apply at once, but wh
|
|
|
702
707
|
drew with them (times, colors set by its script) changes only when it reloads.`,
|
|
703
708
|
},
|
|
704
709
|
wait: {
|
|
705
|
-
usage: 'wait [text|request] <what> [--gone] [
|
|
710
|
+
usage: 'wait [text|request] <what> [--gone] [time]',
|
|
706
711
|
summary: 'wait for an element, text, a response or a load (10s)',
|
|
707
712
|
args: [
|
|
708
713
|
['<selector>', 'for a matching element to be visible.'],
|
|
709
|
-
['text <text>', 'for text to be visible: any part of an element\'s text, any case. Quote text that ends in a number, or the number is taken as the
|
|
714
|
+
['text <text>', 'for text to be visible: any part of an element\'s text, any case. Quote text that ends in a number, or the number is taken as the time: wait text "Showing 1".'],
|
|
710
715
|
['request <part>', 'for a matching response (a URL part or a glob), counting one that finished since the previous command began, or is still under way: click, then wait request, does not miss it.'],
|
|
711
716
|
['load', 'for the page to finish loading (its load event), counting a navigation that began since the previous command began: click a link, then wait load.'],
|
|
712
|
-
['[
|
|
717
|
+
['[time]', 'how long, with no limit (give send time too: send -t). Default: 10s.'],
|
|
713
718
|
],
|
|
714
719
|
options: [
|
|
715
720
|
['--gone', 'until nothing matching is visible (removed or hidden): wait .spinner --gone. Done at once if nothing matches yet, so wait for it to appear first if it may not have.'],
|
|
@@ -718,8 +723,8 @@ drew with them (times, colors set by its script) changes only when it reloads.`,
|
|
|
718
723
|
something on the new view instead. A wait that times out is an error; the REPL carries on.`,
|
|
719
724
|
},
|
|
720
725
|
sleep: {
|
|
721
|
-
usage: 'sleep <
|
|
722
|
-
summary: 'wait a fixed time,
|
|
726
|
+
usage: 'sleep <time>',
|
|
727
|
+
summary: 'wait a fixed time: sleep 1500, or sleep 1.5s',
|
|
723
728
|
detail: `While recording, each action is already followed by a pause (help record): a sleep adds to it.`,
|
|
724
729
|
},
|
|
725
730
|
requests: {
|
|
@@ -776,11 +781,11 @@ A page brought back from the back/forward cache (back, forward) reports its earl
|
|
|
776
781
|
the time it comes back, though nothing loaded again.`,
|
|
777
782
|
},
|
|
778
783
|
profile: {
|
|
779
|
-
usage: 'profile on [
|
|
784
|
+
usage: 'profile on [time] | off | reload | save <file>',
|
|
780
785
|
summary: 'what slows the tab: long tasks, layouts, input, frames',
|
|
781
786
|
args: [
|
|
782
|
-
['on', 'starts a performance trace of the selected tab, until profile off (at most
|
|
783
|
-
['[
|
|
787
|
+
['on', 'starts a performance trace of the selected tab, until profile off (at most 120s; give it a time for longer).'],
|
|
788
|
+
['[time]', 'records that long (1s or more), then prints. Other commands wait until it ends. Over 20s, give send time too: pw-repl send -t 70s profile on 60s.'],
|
|
784
789
|
['off', 'stops it and prints the summary.'],
|
|
785
790
|
['reload', 'reloads the tab and profiles its load, until its network is idle (at most 10s after load) and half a second more. For work that starts later (a timer): profile on, reload, sleep, profile off.'],
|
|
786
791
|
['save <file>', 'writes the last profile\'s trace, the tab\'s events only, as Chrome trace JSON for DevTools\' Performance panel (Load profile) or ui.perfetto.dev. It refuses a file that exists.'],
|
|
@@ -813,7 +818,7 @@ may time out, but the profile records it all; profile off once the page answers
|
|
|
813
818
|
|
|
814
819
|
Size: a busy page fills Chrome's trace buffer in under a minute (one that restyles many elements as it
|
|
815
820
|
draws, sooner), which stops the profile there, and the summary says so. Stopping a long one takes a
|
|
816
|
-
while, as Chrome writes out its trace: give send time, pw-repl send -t
|
|
821
|
+
while, as Chrome writes out its trace: give send time, pw-repl send -t 60s profile off. If send gives up
|
|
817
822
|
first, the profile still ends, and profile shows it.
|
|
818
823
|
|
|
819
824
|
One profile runs at a time, in a browser: Chrome traces one at a time, the whole browser's, so another
|
|
@@ -844,12 +849,12 @@ the trace even when its page did nothing. The trace is kept in a temporary folde
|
|
|
844
849
|
profile, or until the REPL stops.`,
|
|
845
850
|
},
|
|
846
851
|
capture: {
|
|
847
|
-
usage: 'capture on [requests|console] [
|
|
852
|
+
usage: 'capture on [requests|console] [time] | off',
|
|
848
853
|
summary: 'record requests and console together, in time order',
|
|
849
854
|
args: [
|
|
850
855
|
['on', 'records both until capture off, which prints them.'],
|
|
851
856
|
['requests | console', 'records only one.'],
|
|
852
|
-
['[
|
|
857
|
+
['[time]', 'records that long (1s or more), then prints. Other commands wait until it ends.'],
|
|
853
858
|
['(none)', 'says whether one is running, or shows the last one.'],
|
|
854
859
|
],
|
|
855
860
|
detail: `A timed capture suits recording what someone does in the browser; around your own commands, use
|
|
@@ -862,7 +867,7 @@ and console it keeps more than the last 200, and lists both together.`,
|
|
|
862
867
|
args: [
|
|
863
868
|
['<glob> <status> <json>', 'answers with this status (200-599) and JSON (the rest of the line), or no body. It never reaches the network, so it still answers while the network is off.'],
|
|
864
869
|
['<glob> patch <json>', 'lets it through, then changes its JSON response: a JSON Merge Patch, where objects merge, null removes a key and anything else replaces; one that is not an object (an array) replaces the whole body.'],
|
|
865
|
-
['<glob> delay <
|
|
870
|
+
['<glob> delay <time>', 'holds it that long, then lets it through.'],
|
|
866
871
|
['<glob> abort', 'fails it as if the connection broke.'],
|
|
867
872
|
['off <glob> | --all', 'removes one route, or all.'],
|
|
868
873
|
['(none)', 'lists the selected tab\'s routes.'],
|
|
@@ -888,11 +893,11 @@ replaces it. A glob may be quoted. Example: route **/api/cart patch {"total": 0}
|
|
|
888
893
|
['on', 'restores it.'],
|
|
889
894
|
],
|
|
890
895
|
detail: `Per tab; lasts until network on or the REPL exits. Routes still answer while it is off or slow. To slow
|
|
891
|
-
one API, use route <glob> delay <
|
|
896
|
+
one API, use route <glob> delay <time>. Stopping a service is not the same as network off: a dev proxy
|
|
892
897
|
in front of it usually holds the request open, so the page spins instead of failing.`,
|
|
893
898
|
},
|
|
894
899
|
eval: {
|
|
895
|
-
usage: 'eval [--all] <JavaScript>',
|
|
900
|
+
usage: 'eval [--all] [--timeout=<time>] <JavaScript>',
|
|
896
901
|
summary: 'evaluate JavaScript in the selected tab and print the result',
|
|
897
902
|
args: [
|
|
898
903
|
['<JavaScript>', 'the rest of the line. Promises are awaited, await works at the top level, and a function (() => document.title) is called.'],
|
|
@@ -900,14 +905,20 @@ in front of it usually holds the request open, so the page spins instead of fail
|
|
|
900
905
|
],
|
|
901
906
|
options: [
|
|
902
907
|
['--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.'],
|
|
908
|
+
['--timeout=<time>', 'how long the page has to answer, before --file. Default: 15s. Past 20s, give send time too, or it gives up first and cancels the eval: pw-repl send -t 70s eval --timeout=60s ...'],
|
|
903
909
|
['--all', 'the whole result, not capped.'],
|
|
904
910
|
],
|
|
905
911
|
detail: `eval fetch(...) sends a new request, which can change state on the server; it cannot read one that
|
|
906
|
-
already happened (requests lists those)
|
|
912
|
+
already happened (requests lists those).
|
|
913
|
+
|
|
914
|
+
Long work (--timeout): every other command waits while an eval runs, the other clients' too. For work of
|
|
915
|
+
minutes, or in a browser others are using, start it without awaiting it, keep its result on window, and
|
|
916
|
+
read it back: eval "window.__done = null; run().then(r => window.__done = r); 'started'", then eval
|
|
917
|
+
window.__done until it is set.`,
|
|
907
918
|
},
|
|
908
919
|
cdp: {
|
|
909
920
|
usage: 'cdp [--all] <method> <JSON object>',
|
|
910
|
-
summary: 'send one CDP command
|
|
921
|
+
summary: 'send one CDP command to the selected tab',
|
|
911
922
|
args: [
|
|
912
923
|
['<method>', 'a CDP method: Network.getCookies.'],
|
|
913
924
|
['<JSON object>', 'its parameters: {} for none.'],
|
|
@@ -915,8 +926,35 @@ already happened (requests lists those).`,
|
|
|
915
926
|
options: [
|
|
916
927
|
['--all', 'the whole result, not capped.'],
|
|
917
928
|
],
|
|
918
|
-
detail: `
|
|
919
|
-
Target.closeTarget are refused, and so is Page.handleJavaScriptDialog: dialog
|
|
929
|
+
detail: `A command goes through a temporary session, detached afterwards, so subscriptions and settings do not
|
|
930
|
+
persist (input aside, below). Browser.close and Target.closeTarget are refused, and so is Page.handleJavaScriptDialog: dialog
|
|
931
|
+
answers one.
|
|
932
|
+
|
|
933
|
+
Input (Input.dispatchTouchEvent, dispatchMouseEvent, the synthesize gestures): through a session kept for
|
|
934
|
+
the tab, so a sequence sent one call at a time stays one sequence (Chrome drops it when the session that
|
|
935
|
+
began it detaches). End one before other commands (touchEnd, or touchCancel to drop it): left open, the
|
|
936
|
+
next click lands mid-touch. Input.setIgnoreInputEvents and setInterceptDrags go through a temporary session,
|
|
937
|
+
as they would otherwise stay on and break later clicks.
|
|
938
|
+
|
|
939
|
+
Touch (under emulate mobile, for pointerType touch): touchStart and touchEnd sent this way make a tap, with
|
|
940
|
+
click, and two in a row a dblclick; held, it is still a tap, with no contextmenu. Chrome takes a tap sent
|
|
941
|
+
soon after another gesture as part of it, with no click: up to a few seconds after a drag (sleep 3s). The synthesize gestures
|
|
942
|
+
(Input.synthesizeTapGesture, synthesizePinchGesture, synthesizeScrollGesture) send pointer and touch events
|
|
943
|
+
but no click, dblclick or contextmenu.`,
|
|
944
|
+
},
|
|
945
|
+
clipboard: {
|
|
946
|
+
usage: 'clipboard [--all] [--shared-clipboard]',
|
|
947
|
+
summary: 'print the text on the clipboard, as a Copy button left it',
|
|
948
|
+
options: [
|
|
949
|
+
['--shared-clipboard', 'reads it in a browser with a window too, whose clipboard is the computer\'s (below).'],
|
|
950
|
+
['--all', 'the whole text, not capped.'],
|
|
951
|
+
],
|
|
952
|
+
detail: `It reads through the selected tab, which must be a secure page (https, or http on localhost), granting
|
|
953
|
+
it clipboard-read only while it reads. Check a Copy button: click it, then clipboard.
|
|
954
|
+
|
|
955
|
+
A headless browser, as --launch starts, has a clipboard of its own, holding only what its pages copied.
|
|
956
|
+
One with a window reads the computer's: whatever its user last copied, a password too. There it refuses
|
|
957
|
+
without --shared-clipboard; ask the user before giving it.`,
|
|
920
958
|
},
|
|
921
959
|
cookies: {
|
|
922
960
|
usage: 'cookies [--all]',
|
|
@@ -973,7 +1011,11 @@ at the browser (e.g. headless), it is the only way to answer one.`,
|
|
|
973
1011
|
quit: {
|
|
974
1012
|
usage: 'quit',
|
|
975
1013
|
summary: 'disconnect, leaving Chromium running (one --launch started stops too)',
|
|
976
|
-
detail: `Runs immediately, even while another command is waiting. Only available at the prompt
|
|
1014
|
+
detail: `Runs immediately, even while another command is waiting. Only available at the prompt.
|
|
1015
|
+
|
|
1016
|
+
As it stops (quit, or pw-repl stop), it says, for each client, how many of the files it wrote are still
|
|
1017
|
+
there, how large, and in which folders: Files left by cart-bug: 3 files, 48 MB (2 screenshots, 1 video)
|
|
1018
|
+
in /tmp/. pw-repl send -c <name> close lists a client's own in full.`,
|
|
977
1019
|
},
|
|
978
1020
|
};
|
|
979
1021
|
|
package/lib/inspect.js
CHANGED
|
@@ -5,16 +5,17 @@ const { state, withTimeout } = require('./state');
|
|
|
5
5
|
const out = require('./output');
|
|
6
6
|
const { printOutput } = out;
|
|
7
7
|
const { devices } = require('playwright-core');
|
|
8
|
-
const { toSelector, REF, unquote } = require('./syntax');
|
|
9
|
-
const { regexOf, COMMAND_TIMEOUT, BUSY_PAGE } = require('./util');
|
|
8
|
+
const { toSelector, REF, SCREENSHOT_FILE, unquote } = require('./syntax');
|
|
9
|
+
const { regexOf, COMMAND_TIMEOUT, BUSY_PAGE, TIME, parseDuration, durationText } = require('./util');
|
|
10
10
|
const { tabState } = require('./tabstate');
|
|
11
|
-
const {
|
|
11
|
+
const { noteWritten } = require('./written');
|
|
12
|
+
const { keptSession, keepDrawing } = require('./cdp');
|
|
12
13
|
const { CDP_REFUSED } = require('./dialogs');
|
|
13
14
|
const { onElement, soleSelector } = require('./elements');
|
|
14
15
|
const { waitForLoad, waitForRequest } = require('./requestlog');
|
|
15
16
|
const { deviceMetrics, viewportMetrics, reapplyMetrics } = require('./emulation');
|
|
16
17
|
|
|
17
|
-
const SCREENSHOT_DELAY_MAX =
|
|
18
|
+
const SCREENSHOT_DELAY_MAX = 60000;
|
|
18
19
|
// What inputs lists: native form controls, and elements that are one by their ARIA role.
|
|
19
20
|
const INPUTS = ['input', 'select', 'textarea', ...['checkbox', 'radio', 'switch', 'combobox', 'listbox', 'textbox', 'searchbox', 'slider', 'spinbutton'].map(role => `[role=${role}]`)].join(', ');
|
|
20
21
|
|
|
@@ -111,15 +112,14 @@ function compactSnapshot(text) {
|
|
|
111
112
|
return lines.join('\n');
|
|
112
113
|
}
|
|
113
114
|
|
|
114
|
-
const WAIT_DEFAULT =
|
|
115
|
-
const WAIT_MAX = 120;
|
|
115
|
+
const WAIT_DEFAULT = 10000;
|
|
116
116
|
|
|
117
117
|
// Gone once no match is visible, whether it was removed or hidden.
|
|
118
118
|
async function waitGone(locator, what, timeout) {
|
|
119
119
|
try {
|
|
120
120
|
await locator.filter({ visible: true }).first().waitFor({ state: 'detached', timeout });
|
|
121
121
|
} catch (error) {
|
|
122
|
-
if (/Timeout \d+ms exceeded/.test(error.message)) throw new Error(`Still visible after ${timeout
|
|
122
|
+
if (/Timeout \d+ms exceeded/.test(error.message)) throw new Error(`Still visible after ${durationText(timeout)}: ${what}`);
|
|
123
123
|
throw error;
|
|
124
124
|
}
|
|
125
125
|
out.log(`Gone: ${what}`);
|
|
@@ -127,9 +127,9 @@ 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',
|
|
132
|
-
if (error.message === 'Page evaluation timed out') error.message += ` after ${
|
|
130
|
+
function evaluated(promise, timeout = COMMAND_TIMEOUT) {
|
|
131
|
+
return withTimeout(promise, 'Page evaluation', timeout).catch(error => {
|
|
132
|
+
if (error.message === 'Page evaluation timed out') error.message += ` after ${durationText(timeout)}: eval --timeout=<time> waits longer (and send -t with it), but ${BUSY_PAGE}`;
|
|
133
133
|
throw error;
|
|
134
134
|
});
|
|
135
135
|
}
|
|
@@ -316,7 +316,7 @@ const commands = {
|
|
|
316
316
|
},
|
|
317
317
|
|
|
318
318
|
async screenshot(args) {
|
|
319
|
-
const usage = 'Usage: screenshot [<ref>] [--full] [--delay|-d <
|
|
319
|
+
const usage = 'Usage: screenshot [<ref>] [--full] [--delay|-d <time>] [name | <file> | --filename=<file>]\nNames may contain letters, digits, underscores, and hyphens; a word with a / or ending in .png, .jpg or .jpeg is a file.';
|
|
320
320
|
// The file first: a path may be quoted, with spaces in it.
|
|
321
321
|
let file = null;
|
|
322
322
|
const rest = (args || '').replace(/(?:^|\s)--filename(?:=|\s+)("(?:[^"\\]|\\.)*"|'[^']*'|\S+)/, (_, value) => { file = unquote(value); return ' '; });
|
|
@@ -334,9 +334,14 @@ const commands = {
|
|
|
334
334
|
if (token === '--delay' || token === '-d') {
|
|
335
335
|
const value = tokens[++i];
|
|
336
336
|
// Capped low on purpose: a timed shot is human-in-the-loop, and a typo
|
|
337
|
-
// like `-d
|
|
338
|
-
|
|
339
|
-
delay
|
|
337
|
+
// like `-d 300s` would otherwise park the serialized command queue.
|
|
338
|
+
delay = parseDuration(value);
|
|
339
|
+
if (delay === null || delay > SCREENSHOT_DELAY_MAX) throw new Error(`Usage: screenshot --delay <time> (ms, or 5s; at most ${durationText(SCREENSHOT_DELAY_MAX)})`);
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (SCREENSHOT_FILE.test(token)) {
|
|
343
|
+
if (file) throw new Error(usage);
|
|
344
|
+
file = token;
|
|
340
345
|
continue;
|
|
341
346
|
}
|
|
342
347
|
names.push(token);
|
|
@@ -345,9 +350,11 @@ const commands = {
|
|
|
345
350
|
const name = names[0] || null;
|
|
346
351
|
// Counted down out loud so a watcher can time a hover or menu state, and so
|
|
347
352
|
// the wait is distinguishable from a hung command in a tmux capture.
|
|
348
|
-
for (let
|
|
349
|
-
out.log(`${
|
|
350
|
-
|
|
353
|
+
for (let left = delay; left > 0;) {
|
|
354
|
+
out.log(`${Math.ceil(left / 1000)}...`);
|
|
355
|
+
const step = left % 1000 || 1000;
|
|
356
|
+
await state.page.waitForTimeout(step);
|
|
357
|
+
left -= step;
|
|
351
358
|
}
|
|
352
359
|
// Named after the countdown so a default filename timestamps the capture.
|
|
353
360
|
// A file given is relative to the REPL's folder; pw-repl send makes it the sender's.
|
|
@@ -388,6 +395,7 @@ const commands = {
|
|
|
388
395
|
if (error.code === 'EEXIST') throw new Error(`Screenshot already exists: ${filepath}`);
|
|
389
396
|
throw error;
|
|
390
397
|
}
|
|
398
|
+
noteWritten(filepath, 'screenshot');
|
|
391
399
|
out.log(`Saved: ${filepath}`);
|
|
392
400
|
},
|
|
393
401
|
|
|
@@ -433,22 +441,22 @@ const commands = {
|
|
|
433
441
|
},
|
|
434
442
|
|
|
435
443
|
async wait(args) {
|
|
436
|
-
const usage = 'Usage: wait <selector> | wait text <text> | wait request <url-part|glob> | wait load, with optional [
|
|
444
|
+
const usage = 'Usage: wait <selector> | wait text <text> | wait request <url-part|glob> | wait load, with an optional [time] (ms, or 30s); --gone for a selector or text';
|
|
437
445
|
let tokens = (args || '').trim().split(/\s+/).filter(Boolean);
|
|
438
446
|
const gone = tokens.includes('--gone');
|
|
439
447
|
tokens = tokens.filter(t => t !== '--gone');
|
|
440
|
-
|
|
441
|
-
// A number last is the seconds, unless it is all there is to wait for: wait text 990.
|
|
448
|
+
// A time last is how long, unless it is all there is to wait for: wait text 990.
|
|
442
449
|
const onlyWhat = (tokens[0] === 'text' || tokens[0] === 'request') && tokens.length === 2;
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
if (
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
+
const tookTime = tokens.length > 1 && !onlyWhat && TIME.test(tokens[tokens.length - 1]);
|
|
451
|
+
const given = tookTime ? tokens.pop() : null;
|
|
452
|
+
const timeout = given ? parseDuration(given) : WAIT_DEFAULT;
|
|
453
|
+
if (!tokens.length) throw new Error(usage);
|
|
454
|
+
if (!timeout) {
|
|
455
|
+
// Most likely text that ends in a number, not a time at all.
|
|
456
|
+
const text = tokens[0] === 'text' && !/^["']/.test(tokens[1]) ? `; for text that ends in a number, quote it: wait text "${tokens.slice(1).join(' ')} ${given}"` : '';
|
|
457
|
+
throw new Error(`wait takes a time over 0 (got ${given})${text}`);
|
|
450
458
|
}
|
|
451
|
-
const
|
|
459
|
+
const within = durationText(timeout);
|
|
452
460
|
// Said plainly, not as Playwright's call log.
|
|
453
461
|
const plainly = message => error => { throw /Timeout \d+ms exceeded/.test(error.message) ? new Error(message) : error; };
|
|
454
462
|
const kind = tokens[0];
|
|
@@ -464,26 +472,36 @@ const commands = {
|
|
|
464
472
|
}
|
|
465
473
|
if (gone) return waitGone(state.page.getByText(what), what, timeout);
|
|
466
474
|
// Text that ends in a number gives it up as the seconds, unless quoted: said when it times out.
|
|
467
|
-
const quote =
|
|
468
|
-
await state.page.getByText(what).first().waitFor({ state: 'visible', timeout }).catch(plainly(`No visible text matches ${what} within ${
|
|
475
|
+
const quote = tookTime && !/^["']/.test(tokens[1]) ? ` (${given} was taken as the time: wait text "${what} ${given}" waits for that text)` : '';
|
|
476
|
+
await state.page.getByText(what).first().waitFor({ state: 'visible', timeout }).catch(plainly(`No visible text matches ${what} within ${within}${quote}`));
|
|
469
477
|
out.log(`Visible: ${what}`);
|
|
470
478
|
return;
|
|
471
479
|
}
|
|
472
480
|
const selector = toSelector(unquote(tokens.join(' ')));
|
|
473
481
|
if (gone) return waitGone(state.page.locator(selector), tokens.join(' '), timeout);
|
|
474
|
-
await state.page.waitForSelector(selector, { state: 'visible', timeout }).catch(plainly(`No visible element matches ${tokens.join(' ')} within ${
|
|
482
|
+
await state.page.waitForSelector(selector, { state: 'visible', timeout }).catch(plainly(`No visible element matches ${tokens.join(' ')} within ${within}`));
|
|
475
483
|
out.log(`Visible: ${tokens.join(' ')}`);
|
|
476
484
|
},
|
|
477
485
|
|
|
478
486
|
async sleep(args) {
|
|
479
|
-
|
|
480
|
-
|
|
487
|
+
const ms = parseDuration(args);
|
|
488
|
+
if (ms === null) throw new Error('Usage: sleep <time> (ms, or 3s)');
|
|
489
|
+
await state.page.waitForTimeout(ms);
|
|
481
490
|
// sleep 3 is easily meant as seconds.
|
|
482
|
-
out.log(
|
|
491
|
+
out.log(/^\d+$/.test(args) && ms < 100 ? `Slept ${args}ms (sleep takes milliseconds: sleep ${args}s for ${args}s)` : `Slept ${durationText(ms)}`);
|
|
483
492
|
},
|
|
484
493
|
|
|
485
494
|
async eval(args, all) {
|
|
486
495
|
if (!args) throw new Error('Usage: eval <js-expression>');
|
|
496
|
+
// eval --timeout=<time> [--file=...] <JavaScript>: how long the page has to answer.
|
|
497
|
+
let timeout = COMMAND_TIMEOUT;
|
|
498
|
+
const timed = /^--timeout(?:=|\s+)(\S+)(?:\s+([\s\S]*))?$/.exec(args.trim());
|
|
499
|
+
if (/^--timeout(?:[=\s]|$)/.test(args.trim())) {
|
|
500
|
+
timeout = parseDuration(timed?.[1]);
|
|
501
|
+
if (!timeout) throw new Error('Usage: eval --timeout=<time> <JavaScript>, a time in ms or with s: eval --timeout=60s ...');
|
|
502
|
+
args = timed[2] || '';
|
|
503
|
+
if (!args.trim()) throw new Error('Usage: eval --timeout=<time> <JavaScript>');
|
|
504
|
+
}
|
|
487
505
|
let ref = null;
|
|
488
506
|
// eval --file=<path> [<ref or selector>]: the file's text is what the line would be, over as many lines as
|
|
489
507
|
// it likes. The rest of the line can only be the element, so any selector goes there, not only a ref.
|
|
@@ -509,9 +527,9 @@ const commands = {
|
|
|
509
527
|
try {
|
|
510
528
|
// Made from its text by the debugger, as eval is, so a page's CSP cannot
|
|
511
529
|
// refuse it, and in the element's own frame, which may be an iframe.
|
|
512
|
-
const fn = await evaluated((await el.ownerFrame()).evaluateHandle(args));
|
|
530
|
+
const fn = await evaluated((await el.ownerFrame()).evaluateHandle(args), timeout);
|
|
513
531
|
try {
|
|
514
|
-
printOutput(await el.evaluate((e, f) => f(e), fn), all);
|
|
532
|
+
printOutput(await evaluated(el.evaluate((e, f) => f(e), fn), timeout), all);
|
|
515
533
|
} finally {
|
|
516
534
|
await fn.dispose().catch(() => {});
|
|
517
535
|
}
|
|
@@ -531,13 +549,13 @@ const commands = {
|
|
|
531
549
|
const call = `const value = (${args}\n); return typeof value === 'function' ? value() : value;`;
|
|
532
550
|
if (awaits && !single && compiles(AsyncFunction, args)) {
|
|
533
551
|
const session = await keptSession(state.page);
|
|
534
|
-
const { result, exceptionDetails } = await evaluated(session.send('Runtime.evaluate', { expression: args, replMode: true, awaitPromise: true, returnByValue: true }));
|
|
552
|
+
const { result, exceptionDetails } = await evaluated(session.send('Runtime.evaluate', { expression: args, replMode: true, awaitPromise: true, returnByValue: true }), timeout);
|
|
535
553
|
if (exceptionDetails) throw new Error(exceptionDetails.exception?.description || exceptionDetails.text);
|
|
536
554
|
printOutput(result.value !== undefined ? result.value : result.description ?? 'undefined', all);
|
|
537
555
|
return;
|
|
538
556
|
}
|
|
539
557
|
const expression = single ? `(${awaits ? 'async ' : ''}() => { ${call} })()` : args;
|
|
540
|
-
const value = await evaluated(state.page.evaluate(expression));
|
|
558
|
+
const value = await evaluated(state.page.evaluate(expression), timeout);
|
|
541
559
|
// An empty string is shown as one, not as no output at all.
|
|
542
560
|
printOutput(value === '' ? '""' : value, all);
|
|
543
561
|
// Quotes around the whole of it, as a shell's quoting sent on, make it a string, not code to run:
|
|
@@ -547,6 +565,38 @@ const commands = {
|
|
|
547
565
|
}
|
|
548
566
|
},
|
|
549
567
|
|
|
568
|
+
// Read through the selected tab, granted clipboard-read for that moment only. A browser with a
|
|
569
|
+
// window reads the system's clipboard, the user's, so only with --shared-clipboard.
|
|
570
|
+
async clipboard(args, all) {
|
|
571
|
+
const words = (args || '').trim().split(/\s+/).filter(Boolean);
|
|
572
|
+
if (words.some(w => w !== '--shared-clipboard')) throw new Error('Usage: clipboard [--shared-clipboard]');
|
|
573
|
+
const browser = await state.browser.newBrowserCDPSession();
|
|
574
|
+
try {
|
|
575
|
+
// Headless says so in its user agent (its product name says Chrome either way).
|
|
576
|
+
const { userAgent } = await browser.send('Browser.getVersion');
|
|
577
|
+
if (!/\bHeadlessChrome\//.test(userAgent) && !words.length) {
|
|
578
|
+
throw new Error('This browser has a window, so its clipboard is the computer\'s: whatever its user last copied, a password too. clipboard --shared-clipboard reads it anyway; ask them first. A headless browser (--launch, without --headed) has a clipboard of its own.');
|
|
579
|
+
}
|
|
580
|
+
const p = state.page;
|
|
581
|
+
const origin = await evaluated(p.evaluate(() => (isSecureContext && location.origin !== 'null' ? location.origin : null)));
|
|
582
|
+
if (!origin) throw new Error(`The selected tab (${p.url()}) is not a secure page, and only one can read the clipboard: https, or http on localhost`);
|
|
583
|
+
const permission = { name: 'clipboard-read' };
|
|
584
|
+
const before = await p.evaluate(() => navigator.permissions.query({ name: 'clipboard-read' }).then(s => s.state));
|
|
585
|
+
// readText also needs the page focused: a tab the REPL uses is told it is (help tab).
|
|
586
|
+
await keepDrawing(p);
|
|
587
|
+
await browser.send('Browser.setPermission', { permission, setting: 'granted', origin });
|
|
588
|
+
try {
|
|
589
|
+
const granted = await p.evaluate(() => navigator.permissions.query({ name: 'clipboard-read' }).then(s => s.state));
|
|
590
|
+
if (granted !== 'granted') throw new Error('The browser did not let the selected tab read the clipboard: a tab in a private window, or in a browser context of its own, does not take the grant');
|
|
591
|
+
printOutput(await evaluated(p.evaluate(() => navigator.clipboard.readText())), all);
|
|
592
|
+
} finally {
|
|
593
|
+
await browser.send('Browser.setPermission', { permission, setting: before, origin }).catch(() => {});
|
|
594
|
+
}
|
|
595
|
+
} finally {
|
|
596
|
+
await browser.detach().catch(() => {});
|
|
597
|
+
}
|
|
598
|
+
},
|
|
599
|
+
|
|
550
600
|
async cdp(args, all) {
|
|
551
601
|
const match = /^(\S+)\s+([\s\S]+)$/.exec(args || '');
|
|
552
602
|
if (!match) throw new Error('Usage: cdp <method> <JSON object>');
|
|
@@ -554,6 +604,13 @@ const commands = {
|
|
|
554
604
|
if (match[1] === 'Page.handleJavaScriptDialog') throw new Error(CDP_REFUSED);
|
|
555
605
|
const params = JSON.parse(match[2]);
|
|
556
606
|
if (!params || Array.isArray(params) || typeof params !== 'object') throw new Error('Parameters must be a JSON object');
|
|
607
|
+
// Input sent through the tab's kept session: Chrome drops a touch or mouse sequence when the session
|
|
608
|
+
// that began it detaches. Not Input's two switches, which would stay on and break later clicks.
|
|
609
|
+
if (/^Input\./.test(match[1]) && !['Input.setIgnoreInputEvents', 'Input.setInterceptDrags'].includes(match[1])) {
|
|
610
|
+
const kept = await keptSession(state.page);
|
|
611
|
+
printOutput(await withTimeout(kept.send(match[1], params), 'CDP operation', COMMAND_TIMEOUT), all);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
557
614
|
const operation = async () => {
|
|
558
615
|
const cdp = await state.page.context().newCDPSession(state.page);
|
|
559
616
|
try {
|
package/lib/output.js
CHANGED
|
@@ -4,14 +4,21 @@ const OUTPUT_LIMIT = 12000;
|
|
|
4
4
|
|
|
5
5
|
let sink = null;
|
|
6
6
|
|
|
7
|
+
// A cancelled command still running prints to the pane only, never into the next command's answer.
|
|
8
|
+
let cancelled = () => false;
|
|
9
|
+
|
|
10
|
+
function onCancelled(fn) {
|
|
11
|
+
cancelled = fn;
|
|
12
|
+
}
|
|
13
|
+
|
|
7
14
|
function log(text) {
|
|
8
15
|
console.log(text);
|
|
9
|
-
if (sink) sink.push(String(text));
|
|
16
|
+
if (sink && !cancelled()) sink.push(String(text));
|
|
10
17
|
}
|
|
11
18
|
|
|
12
19
|
function error(text) {
|
|
13
20
|
console.error(text);
|
|
14
|
-
if (sink) sink.push(String(text));
|
|
21
|
+
if (sink && !cancelled()) sink.push(String(text));
|
|
15
22
|
}
|
|
16
23
|
|
|
17
24
|
// Commands run one at a time, so a single sink is enough. Lines printed by
|
|
@@ -86,4 +93,4 @@ function printOutput(value, all = false, footer = '') {
|
|
|
86
93
|
log(footer ? `${text}\n${footer}` : text);
|
|
87
94
|
}
|
|
88
95
|
|
|
89
|
-
module.exports = { OUTPUT_LIMIT, log, error, collect, take, notice, aside, onIdlePrint, onSelectedTab, printOutput };
|
|
96
|
+
module.exports = { OUTPUT_LIMIT, log, error, collect, take, notice, aside, onIdlePrint, onSelectedTab, onCancelled, printOutput };
|