pw-repl 0.3.2 → 0.4.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 +11 -4
- package/README.md +9 -2
- package/bin/pw-repl.js +31 -6
- package/lib/cli-names.js +282 -0
- package/lib/client.js +2 -2
- package/lib/commands.js +421 -86
- package/lib/help.js +60 -25
- package/lib/runner.js +70 -22
- package/lib/send.js +5 -1
- package/lib/server.js +11 -4
- package/lib/state.js +27 -6
- package/lib/syntax.js +22 -1
- package/package.json +1 -1
- package/skill/SKILL.md +55 -8
package/lib/help.js
CHANGED
|
@@ -17,6 +17,7 @@ Common tasks:
|
|
|
17
17
|
console messages and errors console
|
|
18
18
|
show an agent what I do watch on, click around, then watch
|
|
19
19
|
see each step as I click watch on --live
|
|
20
|
+
watch a tab someone will open watch on --next-tab [url-part]
|
|
20
21
|
requests and console together capture on, then capture off
|
|
21
22
|
break the backend on purpose route <glob> <status> <json>, route <glob> abort, network off
|
|
22
23
|
change or slow an API response route <glob> patch <json>, route <glob> delay <secs>
|
|
@@ -32,7 +33,10 @@ Topics (help <topic>):
|
|
|
32
33
|
interact click, fill, type, press, select devtools eval, CDP commands, cookie and storage names
|
|
33
34
|
inspect snapshot, watch, text, screenshots session modes, markers, server, quitting
|
|
34
35
|
|
|
35
|
-
help <command> for usage and caveats (e.g. help route); help --all for everything
|
|
36
|
+
help <command> for usage and caveats (e.g. help route); help --all for everything.
|
|
37
|
+
|
|
38
|
+
Know playwright-cli? Its command names work here too (tab-list, go-back, set-color-scheme, route
|
|
39
|
+
--status=...); help playwright-cli lists them.`;
|
|
36
40
|
|
|
37
41
|
const TOPICS = {
|
|
38
42
|
tabs: {
|
|
@@ -63,6 +67,11 @@ it. It says so (pw-repl send exits 2), and the REPL carries on.
|
|
|
63
67
|
|
|
64
68
|
Server: pw-repl serve also takes commands on /tmp/playwright-repl.sock; they show here as [server] lines.
|
|
65
69
|
|
|
70
|
+
Clients: pw-repl send -c <name> (or $PW_CLIENT) sends as a client of its own. It has a selected tab of
|
|
71
|
+
its own, so clients do not move each other's; its commands show as [server:<name>] lines. modes and tab
|
|
72
|
+
say which client turned each mode on and opened each tab, and modes off --mine turns off only the
|
|
73
|
+
sender's own. Senders without a name share the prompt's selected tab.
|
|
74
|
+
|
|
66
75
|
The prompt is pw[serve]> while the server is on and pw> without it. Before it are the modes on in the
|
|
67
76
|
selected tab, if any: (watch network:off routes:2 capture) pw>. modes lists them for every tab.
|
|
68
77
|
|
|
@@ -77,7 +86,7 @@ const COMMANDS = {
|
|
|
77
86
|
tab: {
|
|
78
87
|
usage: 'tab [<index>|<url-part>|new [url]|close [url-part]]',
|
|
79
88
|
summary: 'list tabs (* is selected); select, open or close one',
|
|
80
|
-
detail: 'tab <index> uses the numbers from
|
|
89
|
+
detail: 'tab <index> uses the numbers from your latest tab listing (each client has its own); they change\nwhen tabs open or close.\n\ntab <url-part> selects the one tab whose URL contains it, and refuses if none or several do.\n\ntab new opens a tab behind the one in front, so it does not take the window from whoever is using it,\nand selects it; tab shows who opened each tab opened this way. tab close closes the selected tab, or\nthe one matching url-part.\n\nClosing the selected tab goes back to the previous tab if tab new opened it; otherwise no tab is\nselected, and commands that need one refuse until one is.',
|
|
81
90
|
},
|
|
82
91
|
goto: {
|
|
83
92
|
usage: 'goto <url>',
|
|
@@ -92,16 +101,24 @@ const COMMANDS = {
|
|
|
92
101
|
forward: { usage: 'forward', summary: 'go forward one history entry', detail: 'Like back, it may bring the page back from the back/forward cache without loading it again.' },
|
|
93
102
|
reload: { usage: 'reload', summary: 'reload the selected tab' },
|
|
94
103
|
info: { usage: 'info', summary: 'selected tab URL, title and viewport' },
|
|
95
|
-
click: {
|
|
96
|
-
|
|
104
|
+
click: {
|
|
105
|
+
usage: 'click <selector> [button]',
|
|
106
|
+
summary: 'click the first match',
|
|
107
|
+
detail: 'After a ref or a quoted selector, a button (left, right, middle) may follow: click e5 right.\n--modifiers=<key>[,<key>] holds keys down meanwhile (Alt, Control, ControlOrMeta, Meta, Shift).',
|
|
108
|
+
},
|
|
109
|
+
dblclick: { usage: 'dblclick <selector> [button]', summary: 'double-click the first match; button and --modifiers as for click' },
|
|
97
110
|
hover: { usage: 'hover <selector>', summary: 'hover the first match' },
|
|
98
111
|
fill: {
|
|
99
|
-
usage: 'fill <selector> <value>',
|
|
112
|
+
usage: 'fill <selector> <value> [--submit]',
|
|
100
113
|
summary: 'clear an input and fill it',
|
|
101
|
-
detail: 'The selector is the first word; quote it if it has spaces. The rest of the line is the value, and\nquotes around all of it are removed, so fill #name "" clears the field.\n\nExamples: fill #name Ada Lovelace, fill "text=Your name" Ada, fill e7 Ada',
|
|
114
|
+
detail: 'The selector is the first word; quote it if it has spaces. The rest of the line is the value, and\nquotes around all of it are removed, so fill #name "" clears the field. --submit at the end presses\nEnter in it afterwards.\n\nExamples: fill #name Ada Lovelace, fill "text=Your name" Ada, fill e7 Ada',
|
|
115
|
+
},
|
|
116
|
+
type: {
|
|
117
|
+
usage: 'type [<selector>] <text> [--submit]',
|
|
118
|
+
summary: 'type key by key after what the field holds; text as for fill',
|
|
119
|
+
detail: 'With one word, or one quoted, it is typed into the element that has focus: type "garden hose".\n--submit at the end presses Enter afterwards.',
|
|
102
120
|
},
|
|
103
|
-
|
|
104
|
-
press: { usage: 'press <key> | press <selector> <key>', summary: 'press a key (Enter, Escape, Control+A), optionally on an element' },
|
|
121
|
+
press: { usage: 'press <key> | press <selector> <key>', summary: 'press a key (Enter, Escape, Control+A; any case), maybe on an element' },
|
|
105
122
|
select: { usage: 'select <selector> <value>', summary: 'choose an option in a select (the value is the rest of the line)' },
|
|
106
123
|
check: { usage: 'check <selector>', summary: 'check a checkbox' },
|
|
107
124
|
uncheck: { usage: 'uncheck <selector>', summary: 'uncheck a checkbox' },
|
|
@@ -113,12 +130,12 @@ const COMMANDS = {
|
|
|
113
130
|
snapshot: {
|
|
114
131
|
usage: 'snapshot [--full] [--grep <text> | <eN> | selector]',
|
|
115
132
|
summary: 'outline by role and name, with [ref=eN] labels',
|
|
116
|
-
detail: 'Playwright\'s accessibility snapshot, with unnamed layout wrappers (generic) and cursor hints left\nout; --full shows it unchanged.\n\n--grep <text> prints only the lines containing text (role, name or flag such as [disabled], any\ncase), each with the named elements around it. A hit with no named element around it prints without\na path. A hit on text (a label) keeps the ref of the element holding it, so snapshot <that ref>\nshows what sits beside it, such as the value next to the label.\n\nsnapshot e3 outlines one element. A ref (e3, or f1e3 in newer Playwright) works as a selector in any\ncommand: click e3. Refs can change when the page changes (e102 may become f4e98), so take a new\nsnapshot after it does.\n\nOutput over 60 lines ends with a line count.',
|
|
133
|
+
detail: 'Playwright\'s accessibility snapshot, with unnamed layout wrappers (generic) and cursor hints left\nout; --full shows it unchanged.\n\n--grep <text> prints only the lines containing text (role, name or flag such as [disabled], any\ncase), each with the named elements around it. A hit with no named element around it prints without\na path. A hit on text (a label) keeps the ref of the element holding it, so snapshot <that ref>\nshows what sits beside it, such as the value next to the label. --regex <pattern> is --grep by a\nregular expression: snapshot --regex "Total: \\d+".\n\nsnapshot e3 outlines one element. A ref (e3, or f1e3 in newer Playwright) works as a selector in any\ncommand: click e3. Refs can change when the page changes (e102 may become f4e98), so take a new\nsnapshot after it does.\n\nOutput over 60 lines ends with a line count.',
|
|
117
134
|
},
|
|
118
135
|
watch: {
|
|
119
136
|
usage: 'watch on [--changes] [--live] | off | [n|new]',
|
|
120
137
|
summary: 'record what happens in the tab; show the last n steps',
|
|
121
|
-
detail: 'Off until watch on; a watch on after watch off starts a new recording. Records clicks, typing (once\nit pauses), Enter and Escape, form changes, submits and navigations, each described by role and name\nlike snapshot, with up to 5 of the requests it caused underneath
|
|
138
|
+
detail: 'Off until watch on; a watch on after watch off starts a new recording. Records clicks, typing (once\nit pauses), Enter and Escape, form changes, submits and navigations, each described by role and name\nlike snapshot, with up to 5 of the requests it caused underneath, each with its status as in requests\n(body <#> for one), leaving out scripts and what requests hides. The REPL\'s own fill is recorded\nas type.\n\nwatch on --next-tab [url-part] returns at once, then waits for the next tab someone opens (tab new,\na client\'s own, does not count), or the next whose URL contains url-part, and watches it from its\nfirst page and selects it for the client that asked: for a tab someone is about to open. It takes\n--changes and --live too; watch off stops waiting, and modes lists it.\n\nwatch on its own says whether it is on and shows the last 20 steps; watch <n> shows the last n.\nwatch new shows only the steps it has not shown yet, and requests that have since arrived for the\nlast one.\n\n--live also prints each step in the REPL window (never in the server\'s answer to a command) once it\nsettles, with its requests and changes, marked [watch], or [watch <url>] for a tab that is not the\nselected one. A step cut short by the next one, or by watch off, prints at once; changes it made\nthen show under the next step.\n\n--changes also shows what each step changed on the page once it settles, in up to 5 lines: + added,\n- removed, ~ changed, each element once with the first names inside it. Not for navigations: what a\npage changes on its own after a step settles (data arriving later) shows under the next step.\n\nA watch belongs to the tab, not to a client: every client sees the same steps, watch new\'s place is\nshared, and anyone\'s watch off stops it.\n\nTyped values are not recorded, and password fields not at all; --changes leaves out field values,\nbut what the page itself shows (e.g. "Hello <name>") is shown. Nothing is replayed.',
|
|
122
139
|
},
|
|
123
140
|
text: { usage: 'text [--all] <selector>', summary: 'visible text of the first match' },
|
|
124
141
|
html: { usage: 'html [--all] <selector>', summary: 'outer HTML of the first match' },
|
|
@@ -133,36 +150,36 @@ const COMMANDS = {
|
|
|
133
150
|
links: { usage: 'links [--all]', summary: 'links on the page (text and href)' },
|
|
134
151
|
inputs: { usage: 'inputs [--all]', summary: 'form controls on the page; values are omitted' },
|
|
135
152
|
screenshot: {
|
|
136
|
-
usage: 'screenshot [--full] [
|
|
137
|
-
summary: 'save a PNG of the viewport
|
|
138
|
-
detail: 'Saved as screenshot-<name or timestamp>.png in $PW_SCREENSHOT_DIR
|
|
153
|
+
usage: 'screenshot [<ref>] [--full] [-d <secs>] [name]',
|
|
154
|
+
summary: 'save a PNG of the viewport, --full page or an element',
|
|
155
|
+
detail: 'Saved as screenshot-<name or timestamp>.png in $PW_SCREENSHOT_DIR; without it, next to the REPL\'s\nsocket when it serves on one of its own, or else in /tmp. --filename=<file> saves it there instead (a\nJPEG if it ends in .jpg), relative to the folder pw-repl send runs in; it never replaces a file.\n\nscreenshot e5 saves only that element, from a snapshot ref. --full-page is --full.\n\nIt brings the tab to the front of its window first: Chrome draws only the tab in front. The image\nis at CSS pixel size, one image pixel per CSS pixel, also while emulate mobile is on.\n\n--delay counts down out loud first (maximum 60s), so someone can hold a hover or open a menu.',
|
|
139
156
|
},
|
|
140
157
|
viewport: { usage: 'viewport [WxH]', summary: 'show or set the viewport size' },
|
|
141
158
|
emulate: {
|
|
142
159
|
usage: 'emulate [<what> [off] | off]',
|
|
143
160
|
summary: 'emulate a phone, dark mode, a locale or a timezone',
|
|
144
|
-
detail: 'emulate mobile [device] a phone\'s screen, touch and user agent: a Pixel 7, or a device named as in\n Playwright\'s list, e.g. emulate mobile iPhone 13; a name that is not\n exact lists the devices it matches (emulate mobile galaxy)\nemulate dark | light the color scheme the page\'s CSS and matchMedia see\nemulate locale <tag> the language (Accept-Language, navigator.language) and date and number\n formats, e.g. fr-FR\nemulate timezone <zone> an IANA timezone, e.g. Asia/Tokyo\nemulate <what> off stop one (dark or light off stops the color scheme); emulate off stops all\n\nPer tab, until turned off or the REPL exits. emulate on its own shows what is on.\n\nThe page sees the user agent, touch and navigator.languages from its next load: reload after\nturning mobile or locale on or off.
|
|
161
|
+
detail: 'emulate mobile [device] a phone\'s screen, touch and user agent: a Pixel 7, or a device named as in\n Playwright\'s list, e.g. emulate mobile iPhone 13; a name that is not\n exact lists the devices it matches (emulate mobile galaxy)\nemulate dark | light the color scheme the page\'s CSS and matchMedia see\nemulate locale <tag> the language (Accept-Language, navigator.language) and date and number\n formats, e.g. fr-FR\nemulate timezone <zone> an IANA timezone, e.g. Asia/Tokyo\nemulate <what> off stop one (dark or light off stops the color scheme); emulate off stops all\n\nPer tab, until turned off or the REPL exits. emulate on its own shows what is on.\n\nThe page sees the user agent, touch and navigator.languages from its next load: reload after\nturning mobile or locale on or off. Dark, light and timezone apply at once, but what the page\nalready drew with them (times, colors set by its script) changes only when it reloads.\n\nWhile mobile is on, viewport refuses: the device sets the size.',
|
|
145
162
|
},
|
|
146
163
|
wait: {
|
|
147
164
|
usage: 'wait [text|request] <what> [--gone] [secs]',
|
|
148
165
|
summary: 'wait for an element, text, a response or a load (10s)',
|
|
149
|
-
detail: 'wait <selector> waits for a matching element
|
|
166
|
+
detail: 'wait <selector> waits for a matching element to be visible, and wait text <text> for text to be;\nwait request <url-part|glob> waits for a matching response, counting one that finished since the\nprevious command began (so click, then wait request, does not miss it).\n\nwait load waits for the page to finish loading (its load event). A navigation that began since the\nprevious command began counts, so click a link, then wait load, waits for the new page. A page\nthat changes its own URL without loading (an app\'s own routing) is not a load: wait for something on\nthe new view instead.\n\nwait text matches any part of an element\'s text, in any case.\n\n--gone waits instead until nothing matching is visible (removed or hidden): wait .spinner --gone.\nIt is done at once if nothing matches yet, so wait for it to appear first if it may not have.\n\nUp to 120s. A wait that times out is an error; the REPL carries on.',
|
|
150
167
|
},
|
|
151
168
|
sleep: { usage: 'sleep <ms>', summary: 'wait a fixed time (maximum 3600000)' },
|
|
152
169
|
requests: {
|
|
153
170
|
usage: 'requests [--all] [n] [url-filter]',
|
|
154
171
|
summary: 'the selected tab\'s last n requests (default 20)',
|
|
155
|
-
detail: 'Recording starts when the REPL connects; the last 200 per tab are kept. Each line: #number, time,\nmethod, status (HTTP code, pending, no response, failed: <reason>, or <code> faked), duration, URL.\nA request that finished a moment ago can still show pending.\n\nbody <#> shows what a request got back.\n\nImages, fonts, stylesheets, media and extension requests are hidden unless --all is given.\n\nurl-filter is a substring: requests 20 /api/ shows API calls only, e.g. when dev-server scripts\ncrowd the list.',
|
|
172
|
+
detail: 'Recording starts when the REPL connects; the last 200 per tab are kept. Each line: #number, time,\nmethod, status (HTTP code, pending, no response, failed: <reason>, or <code> faked), duration, URL.\nA request that finished a moment ago can still show pending.\n\nbody <#> shows what a request got back.\n\nImages, fonts, stylesheets, media and extension requests are hidden unless --all is given.\n\nurl-filter is a substring: requests 20 /api/ shows API calls only, e.g. when dev-server scripts\ncrowd the list. --regex <pattern>, at the end, matches URLs by a regular expression instead.',
|
|
156
173
|
},
|
|
157
174
|
body: {
|
|
158
|
-
usage: 'body [--all]
|
|
159
|
-
summary: 'the response body of request
|
|
160
|
-
detail: '
|
|
175
|
+
usage: 'body [--all] <#>|<url-part>',
|
|
176
|
+
summary: 'the response body of request <#>, or of the latest one matching',
|
|
177
|
+
detail: 'body <url-part> shows the latest finished request whose URL contains url-part (body slots.json).\n\nRead from the browser on demand: JSON is pretty-printed, binary is not shown, output is capped. The\nbrowser may drop a body (e.g. after the tab navigates); then body reports not available.\n\nBodies can contain sensitive data.',
|
|
161
178
|
},
|
|
162
179
|
console: {
|
|
163
|
-
usage: 'console [--all] [n] [filter]',
|
|
180
|
+
usage: 'console [--all] [n] [level|filter]',
|
|
164
181
|
summary: 'the last n console messages and page errors',
|
|
165
|
-
detail: 'Recording starts when the REPL connects; the last 200 per tab are kept. Each line: time, [type],\ntext. Types are console levels (log, warning, error, ...) and pageerror for uncaught exceptions.\n\
|
|
182
|
+
detail: 'Recording starts when the REPL connects; the last 200 per tab are kept. Each line: time, [type],\ntext. Types are console levels (log, warning, error, ...) and pageerror for uncaught exceptions. A\n"--- the page loaded <url>" line marks each load after the first message shown.\n\nA level (error, warning, info, debug) shows that level and those above it: console warning shows\nwarnings and errors, page errors among them. info, which leaves out debug, is what playwright-cli\nshows by default. Anything else is a filter, matching part of the type or the text.\n\nA page brought back from the back/forward cache (back, forward) reports its earlier messages again,\nat the time it comes back, though nothing loaded again.',
|
|
166
183
|
},
|
|
167
184
|
capture: {
|
|
168
185
|
usage: 'capture on [requests|console] [secs] | off',
|
|
@@ -172,7 +189,7 @@ const COMMANDS = {
|
|
|
172
189
|
route: {
|
|
173
190
|
usage: 'route <glob> <how> | off <glob>|--all',
|
|
174
191
|
summary: 'fake, patch, delay or fail the selected tab\'s matching requests',
|
|
175
|
-
detail: 'route <glob> <status> <json> answer with this status (200-599) and JSON; it never
|
|
192
|
+
detail: 'route <glob> <status> <json> answer with this status (200-599) and JSON, or no body; it never\n reaches the network, so it still answers while the network is off.\n The body is the rest of the line. Another kind needs its type:\n route <glob> 200 --content-type=text/plain hello\nroute <glob> patch <json> let it through, then change its JSON response: a JSON Merge Patch, where\n objects merge, null removes a key and anything else replaces\nroute <glob> delay <secs> hold it for up to 120 seconds, then let it through\nroute <glob> abort fail it as if the connection broke\n\nEach matching request prints a line (Faked:, Patched:, Delayed:, Aborted:) in the REPL window, and in\nthe answer to a command running then, with its number as in requests; requests shows a fake as\n<status> faked and a patch as <status> patched. If a route fails it prints "Route failed" and aborts\nthe request.\n\nRoutes belong to the tab and last until route off <glob> (or route off --all) or the REPL exits.\nRouting the same glob again replaces it. route on its own lists the selected tab\'s routes. A glob may\nbe quoted.\n\nExample: route **/api/cart patch {"total": 0}',
|
|
176
193
|
},
|
|
177
194
|
network: {
|
|
178
195
|
usage: 'network [on|off|slow [<ms> [<kbps>]]]',
|
|
@@ -182,7 +199,7 @@ const COMMANDS = {
|
|
|
182
199
|
eval: {
|
|
183
200
|
usage: 'eval [--all] <JavaScript>',
|
|
184
201
|
summary: 'evaluate JavaScript in the selected tab and print the result',
|
|
185
|
-
detail: 'Promises are awaited. eval fetch(...) sends a new request, which can change state on the server; it
|
|
202
|
+
detail: 'Promises are awaited, await works at the top level, and a function (() => document.title) is\ncalled. eval <function> <ref> calls it with that element: eval "el => el.textContent" e5.\n\neval fetch(...) sends a new request, which can change state on the server; it cannot read one that\nalready happened (requests lists those).',
|
|
186
203
|
},
|
|
187
204
|
cdp: {
|
|
188
205
|
usage: 'cdp [--all] <method> <JSON object>',
|
|
@@ -192,9 +209,9 @@ const COMMANDS = {
|
|
|
192
209
|
cookies: { usage: 'cookies [--all]', summary: 'cookie names, domains and flags; values are omitted' },
|
|
193
210
|
storage: { usage: 'storage [--all]', summary: 'localStorage keys; values are omitted' },
|
|
194
211
|
modes: {
|
|
195
|
-
usage: 'modes [off]',
|
|
212
|
+
usage: 'modes [off [--mine]]',
|
|
196
213
|
summary: 'the modes on in every tab; modes off turns them all off',
|
|
197
|
-
detail: 'The modes are watch, network off or slow, route, capture and emulate. Each is turned on and off with\nits own command: watch on|off, network off|slow|on, route <glob> ... | route off <glob>|--all,\ncapture on|off, emulate ... | emulate off.\n\nmodes off turns off every one in every tab; a capture it stops is kept for capture to show.',
|
|
214
|
+
detail: 'The modes are watch, network off or slow, route, capture and emulate. Each is turned on and off with\nits own command: watch on|off, network off|slow|on, route <glob> ... | route off <glob>|--all,\ncapture on|off, emulate ... | emulate off.\n\nmodes off turns off every one in every tab; a capture it stops is kept for capture to show. With\nclients (help session), modes says which client turned each on, and modes off --mine turns off only\nthe sender\'s own.',
|
|
198
215
|
},
|
|
199
216
|
dialog: {
|
|
200
217
|
usage: 'dialog [accept [text] | dismiss]',
|
|
@@ -234,6 +251,24 @@ function renderAll() {
|
|
|
234
251
|
|
|
235
252
|
function render(topicOrCommand) {
|
|
236
253
|
if (!topicOrCommand) return OVERVIEW;
|
|
254
|
+
if (topicOrCommand === 'playwright-cli') {
|
|
255
|
+
return `playwright-cli's command names, and what they run here (each says so the first time):
|
|
256
|
+
|
|
257
|
+
${require('./cli-names').table()}
|
|
258
|
+
|
|
259
|
+
Names both have (goto, click, fill, snapshot, eval, ...) take playwright-cli's forms too: click <ref>
|
|
260
|
+
[button] [--modifiers=<keys>], fill and type --submit, type <text> into what has focus, eval <function>
|
|
261
|
+
<ref>, press in any case, screenshot <ref> --full-page --filename=<file>, console <level>, requests
|
|
262
|
+
--static --filter=<regexp>, find --regex <regexp>, route <pattern> --status= --body= --content-type=.
|
|
263
|
+
Its other options are refused, never read as part of a selector or a value.
|
|
264
|
+
|
|
265
|
+
Different on purpose, since the browser may be someone else's too:
|
|
266
|
+
routes, network and emulation per tab, not the whole browser, so other people's tabs are untouched
|
|
267
|
+
close turns off your modes; tabs stay open (tab close <url-part> closes one)
|
|
268
|
+
upload <selector> <file> names the input: file pickers are not caught, as a person's would be
|
|
269
|
+
no snapshot after each command a person reads the pane too; snapshot when you need one
|
|
270
|
+
-s=<session> -c <name> is the nearest: a client with a selected tab of its own`;
|
|
271
|
+
}
|
|
237
272
|
if (topicOrCommand === '--all') return renderAll();
|
|
238
273
|
if (Object.hasOwn(TOPICS, topicOrCommand)) {
|
|
239
274
|
const topic = TOPICS[topicOrCommand];
|
package/lib/runner.js
CHANGED
|
@@ -1,9 +1,25 @@
|
|
|
1
1
|
// Runs commands one at a time, whether they come from the prompt or the server.
|
|
2
2
|
// A prompt command tagged @<id> reports completion with a marker the caller can wait for.
|
|
3
3
|
const readline = require('readline');
|
|
4
|
-
const { state, beforeExit } = require('./state');
|
|
4
|
+
const { state, clientRecord, asClient, beforeExit } = require('./state');
|
|
5
5
|
const out = require('./output');
|
|
6
|
-
const { commands, dialogCommand, activeModes } = require('./commands');
|
|
6
|
+
const { commands, cliCommands, needsTab, dialogCommand, activeModes } = require('./commands');
|
|
7
|
+
const cliNames = require('./cli-names');
|
|
8
|
+
|
|
9
|
+
// playwright-cli names already explained, per client: each client is told once.
|
|
10
|
+
const explained = new Set();
|
|
11
|
+
|
|
12
|
+
// The line saying what a playwright-cli name is here, the first time its client uses it.
|
|
13
|
+
function explain(text, translated, client = state.client) {
|
|
14
|
+
const name = translated.key || text.split(/\s/)[0];
|
|
15
|
+
const key = `${client ?? ''}\0${name}`;
|
|
16
|
+
if (explained.has(key)) return null;
|
|
17
|
+
explained.add(key);
|
|
18
|
+
return `(playwright-cli's ${name} is ${translated.as} here)`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// quit and dialog skip the queue; so do playwright-cli's dialog-accept and dialog-dismiss.
|
|
22
|
+
const DIALOG = /^dialog(?:-accept|-dismiss)?(?:\s|$)/;
|
|
7
23
|
|
|
8
24
|
// A timeout here cannot have changed anything, so it is an ordinary error. Any
|
|
9
25
|
// other command that times out may or may not have done what it was sent to
|
|
@@ -12,7 +28,7 @@ const READ_ONLY = new Set(['info', 'listeners', 'text', 'html', 'attrs', 'count'
|
|
|
12
28
|
'snapshot', 'screenshot', 'wait', 'sleep', 'requests', 'body', 'console', 'cookies', 'storage', 'capture', 'help']);
|
|
13
29
|
|
|
14
30
|
// Commands that work with no tab selected.
|
|
15
|
-
const NO_TAB_NEEDED = new Set(['tab', 'modes', 'capture', 'dialog', 'help', 'quit']);
|
|
31
|
+
const NO_TAB_NEEDED = new Set(['tab', 'modes', 'capture', 'dialog', 'help', 'quit', 'tab-select', 'close']);
|
|
16
32
|
|
|
17
33
|
// Commands that accept a leading --all to lift the output limit.
|
|
18
34
|
const INSPECTION = ['info', 'listeners', 'text', 'html', 'attrs', 'links', 'inputs', 'eval', 'cdp', 'cookies', 'storage', 'capture', 'body', 'console', 'snapshot', 'watch'];
|
|
@@ -41,10 +57,12 @@ function busy() {
|
|
|
41
57
|
return running > 0;
|
|
42
58
|
}
|
|
43
59
|
|
|
44
|
-
|
|
60
|
+
// A queued command runs as its client; quit and dialog, which skip the queue,
|
|
61
|
+
// run as whoever is running, so they never switch the client under a command.
|
|
62
|
+
async function execute(text, client, direct = false) {
|
|
45
63
|
running += 1;
|
|
46
64
|
try {
|
|
47
|
-
return await executeCommand(text);
|
|
65
|
+
return direct ? await executeCommand(text) : await asClient(client, () => executeCommand(text));
|
|
48
66
|
} finally {
|
|
49
67
|
running -= 1;
|
|
50
68
|
}
|
|
@@ -53,6 +71,17 @@ async function execute(text) {
|
|
|
53
71
|
async function executeCommand(text) {
|
|
54
72
|
if (state.connectionLost) return { status: 'error', cmd: '' };
|
|
55
73
|
if (!text) return { status: 'ok', cmd: '' };
|
|
74
|
+
const translated = cliNames.translate(text);
|
|
75
|
+
if (translated?.refuse) {
|
|
76
|
+
out.error(`Error: ${translated.refuse}`);
|
|
77
|
+
return { status: 'error', cmd: text.split(/\s/)[0] };
|
|
78
|
+
}
|
|
79
|
+
if (translated) {
|
|
80
|
+
const note = explain(text, translated);
|
|
81
|
+
if (note) out.log(note);
|
|
82
|
+
if (translated.steps) return runSteps(translated);
|
|
83
|
+
text = translated.text;
|
|
84
|
+
}
|
|
56
85
|
const spaceIdx = text.indexOf(' ');
|
|
57
86
|
const cmd = spaceIdx === -1 ? text : text.slice(0, spaceIdx);
|
|
58
87
|
let args = spaceIdx === -1 ? '' : text.slice(spaceIdx + 1);
|
|
@@ -61,16 +90,17 @@ async function executeCommand(text) {
|
|
|
61
90
|
// wait request counts responses since the previous command began.
|
|
62
91
|
state.previousCommandAt = state.currentCommandAt;
|
|
63
92
|
state.currentCommandAt = Date.now();
|
|
64
|
-
|
|
93
|
+
const run = Object.hasOwn(commands, cmd) ? commands[cmd] : translated && Object.hasOwn(cliCommands, cmd) ? cliCommands[cmd] : null;
|
|
94
|
+
if (!run) {
|
|
65
95
|
out.log(`Unknown command: ${cmd}. Type 'help' for commands.`);
|
|
66
96
|
return { status: 'error', cmd };
|
|
67
97
|
}
|
|
68
|
-
if (!NO_TAB_NEEDED.has(cmd) && (!state.page || state.page.isClosed())) {
|
|
98
|
+
if (!NO_TAB_NEEDED.has(cmd) && needsTab(cmd, args) && (!state.page || state.page.isClosed())) {
|
|
69
99
|
out.error('Error: No tab is selected; select one with tab <index|url-part>, or open one with tab new');
|
|
70
100
|
return { status: 'error', cmd };
|
|
71
101
|
}
|
|
72
102
|
try {
|
|
73
|
-
await
|
|
103
|
+
await run(args, all);
|
|
74
104
|
} catch (e) {
|
|
75
105
|
// Playwright colours its call log; the codes are noise anywhere but a terminal.
|
|
76
106
|
e.message = String(e.message || '').replace(/\x1b\[[0-9;]*m/g, '');
|
|
@@ -83,6 +113,19 @@ async function executeCommand(text) {
|
|
|
83
113
|
return { status: 'ok', cmd };
|
|
84
114
|
}
|
|
85
115
|
|
|
116
|
+
// A playwright-cli command that is several here, run in turn until one fails.
|
|
117
|
+
async function runSteps({ steps, failed }) {
|
|
118
|
+
let result;
|
|
119
|
+
for (const [i, step] of steps.entries()) {
|
|
120
|
+
result = await executeCommand(step);
|
|
121
|
+
if (result.status !== 'ok') {
|
|
122
|
+
if (i && failed) out.error(failed);
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
|
|
86
129
|
// A quit does not wait for the running server command, which may never finish
|
|
87
130
|
// once the browser is gone, so its sender is answered now. A read-only command
|
|
88
131
|
// changed nothing; any other may or may not have done what it was sent to do.
|
|
@@ -98,12 +141,12 @@ function answerInterrupted() {
|
|
|
98
141
|
flight.resolve({ status: 'error', output: output ? `${output}\n${note}` : note, uncertain: !known, interrupted: true });
|
|
99
142
|
}
|
|
100
143
|
|
|
101
|
-
async function handleLine(line) {
|
|
144
|
+
async function handleLine(line, direct = false) {
|
|
102
145
|
const input = parseInput(line);
|
|
103
146
|
const done = status => { if (input.token) out.log(`[[pw-done:${input.token}:${status}]]`); };
|
|
104
147
|
if (state.stopping) return done('error');
|
|
105
148
|
if (/^quit(?:\s|$)/.test(input.text)) answerInterrupted();
|
|
106
|
-
const result = await execute(input.text);
|
|
149
|
+
const result = await execute(input.text, null, direct);
|
|
107
150
|
done(result.status);
|
|
108
151
|
if (result.cmd === 'quit') { await beforeExit(); process.exit(process.exitCode || 0); }
|
|
109
152
|
if (!state.stopping) prompt();
|
|
@@ -112,7 +155,8 @@ async function handleLine(line) {
|
|
|
112
155
|
// The prompt starts with the modes on in the selected tab: (watch network:off) pw>.
|
|
113
156
|
function prompt(preserveCursor) {
|
|
114
157
|
if (!state.rl) return;
|
|
115
|
-
const
|
|
158
|
+
const page = clientRecord(null).page;
|
|
159
|
+
const modes = page && !page.isClosed() ? activeModes(page) : [];
|
|
116
160
|
state.rl.setPrompt(`${modes.length ? `(${modes.join(' ')}) ` : ''}${state.promptBase}`);
|
|
117
161
|
state.rl.prompt(preserveCursor);
|
|
118
162
|
}
|
|
@@ -120,23 +164,24 @@ function prompt(preserveCursor) {
|
|
|
120
164
|
// quit and dialog skip the queue: quit so it works while a long command runs,
|
|
121
165
|
// dialog because the commands queued behind a dialog wait for its answer.
|
|
122
166
|
function enqueue(line) {
|
|
123
|
-
const
|
|
124
|
-
const
|
|
125
|
-
if (direct)
|
|
126
|
-
else queue = queue.then(
|
|
167
|
+
const text = parseInput(line).text;
|
|
168
|
+
const direct = /^quit(?:\s|$)/.test(text) || DIALOG.test(text);
|
|
169
|
+
if (direct) handleLine(line, true).catch(e => out.error(`Error: ${e.message}`));
|
|
170
|
+
else queue = queue.then(() => handleLine(line)).catch(e => out.error(`Error: ${e.message}`));
|
|
127
171
|
}
|
|
128
172
|
|
|
129
173
|
// Server commands share the prompt's queue and are echoed to the pane, so
|
|
130
174
|
// someone watching sees everything an agent does.
|
|
131
|
-
|
|
132
|
-
|
|
175
|
+
// client names the sender: its commands show as [server:<client>] and use its own selected tab.
|
|
176
|
+
function submit(text, client = null) {
|
|
177
|
+
if (DIALOG.test(text)) return answerDialog(text, client);
|
|
133
178
|
return new Promise(resolve => {
|
|
134
179
|
queue = queue.then(async () => {
|
|
135
180
|
if (state.stopping) return resolve({ status: 'error', output: 'The REPL is shutting down.' });
|
|
136
181
|
// Printed above whatever the person at the prompt is typing, which is
|
|
137
182
|
// redrawn afterwards rather than broken up.
|
|
138
183
|
if (process.stdout.isTTY) { readline.clearLine(process.stdout, 0); readline.cursorTo(process.stdout, 0); }
|
|
139
|
-
out.log(`[server] ${text}`);
|
|
184
|
+
out.log(`[server${client ? `:${client}` : ''}] ${text}`);
|
|
140
185
|
// So the person at the prompt can bring an agent's command back with up-arrow.
|
|
141
186
|
if (state.rl?.history && state.rl.history[0] !== text) {
|
|
142
187
|
state.rl.history.unshift(text);
|
|
@@ -149,7 +194,7 @@ function submit(text) {
|
|
|
149
194
|
let result;
|
|
150
195
|
const flight = { text, resolve, answered: false };
|
|
151
196
|
inFlight = flight;
|
|
152
|
-
const output = await out.collect(async () => { result = await execute(text); });
|
|
197
|
+
const output = await out.collect(async () => { result = await execute(text, client); });
|
|
153
198
|
if (inFlight === flight) inFlight = null;
|
|
154
199
|
if (!flight.answered) resolve({ status: result.status, output, uncertain: result.uncertain });
|
|
155
200
|
}
|
|
@@ -160,11 +205,14 @@ function submit(text) {
|
|
|
160
205
|
|
|
161
206
|
// Outside the queue, and so outside out.collect, whose single sink belongs to
|
|
162
207
|
// the command that is waiting on the dialog.
|
|
163
|
-
async function answerDialog(text) {
|
|
164
|
-
console.log(`[server] ${text}`);
|
|
208
|
+
async function answerDialog(text, client) {
|
|
209
|
+
console.log(`[server${client ? `:${client}` : ''}] ${text}`);
|
|
210
|
+
const translated = cliNames.translate(text);
|
|
211
|
+
const note = translated && explain(text, translated, client);
|
|
165
212
|
let result;
|
|
166
|
-
try { result = { status: 'ok', output: await dialogCommand(text.slice(6)) }; }
|
|
213
|
+
try { result = { status: 'ok', output: await dialogCommand((translated?.text || text).slice(6), clientRecord(client).page) }; }
|
|
167
214
|
catch (error) { result = { status: 'error', output: `Error: ${error.message}` }; }
|
|
215
|
+
if (note) result.output = `${note}\n${result.output}`;
|
|
168
216
|
console.log(result.output);
|
|
169
217
|
if (!state.stopping) prompt(true);
|
|
170
218
|
return result;
|
package/lib/send.js
CHANGED
|
@@ -94,8 +94,12 @@ async function send(options) {
|
|
|
94
94
|
if (/[\r\n]/.test(command)) return fail('the command must be a single line');
|
|
95
95
|
const timeoutMs = options.timeout * 1000;
|
|
96
96
|
const way = route(options);
|
|
97
|
+
const name = options.client || process.env.PW_CLIENT || null;
|
|
98
|
+
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');
|
|
99
|
+
// The pane has one prompt, so a command typed there has no client of its own.
|
|
100
|
+
if (way.kind === 'tmux' && options.client) return fail('-c needs the command server (pw-repl serve); a REPL in tmux has no clients');
|
|
97
101
|
if (way.kind === 'tmux') return viaTmux(way.session, command, timeoutMs);
|
|
98
|
-
const answer = await client.request(way.endpoint, command, timeoutMs);
|
|
102
|
+
const answer = await client.request(way.endpoint, command, timeoutMs, name);
|
|
99
103
|
if (answer.result) {
|
|
100
104
|
if (answer.result.output) console.log(answer.result.output);
|
|
101
105
|
if (answer.result.unconfirmed) { console.error('completion not confirmed'); return 2; }
|
package/lib/server.js
CHANGED
|
@@ -6,9 +6,11 @@ const fs = require('fs');
|
|
|
6
6
|
const out = require('./output');
|
|
7
7
|
const runner = require('./runner');
|
|
8
8
|
const { state, onShutdown, onBeforeExit } = require('./state');
|
|
9
|
-
const { parseEndpoint, describe } = require('./client');
|
|
9
|
+
const { parseEndpoint, describe, DEFAULT_SOCKET } = require('./client');
|
|
10
|
+
const path = require('path');
|
|
10
11
|
|
|
11
12
|
const MAX_BODY = 1024 * 1024;
|
|
13
|
+
const CLIENT_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$/;
|
|
12
14
|
const FLUSH_TIMEOUT = 1000;
|
|
13
15
|
|
|
14
16
|
// Answers being written, so the REPL can let them reach their senders before it exits.
|
|
@@ -40,11 +42,15 @@ function handle(req, res) {
|
|
|
40
42
|
});
|
|
41
43
|
req.on('end', async () => {
|
|
42
44
|
let command;
|
|
43
|
-
|
|
45
|
+
let client;
|
|
46
|
+
try { ({ command, client } = JSON.parse(body)); } catch {}
|
|
44
47
|
if (typeof command !== 'string' || !command.trim() || /[\r\n]/.test(command)) {
|
|
45
|
-
return send(res, 400, { status: 'error', output: 'Body must be {"command": "<one line>"}' });
|
|
48
|
+
return send(res, 400, { status: 'error', output: 'Body must be {"command": "<one line>"}, with "client": "<name>" if it has one' });
|
|
46
49
|
}
|
|
47
|
-
|
|
50
|
+
if (client !== undefined && client !== null && !CLIENT_NAME.test(client)) {
|
|
51
|
+
return send(res, 400, { status: 'error', output: 'A client name is 1-40 letters, digits, dots, dashes and underscores' });
|
|
52
|
+
}
|
|
53
|
+
const result = await runner.submit(command.trim(), client || null);
|
|
48
54
|
if (!result.uncertain) return send(res, 200, { status: result.status, output: result.output });
|
|
49
55
|
// unconfirmed: the command may or may not have done what it was sent to do.
|
|
50
56
|
send(res, 200, { status: result.status, output: result.output, unconfirmed: true });
|
|
@@ -80,6 +86,7 @@ async function serve(endpointArg) {
|
|
|
80
86
|
} finally {
|
|
81
87
|
if (previousUmask !== null) process.umask(previousUmask);
|
|
82
88
|
}
|
|
89
|
+
if (endpoint.socket && path.resolve(endpoint.socket) !== path.resolve(DEFAULT_SOCKET)) state.ownDir = path.dirname(path.resolve(endpoint.socket));
|
|
83
90
|
const removeSocket = () => { if (endpoint.socket) try { fs.unlinkSync(endpoint.socket); } catch {} };
|
|
84
91
|
onShutdown(() => { server.close(); removeSocket(); });
|
|
85
92
|
process.on('exit', removeSocket);
|
package/lib/state.js
CHANGED
|
@@ -6,9 +6,9 @@ const SHUTDOWN_TIMEOUT = 5000;
|
|
|
6
6
|
|
|
7
7
|
const state = {
|
|
8
8
|
browser: null,
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
// The client whose command runs now: null for the prompt and senders without
|
|
10
|
+
// a name, who share one selected tab, as before clients had names.
|
|
11
|
+
client: null,
|
|
12
12
|
rl: null,
|
|
13
13
|
promptBase: 'pw> ',
|
|
14
14
|
// The browser the REPL is connected to, and whether --launch started it.
|
|
@@ -17,10 +17,31 @@ const state = {
|
|
|
17
17
|
stopping: false,
|
|
18
18
|
connectionLost: false,
|
|
19
19
|
shutdownFailed: false,
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
// The folder of a socket of its own, where its screenshots go.
|
|
21
|
+
ownDir: null,
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
// What each client has selected and when its commands ran: state.page and the
|
|
25
|
+
// rest read and write the running client's, so commands need not know.
|
|
26
|
+
const PER_CLIENT = { page: null, previousPage: null, tabListing: [], previousCommandAt: 0, currentCommandAt: 0 };
|
|
27
|
+
const clients = new Map();
|
|
28
|
+
|
|
29
|
+
function clientRecord(name = state.client) {
|
|
30
|
+
if (!clients.has(name)) clients.set(name, { ...PER_CLIENT, tabListing: [] });
|
|
31
|
+
return clients.get(name);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (const key of Object.keys(PER_CLIENT)) {
|
|
35
|
+
Object.defineProperty(state, key, { enumerable: true, get: () => clientRecord()[key], set: value => { clientRecord()[key] = value; } });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Runs fn as the named client. Commands run one at a time, so this is safe.
|
|
39
|
+
async function asClient(name, fn) {
|
|
40
|
+
const previous = state.client;
|
|
41
|
+
state.client = name || null;
|
|
42
|
+
try { return await fn(); } finally { state.client = previous; }
|
|
43
|
+
}
|
|
44
|
+
|
|
24
45
|
const cleanups = [];
|
|
25
46
|
const exitWaits = [];
|
|
26
47
|
let shutdownPromise = null;
|
|
@@ -62,4 +83,4 @@ async function shutdown() {
|
|
|
62
83
|
return shutdownPromise;
|
|
63
84
|
}
|
|
64
85
|
|
|
65
|
-
module.exports = { state, withTimeout, onShutdown, onBeforeExit, beforeExit, shutdown };
|
|
86
|
+
module.exports = { state, clientRecord, asClient, withTimeout, onShutdown, onBeforeExit, beforeExit, shutdown };
|
package/lib/syntax.js
CHANGED
|
@@ -26,4 +26,25 @@ function splitSelector(args) {
|
|
|
26
26
|
return { word, selector: toSelector(word), rest: match[4] };
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
// Key names in any case, as playwright-cli's press takes them (arrowleft), in
|
|
30
|
+
// the case Playwright needs (ArrowLeft); a single character is kept as it is.
|
|
31
|
+
const KEY_NAMES = [
|
|
32
|
+
'Backquote', 'Minus', 'Equal', 'Backslash', 'Backspace', 'Tab', 'BracketLeft', 'BracketRight', 'CapsLock',
|
|
33
|
+
'Semicolon', 'Quote', 'Enter', 'Comma', 'Period', 'Slash', 'Space', 'Escape', 'Shift', 'ShiftLeft', 'ShiftRight',
|
|
34
|
+
'Control', 'ControlLeft', 'ControlRight', 'Meta', 'MetaLeft', 'MetaRight', 'Alt', 'AltLeft', 'AltRight',
|
|
35
|
+
'AltGraph', 'ControlOrMeta', 'ContextMenu', 'PrintScreen', 'ScrollLock', 'Pause', 'PageUp', 'PageDown',
|
|
36
|
+
'Insert', 'Delete', 'Home', 'End', 'ArrowLeft', 'ArrowUp', 'ArrowRight', 'ArrowDown', 'NumLock', 'Clear',
|
|
37
|
+
'NumpadDivide', 'NumpadMultiply', 'NumpadSubtract', 'NumpadAdd', 'NumpadDecimal', 'NumpadEnter',
|
|
38
|
+
'AudioVolumeMute', 'AudioVolumeDown', 'AudioVolumeUp', 'MediaTrackNext', 'MediaTrackPrevious', 'MediaPlayPause',
|
|
39
|
+
...Array.from({ length: 12 }, (_, i) => `F${i + 1}`),
|
|
40
|
+
...Array.from({ length: 10 }, (_, i) => [`Digit${i}`, `Numpad${i}`]).flat(),
|
|
41
|
+
...Array.from({ length: 26 }, (_, i) => `Key${String.fromCharCode(65 + i)}`),
|
|
42
|
+
];
|
|
43
|
+
const KEYS = new Map(KEY_NAMES.map(name => [name.toLowerCase(), name]));
|
|
44
|
+
|
|
45
|
+
function keyName(key) {
|
|
46
|
+
// Control+Shift+ArrowLeft: each part, where + itself may be the last.
|
|
47
|
+
return key.split(/\+(?!$)/).map(part => (part.length > 1 ? KEYS.get(part.toLowerCase()) || part : part)).join('+');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { SELECTOR_FIRST, REF, toSelector, unquote, splitSelector, keyName };
|