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/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 the latest tab listing; they change when 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 close closes the selected tab, or the 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.',
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: { usage: 'click <selector>', summary: 'click the first match' },
96
- dblclick: { usage: 'dblclick <selector>', summary: 'double-click the first match' },
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
- type: { usage: 'type <selector> <text>', summary: 'type key by key after what the field holds; text as for fill' },
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 (their numbers in requests; body\n<#> for one), leaving out scripts and what requests hides. The REPL\'s own fill is recorded as type.\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.\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.',
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] [--delay|-d <seconds>] [name]',
137
- summary: 'save a PNG of the viewport (or --full page)',
138
- detail: 'Saved as screenshot-<name or timestamp>.png in $PW_SCREENSHOT_DIR (default /tmp). It brings the tab to\nthe front of its window first: Chrome draws only the tab in front. The image is at CSS pixel size,\none 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.',
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. While mobile is on, viewport refuses: the device sets the size.',
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; wait text <text> for text to be visible; wait request\n<url-part|glob> for a matching response, counting one that finished since the previous command began\n(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.',
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 <#> from requests',
160
- detail: 'Read 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.',
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\nfilter matches the type or the text, e.g. console error.\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.',
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 reaches the network,\n so it still answers while the network is off\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.\n\nExample: route **/api/cart patch {"total": 0}',
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\ncannot read one that already happened (requests lists those).',
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
- async function execute(text) {
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
- if (!Object.hasOwn(commands, cmd)) {
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 commands[cmd](args, all);
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 modes = state.page && !state.page.isClosed() ? activeModes(state.page) : [];
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 direct = /^(?:@[A-Za-z0-9][A-Za-z0-9._-]*\s+)?(?:quit|dialog)(?:\s|$)/.test(line.trim());
124
- const run = () => handleLine(line);
125
- if (direct) run().catch(e => out.error(`Error: ${e.message}`));
126
- else queue = queue.then(run).catch(e => out.error(`Error: ${e.message}`));
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
- function submit(text) {
132
- if (/^dialog(?:\s|$)/.test(text)) return answerDialog(text);
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
- try { ({ command } = JSON.parse(body)); } catch {}
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
- const result = await runner.submit(command.trim());
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
- page: null,
10
- previousPage: null,
11
- tabListing: [],
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
- previousCommandAt: 0,
21
- currentCommandAt: 0,
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
- module.exports = { SELECTOR_FIRST, toSelector, unquote, splitSelector };
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pw-repl",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Text REPL for driving an existing Chromium through Playwright over CDP",
5
5
  "keywords": [
6
6
  "playwright",