pw-repl 0.3.1 → 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 +34 -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 +463 -97
- package/lib/help.js +68 -29
- package/lib/runner.js +70 -22
- package/lib/send.js +7 -2
- 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 +60 -11
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: {
|
|
@@ -52,7 +56,7 @@ const TOPICS = {
|
|
|
52
56
|
commands: ['requests', 'body', 'console', 'capture', 'route', 'network'],
|
|
53
57
|
},
|
|
54
58
|
devtools: {
|
|
55
|
-
intro: 'Cookie and storage listings omit values. eval, html, screenshots and URLs can still show
|
|
59
|
+
intro: 'Cookie and storage listings omit values. eval, cdp, html, screenshots and URLs can still show\nsensitive data (cdp Network.getCookies shows cookie values).',
|
|
56
60
|
commands: ['eval', 'cdp', 'cookies', 'storage'],
|
|
57
61
|
},
|
|
58
62
|
session: {
|
|
@@ -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,27 +86,39 @@ 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>',
|
|
84
93
|
summary: 'navigate the selected tab',
|
|
85
94
|
detail: 'Without a scheme, http:// is assumed for localhost, 127.x and [::1], and https:// otherwise, as in\nthe address bar. tab new <url> reads its URL the same way.',
|
|
86
95
|
},
|
|
87
|
-
back: {
|
|
88
|
-
|
|
96
|
+
back: {
|
|
97
|
+
usage: 'back',
|
|
98
|
+
summary: 'go back one history entry',
|
|
99
|
+
detail: 'The browser may bring the page back from its back/forward cache, as it was: nothing loads or runs\nagain, so a loading message or a request the page makes on load does not come back. reload loads it\nafresh.',
|
|
100
|
+
},
|
|
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.' },
|
|
89
102
|
reload: { usage: 'reload', summary: 'reload the selected tab' },
|
|
90
103
|
info: { usage: 'info', summary: 'selected tab URL, title and viewport' },
|
|
91
|
-
click: {
|
|
92
|
-
|
|
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' },
|
|
93
110
|
hover: { usage: 'hover <selector>', summary: 'hover the first match' },
|
|
94
111
|
fill: {
|
|
95
|
-
usage: 'fill <selector> <value>',
|
|
112
|
+
usage: 'fill <selector> <value> [--submit]',
|
|
96
113
|
summary: 'clear an input and fill it',
|
|
97
|
-
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.',
|
|
98
120
|
},
|
|
99
|
-
|
|
100
|
-
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' },
|
|
101
122
|
select: { usage: 'select <selector> <value>', summary: 'choose an option in a select (the value is the rest of the line)' },
|
|
102
123
|
check: { usage: 'check <selector>', summary: 'check a checkbox' },
|
|
103
124
|
uncheck: { usage: 'uncheck <selector>', summary: 'uncheck a checkbox' },
|
|
@@ -109,12 +130,12 @@ const COMMANDS = {
|
|
|
109
130
|
snapshot: {
|
|
110
131
|
usage: 'snapshot [--full] [--grep <text> | <eN> | selector]',
|
|
111
132
|
summary: 'outline by role and name, with [ref=eN] labels',
|
|
112
|
-
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.\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.',
|
|
113
134
|
},
|
|
114
135
|
watch: {
|
|
115
136
|
usage: 'watch on [--changes] [--live] | off | [n|new]',
|
|
116
137
|
summary: 'record what happens in the tab; show the last n steps',
|
|
117
|
-
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.',
|
|
118
139
|
},
|
|
119
140
|
text: { usage: 'text [--all] <selector>', summary: 'visible text of the first match' },
|
|
120
141
|
html: { usage: 'html [--all] <selector>', summary: 'outer HTML of the first match' },
|
|
@@ -129,36 +150,36 @@ const COMMANDS = {
|
|
|
129
150
|
links: { usage: 'links [--all]', summary: 'links on the page (text and href)' },
|
|
130
151
|
inputs: { usage: 'inputs [--all]', summary: 'form controls on the page; values are omitted' },
|
|
131
152
|
screenshot: {
|
|
132
|
-
usage: 'screenshot [--full] [
|
|
133
|
-
summary: 'save a PNG of the viewport
|
|
134
|
-
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.',
|
|
135
156
|
},
|
|
136
157
|
viewport: { usage: 'viewport [WxH]', summary: 'show or set the viewport size' },
|
|
137
158
|
emulate: {
|
|
138
159
|
usage: 'emulate [<what> [off] | off]',
|
|
139
160
|
summary: 'emulate a phone, dark mode, a locale or a timezone',
|
|
140
|
-
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\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\
|
|
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.',
|
|
141
162
|
},
|
|
142
163
|
wait: {
|
|
143
164
|
usage: 'wait [text|request] <what> [--gone] [secs]',
|
|
144
165
|
summary: 'wait for an element, text, a response or a load (10s)',
|
|
145
|
-
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.',
|
|
146
167
|
},
|
|
147
168
|
sleep: { usage: 'sleep <ms>', summary: 'wait a fixed time (maximum 3600000)' },
|
|
148
169
|
requests: {
|
|
149
170
|
usage: 'requests [--all] [n] [url-filter]',
|
|
150
171
|
summary: 'the selected tab\'s last n requests (default 20)',
|
|
151
|
-
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.',
|
|
152
173
|
},
|
|
153
174
|
body: {
|
|
154
|
-
usage: 'body [--all]
|
|
155
|
-
summary: 'the response body of request
|
|
156
|
-
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.',
|
|
157
178
|
},
|
|
158
179
|
console: {
|
|
159
|
-
usage: 'console [--all] [n] [filter]',
|
|
180
|
+
usage: 'console [--all] [n] [level|filter]',
|
|
160
181
|
summary: 'the last n console messages and page errors',
|
|
161
|
-
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.',
|
|
162
183
|
},
|
|
163
184
|
capture: {
|
|
164
185
|
usage: 'capture on [requests|console] [secs] | off',
|
|
@@ -168,17 +189,17 @@ const COMMANDS = {
|
|
|
168
189
|
route: {
|
|
169
190
|
usage: 'route <glob> <how> | off <glob>|--all',
|
|
170
191
|
summary: 'fake, patch, delay or fail the selected tab\'s matching requests',
|
|
171
|
-
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}',
|
|
172
193
|
},
|
|
173
194
|
network: {
|
|
174
195
|
usage: 'network [on|off|slow [<ms> [<kbps>]]]',
|
|
175
196
|
summary: 'cut, slow or restore the tab\'s network',
|
|
176
|
-
detail: 'network off cuts it, like dropped wifi. Stopping a service is not the same: a dev proxy in front of\nit usually holds the request open, so the page spins instead of failing.\n\nnetwork slow
|
|
197
|
+
detail: 'network off cuts it, like dropped wifi. Stopping a service is not the same: a dev proxy in front of\nit usually holds the request open, so the page spins instead of failing.\n\nnetwork slow makes each request take at least <ms> (Chrome\'s latency is a minimum, not added to a\nslow server\'s own time) and limits its speed: by default as DevTools\' Slow 4G (563ms, 1440 kbps\ndown, 675 up); network slow <ms> [<kbps>] sets them, kbps both ways (left out, it stays Slow\n4G\'s). To slow one API, use route <glob> delay <secs>.\n\nPer tab; lasts until network on or the REPL exits. Routes still answer while it is off or slow.',
|
|
177
198
|
},
|
|
178
199
|
eval: {
|
|
179
200
|
usage: 'eval [--all] <JavaScript>',
|
|
180
201
|
summary: 'evaluate JavaScript in the selected tab and print the result',
|
|
181
|
-
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).',
|
|
182
203
|
},
|
|
183
204
|
cdp: {
|
|
184
205
|
usage: 'cdp [--all] <method> <JSON object>',
|
|
@@ -188,9 +209,9 @@ const COMMANDS = {
|
|
|
188
209
|
cookies: { usage: 'cookies [--all]', summary: 'cookie names, domains and flags; values are omitted' },
|
|
189
210
|
storage: { usage: 'storage [--all]', summary: 'localStorage keys; values are omitted' },
|
|
190
211
|
modes: {
|
|
191
|
-
usage: 'modes [off]',
|
|
212
|
+
usage: 'modes [off [--mine]]',
|
|
192
213
|
summary: 'the modes on in every tab; modes off turns them all off',
|
|
193
|
-
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.',
|
|
194
215
|
},
|
|
195
216
|
dialog: {
|
|
196
217
|
usage: 'dialog [accept [text] | dismiss]',
|
|
@@ -230,6 +251,24 @@ function renderAll() {
|
|
|
230
251
|
|
|
231
252
|
function render(topicOrCommand) {
|
|
232
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
|
+
}
|
|
233
272
|
if (topicOrCommand === '--all') return renderAll();
|
|
234
273
|
if (Object.hasOwn(TOPICS, topicOrCommand)) {
|
|
235
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; }
|
|
@@ -117,7 +121,8 @@ async function where(options) {
|
|
|
117
121
|
// use, so a REPL that will not start can be told apart from one that is not running.
|
|
118
122
|
const cdp = info?.browser || process.env.PW_CDP_URL || 'http://localhost:9222';
|
|
119
123
|
const version = await fetch(`${cdp}/json/version`, { signal: AbortSignal.timeout(2000) }).then(r => r.json()).catch(() => null);
|
|
120
|
-
|
|
124
|
+
// With no REPL running, it is only where one would connect, not a browser of a REPL that stopped.
|
|
125
|
+
const whose = info?.launched ? ', launched by this REPL' : info ? '' : '; run and serve connect here without --launch';
|
|
121
126
|
console.log(version ? `browser: ${cdp} answers (${version.Browser}${whose})` : `browser: nothing answers at ${cdp}; pw-repl run says how to start one`);
|
|
122
127
|
if (way.kind === 'server') {
|
|
123
128
|
const name = client.describe(way.endpoint);
|
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 };
|