pw-repl 0.3.2 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -14,16 +14,21 @@ If you hit a limitation or write a workaround, consider adding the capability to
14
14
  runs commands one at a time (`quit` and `dialog` skip its queue); `lib/server.js` is the command
15
15
  server; `lib/send.js` and `lib/client.js` are `send` and `where`; `lib/background.js` is
16
16
  `serve --background`, `attach` and `stop`; `lib/launch.js` is `--launch`; `lib/syntax.js` is how a
17
- command line's words are read, shared with `send`; `lib/state.js` holds the session state;
17
+ command line's words are read, shared with `send`; `lib/cli-names.js` maps playwright-cli's command
18
+ names to these; `lib/state.js` holds the session state, with each client's selected tab;
18
19
  `lib/output.js` routes all output so the server can return it.
20
+ - Match playwright-cli (Microsoft's CLI for agents) where it is reasonable: agents are likelier to be
21
+ trained on it. A new command, option or output that does what one of its does takes its name and form,
22
+ or accepts them too (`lib/cli-names.js`, `help playwright-cli`). Differ where sharing a browser with a
23
+ person needs it, and say why in the help.
19
24
  - Comment the *why* when it isn't obvious from the code.
20
- - `npm test` runs the suite (about a minute): a private headless Chromium, a local test site
25
+ - `npm test` runs the suite (under two minutes): a private headless Chromium, a local test site
21
26
  (`test/harness.js`), and the real REPL with its server. Nothing is mocked, and the shared browser is
22
27
  never touched. It finds Chromium through `PW_TEST_CHROME`, or where `--launch` looks (Playwright's
23
28
  browsers, then the `PATH`), and skips the browser tests if there is none. Add a test with each new
24
29
  command or behaviour.
25
- - For anything the tests can't reach, exercise the change in a running REPL. Use your own tmux session
26
- and a new tab, not the user's.
30
+ - For anything the tests can't reach, exercise the change in a running REPL of your own: on a socket of
31
+ your own, with `--launch` unless the change needs the user's browser, and then in a tab of your own.
27
32
  - `skill/SKILL.md` is how agents learn to use the REPL: keep it in step with a change to how it is
28
33
  used, and leave its Custom rules section empty.
29
34
 
@@ -41,6 +46,8 @@ Work goes in waves, and each one ends with a release that is ready to push.
41
46
  ask again.
42
47
  - Then it carries the plan out, in a browser and on a socket of its own, and reports as it goes:
43
48
  above all, where the tool did something other than what the docs led it to expect.
49
+ - Once it has confirmed the fixes work, it reviews them as a second pair of eyes: anything important
50
+ they miss or get wrong, not nitpicks.
44
51
  3. Triage what it reports. Fix what matters, and what is small and useful; don't put off something
45
52
  useful for later. Leave trivia alone, and say why.
46
53
  4. Repeat 2 and 3, with a fresh agent each time, until a pass comes back clean: no real bugs, and
package/README.md CHANGED
@@ -1,7 +1,40 @@
1
1
  # playwright-repl
2
2
 
3
- A text REPL for driving an existing Chromium through Playwright over CDP. You and agents can share the same
4
- browser: people click in it, and the REPL inspects it, fakes responses, and records what happened.
3
+ A text REPL for driving an existing Chromium through Playwright over CDP: one session that people and agents
4
+ share. People click in the browser and type commands at the prompt; agents send the same commands, and every
5
+ one shows in the REPL, so each sees what the other does. The REPL inspects the page, records what happened
6
+ (requests, console, and each step a person takes), fakes, patches or slows responses, and emulates phones,
7
+ dark mode, locales and timezones.
8
+
9
+ ## Why use it
10
+
11
+ For working on a web page together with an agent, in the browser you already use:
12
+
13
+ - **Your browser, as it is.** It connects to a running Chromium, with your tabs, sign-ins and state, and
14
+ leaves it running when it stops; nothing is reset to a fresh profile.
15
+ - **One session, seen by everyone.** You and any number of agents drive the same browser, each with a
16
+ selected tab of its own, and every command shows in the REPL, whoever sent it.
17
+ - **What you did, for an agent to read.** `watch` records each step you take in a tab, with the requests
18
+ it caused, so you can reproduce a bug by hand and the agent can see exactly what happened.
19
+ - **Breaking things on purpose.** Fake, patch, delay or fail responses, cut or slow the network, emulate a
20
+ phone, a locale or a timezone, all per tab, so your other tabs are left alone.
21
+
22
+ ## How it differs from playwright-cli
23
+
24
+ [playwright-cli](https://github.com/microsoft/playwright-cli) is Microsoft's command line for agents. It
25
+ gives each agent a browser session of its own; pw-repl shares one browser between a person and agents.
26
+ Its command names and most of their options work here too (`help playwright-cli` lists them), and where
27
+ pw-repl differs, it is because someone else may be using the browser:
28
+
29
+ - `open` and `close` open a tab and turn off your modes; they never start, close or wipe a browser.
30
+ - Routes, network and emulation apply to one tab, not the whole browser.
31
+ - File pickers are not caught, since they may be the person's: `upload` names the input instead.
32
+ - There is no snapshot after every command, since a person reads the REPL too; `snapshot` shows one.
33
+ - Instead of `-s=<session>`, an agent is a client of its own (`send -c <name>`).
34
+ - `watch` records what a person does as steps with their requests, not as generated code.
35
+
36
+ For a browser of its own per agent, isolated sessions, traces, video, PDFs or generated test code,
37
+ playwright-cli is the better fit.
5
38
 
6
39
  ## Getting started
7
40
 
@@ -141,6 +174,10 @@ may not have done what it was sent to do (it timed out, or the REPL quit while i
141
174
  read-only). It is off unless started with `pw-repl serve`.
142
175
  `pw-repl serve <port>` serves TCP on 127.0.0.1 instead; other addresses are refused.
143
176
 
177
+ Several agents can share one REPL: `pw-repl send -c <name>` (or `PW_CLIENT=<name>`) sends as a client with a
178
+ selected tab of its own, shown in the pane as `[server:<name>]`. `modes` says which client turned each mode on,
179
+ and `modes off --mine` turns off only the sender's own.
180
+
144
181
  ### A skill for agents
145
182
 
146
183
  `pw-repl skill` prints an agent skill (`SKILL.md`) that teaches an agent to use the REPL: how to start
package/bin/pw-repl.js CHANGED
@@ -17,7 +17,7 @@ const USAGE = `Usage:
17
17
  pw-repl stop [-e endpoint]
18
18
  stop a background REPL
19
19
 
20
- pw-repl send [-e endpoint | -s session] [-t seconds] <command...>
20
+ pw-repl send [-e endpoint | -s session] [-c client] [-t seconds] <command...>
21
21
  run one command in a running REPL and print its output
22
22
 
23
23
  pw-repl where [-e endpoint | -s session]
@@ -33,6 +33,9 @@ send uses the server when -e, $PW_ENDPOINT, or the socket ($PW_SOCKET, default /
33
33
  is there, and the tmux session (-s, $PW_TMUX_SESSION, default playwright-repl) otherwise. If the socket
34
34
  exists but nothing answers, send fails rather than fall back.
35
35
 
36
+ send -c <client> (or $PW_CLIENT) sends as a client of the REPL's with a selected tab of its own, so
37
+ several agents can share one REPL; pw-repl help session has the rest.
38
+
36
39
  send exit status: 0 ok, 1 command error, 2 completion not confirmed, 64 usage or unreachable.
37
40
 
38
41
  The browser must be running with --remote-debugging-port (default http://localhost:9222; set $PW_CDP_URL).
@@ -53,17 +56,27 @@ function usage() {
53
56
  process.exit(64);
54
57
  }
55
58
 
56
- // -e, -s and -t, then the command words (unquoted words are one command).
59
+ // -e, -s, -c and -t, then the command words (unquoted words are one command).
57
60
  function parseSendArgs(args, allowCommand) {
58
- const options = { endpoint: null, session: null, timeout: 20, command: '' };
61
+ const options = { endpoint: null, session: null, client: null, timeout: 20, command: '' };
59
62
  let i = 0;
60
63
  for (; i < args.length; i++) {
61
64
  const arg = args[i];
62
- if (arg === '-e' || arg === '-s' || arg === '-t') {
65
+ // playwright-cli's --raw: the output here is only the command's already.
66
+ if (arg === '--raw') continue;
67
+ // playwright-cli's -s=<session> names a browser of its own; the nearest here is a client.
68
+ if (/^(?:-s|--session)=/.test(arg)) {
69
+ console.error(`pw-repl: ${arg} is playwright-cli's session, a browser of its own. Here -s <name> is a tmux session;`);
70
+ console.error('the nearest to a session is a client: -c <name> (or PW_CLIENT), with a selected tab of its own, in a');
71
+ console.error('browser it shares, cookies, storage and modes and all.');
72
+ process.exit(64);
73
+ }
74
+ if (arg === '-e' || arg === '-s' || arg === '-c' || arg === '-t') {
63
75
  const value = args[++i];
64
76
  if (value === undefined) usage();
65
77
  if (arg === '-e') options.endpoint = value;
66
78
  if (arg === '-s') options.session = value;
79
+ if (arg === '-c') options.client = value;
67
80
  if (arg === '-t') {
68
81
  if (!/^\d+$/.test(value) || Number(value) < 1) usage();
69
82
  options.timeout = Number(value);
@@ -77,11 +90,23 @@ function parseSendArgs(args, allowCommand) {
77
90
  // command takes the rest of its line as it is (eval, route's JSON), so its
78
91
  // words are joined as they are.
79
92
  const { SELECTOR_FIRST } = require('../lib/syntax');
80
- const requote = words.length > 1 && SELECTOR_FIRST.has(words[0]);
93
+ // So does playwright-cli's storage key (localstorage-set "my key" v).
94
+ const requote = words.length > 1 && (SELECTOR_FIRST.has(words[0]) || /^(?:local|session)storage-/.test(words[0]));
81
95
  // An empty word (fill #name "") is the empty value.
82
96
  // upload's files are read by the REPL, whose folder may not be this one.
83
- const resolved = words[0] === 'upload' ? words.map((w, n) => (n > 1 ? require('path').resolve(w) : w)) : words;
97
+ // So are the files upload reads and screenshot --filename writes.
98
+ const resolve = require('path').resolve;
99
+ let resolved = words[0] === 'upload' ? words.map((w, n) => (n > 1 ? resolve(w) : w)) : words;
100
+ if (words[0] === 'screenshot') {
101
+ resolved = words.map((w, n) => (w.startsWith('--filename=') && w.length > 11 ? `--filename=${resolve(w.slice(11))}` : words[n - 1] === '--filename' ? resolve(w) : w));
102
+ }
84
103
  const quoted = requote ? resolved.map(w => (w === '' || /[\s"']/.test(w) ? JSON.stringify(w) : w)) : resolved;
104
+ // An option's value the shell kept whole stays whole too: --device='iPhone 15'.
105
+ // Single quotes escape nothing, so a backslash (a regexp's \d) reaches it as typed.
106
+ for (const [n, w] of quoted.entries()) {
107
+ const option = /^(--[A-Za-z][\w-]*=)([\s\S]*\s[\s\S]*)$/.exec(w);
108
+ if (n && option && !requote) quoted[n] = `${option[1]}${option[2].includes("'") ? JSON.stringify(option[2]) : `'${option[2]}'`}`;
109
+ }
85
110
  options.command = quoted.join(' ').trim();
86
111
  return options;
87
112
  }
@@ -107,7 +132,7 @@ function help(topic) {
107
132
  const helpText = require('../lib/help');
108
133
  const text = helpText.render(topic);
109
134
  if (text === null) {
110
- console.log(`Error: No help for ${topic}. Topics: ${Object.keys(helpText.TOPICS).join(', ')}`);
135
+ console.log(`Error: ${helpText.notFound(topic)}`);
111
136
  process.exit(1);
112
137
  }
113
138
  console.log(text);
@@ -0,0 +1,282 @@
1
+ // playwright-cli's command names, for agents who know that tool: each is
2
+ // rewritten to the pw-repl command that does the same, or refused with the
3
+ // nearest one. Names both tools share (goto, click, fill, snapshot, eval, ...)
4
+ // are pw-repl's own commands already.
5
+
6
+ const { unquote, splitSelector } = require('./syntax');
7
+
8
+ // name -> [what pw-repl runs (from the arguments), how it is written here]
9
+ const NAMES = {
10
+ 'tab-list': [() => 'tab', 'tab'],
11
+ 'tab-new': [a => `tab new ${a}`.trim(), 'tab new [url]'],
12
+ // By index only, unlike tab <number>, which falls back to a URL containing it.
13
+ 'tab-select': [a => `tab-select ${a}`, 'tab <index>, by index only'],
14
+ // By index there, by URL here: a number could match a port in another tab's URL.
15
+ 'tab-close': [a => (a ? null : 'tab close'), 'tab close [url-part], or tab <index> then tab close'],
16
+ open: [a => `tab new ${a}`.trim(), 'tab new [url]; --mobile or --device=<name> is emulate mobile [name]'],
17
+ // It ends playwright-cli's own browser; this one is shared, so tabs stay open.
18
+ close: [() => 'close', 'modes off --mine, and tabs stay open'],
19
+ 'go-back': [() => 'back', 'back'],
20
+ 'go-forward': [() => 'forward', 'forward'],
21
+ find: [a => `snapshot --grep ${a}`, 'snapshot --grep <text>'],
22
+ 'dialog-accept': [a => `dialog accept ${a}`.trim(), 'dialog accept [text]'],
23
+ 'dialog-dismiss': [() => 'dialog dismiss', 'dialog dismiss'],
24
+ resize: [a => { const [w, h] = a.split(/\s+/); return w && h ? `viewport ${w}x${h}` : null; }, 'viewport <w>x<h>'],
25
+ 'cookie-list': [() => 'cookies', 'cookies'],
26
+ 'localstorage-list': [() => 'storage', 'storage'],
27
+ 'set-color-scheme': [a => (/^(dark|light)$/.test(a) ? `emulate ${a}` : null), 'emulate dark|light'],
28
+ 'clear-color-scheme': [() => 'emulate dark off', 'emulate dark off'],
29
+ 'network-state-set': [a => ({ offline: 'network off', online: 'network on' })[a] || null, 'network off|on'],
30
+ 'route-list': [() => 'route', 'route'],
31
+ unroute: [a => (a ? `route off ${a}` : 'route off --all'), 'route off <glob>|--all'],
32
+ request: [a => `body ${a}`, 'body <#>, the response body only'],
33
+ 'response-body': [a => `body ${a}`, 'body <#>'],
34
+ };
35
+
36
+ // Storage by eval: a key is the first word, quoted if it has spaces, and a value the rest.
37
+ function storage(which, make) {
38
+ return a => {
39
+ const parsed = splitSelector(a);
40
+ return make(parsed && JSON.stringify(parsed.word), parsed?.rest !== undefined ? JSON.stringify(unquote(parsed.rest)) : null, which);
41
+ };
42
+ }
43
+ for (const which of ['localStorage', 'sessionStorage']) {
44
+ const cli = which.toLowerCase();
45
+ Object.assign(NAMES, {
46
+ // Anything after the key would be a key with spaces, unquoted: refused.
47
+ [`${cli}-get`]: [storage(which, (k, v) => k && !v && `eval ${which}.getItem(${k})`), `eval ${which}.getItem("<key>")`, `quote a key with spaces: ${cli}-get "my key"`],
48
+ [`${cli}-set`]: [storage(which, (k, v) => k && v && `eval ${which}.setItem(${k}, ${v}), "Set " + ${k}`), `eval ${which}.setItem("<key>", "<value>")`],
49
+ [`${cli}-delete`]: [storage(which, (k, v) => k && !v && `eval ${which}.removeItem(${k}), "Deleted " + ${k}`), `eval ${which}.removeItem("<key>")`, `quote a key with spaces: ${cli}-delete "my key"`],
50
+ [`${cli}-clear`]: [() => `eval ${which}.clear(), "Cleared ${which}"`, `eval ${which}.clear()`],
51
+ });
52
+ }
53
+ // Keys only, as storage lists localStorage's: values can be tokens.
54
+ NAMES['sessionstorage-list'] = [() => 'eval Object.keys(sessionStorage)', 'eval Object.keys(sessionStorage)'];
55
+ Object.assign(NAMES, {
56
+ 'tracing-start': [() => 'capture on', 'capture on, which records requests and console messages together'],
57
+ 'tracing-stop': [() => 'capture off', 'capture off, which prints what it recorded'],
58
+ 'recording-start': [() => 'watch on', 'watch on, which records the steps taken in the tab (as steps, not code)'],
59
+ // Run as its steps (translate), since stopping and showing are two commands here.
60
+ 'recording-stop': [() => 'watch off', 'watch off, then watch, which shows the steps'],
61
+ });
62
+
63
+ // Why a name has no counterpart here, and the nearest command if there is one.
64
+ const NEAREST = {
65
+ 'run-code': 'eval <JavaScript> runs in the page; cdp <method> <json> sends a CDP command',
66
+ list: 'pw-repl where says which REPL is running; one REPL serves everyone using its browser',
67
+ attach: 'pw-repl serve connects to the browser on PW_CDP_URL; pw-repl skill has the rest',
68
+ detach: 'pw-repl stop, or quit at the prompt, disconnects and leaves the browser running',
69
+ drag: 'not here yet', drop: 'not here yet', highlight: 'not here yet', 'webmcp-list': 'not here yet', 'webmcp-call': 'not here yet',
70
+ keydown: 'not here yet; press <key> presses and releases one', keyup: 'not here yet; press <key> presses and releases one',
71
+ mousewheel: 'not here yet; eval scrollBy(<dx>, <dy>) scrolls the page, without wheel events',
72
+ pdf: 'not here yet; screenshot --full saves the whole page as a PNG',
73
+ 'generate-locator': 'not here yet; a snapshot ref (e5) works as a selector in any command',
74
+ 'cookie-get': 'not here yet; cookies lists them without values, and cdp Network.getCookies {} shows the values',
75
+ 'cookie-set': 'not here yet; cdp Network.setCookie {"name": ..., "value": ..., "url": ...} sets one',
76
+ 'cookie-delete': 'not here yet; cdp Network.deleteCookies {"name": ..., "url": ...} deletes one',
77
+ show: 'not here; pw-repl attach, or the REPL\'s pane, shows everything the REPL does',
78
+ 'close-all': 'not here on purpose: it would close a browser someone else may be using; close turns off your modes',
79
+ 'kill-all': 'not here on purpose: it would kill a browser someone else may be using; pw-repl stop stops a REPL',
80
+ 'delete-data': 'not here on purpose: it would wipe the data of a browser someone else may be using',
81
+ 'cookie-clear': 'not here on purpose: it clears every site\'s cookies, in a browser someone else may be using',
82
+ 'state-save': 'not here on purpose: it would save the sign-ins of someone else sharing the browser',
83
+ 'state-load': 'not here on purpose: it would replace the sign-ins of someone else sharing the browser',
84
+ install: 'not needed here: pw-repl needs no workspace set up; pw-repl skill says how to use it',
85
+ 'install-browser': 'not needed here: --launch uses Playwright\'s Chromium or one on the PATH (PW_CHROME)',
86
+ };
87
+ for (const name of ['mousemove', 'mousedown', 'mouseup']) NEAREST[name] = 'not here yet; click, dblclick and hover act on an element';
88
+ for (const what of ['reduced-motion', 'forced-colors', 'contrast', 'media']) {
89
+ NEAREST[`set-${what}`] = 'not here yet; emulate dark|light sets the color scheme';
90
+ NEAREST[`clear-${what}`] = 'not here yet; emulate dark|light sets the color scheme';
91
+ }
92
+ for (const name of ['request-headers', 'response-headers', 'request-body']) NEAREST[name] = 'not here yet; body <#> shows the response body';
93
+ for (const name of ['video-start', 'video-stop', 'video-chapter', 'video-show-actions', 'video-hide-actions']) {
94
+ NEAREST[name] = 'not here; screenshot saves an image, and watch on records each step';
95
+ }
96
+ for (const name of ['pause-at', 'resume', 'step-over']) NEAREST[name] = 'not here: they step through a Playwright test';
97
+
98
+ const NAME_LIST = Object.keys(NAMES);
99
+
100
+ // playwright-cli's options on the commands both tools have, and on those
101
+ // rewritten here: flag (no value), value, or rest (the rest of the line up to
102
+ // the next option, for a body with spaces). One this tool does not take is
103
+ // refused, rather than read as part of a selector, a value or a file name.
104
+ const OPTIONS = {
105
+ click: { modifiers: 'value' },
106
+ dblclick: { modifiers: 'value' },
107
+ fill: { submit: 'flag' },
108
+ type: { submit: 'flag' },
109
+ eval: { filename: 'value' },
110
+ find: { regex: 'rest' },
111
+ screenshot: { filename: 'value', type: 'value', 'full-page': 'flag', hires: 'flag' },
112
+ snapshot: { filename: 'value', depth: 'value', boxes: 'flag' },
113
+ console: { clear: 'flag' },
114
+ requests: { static: 'flag', filter: 'value', clear: 'flag' },
115
+ request: { filename: 'value' },
116
+ 'response-body': { filename: 'value' },
117
+ route: { status: 'value', body: 'rest', 'content-type': 'value', header: 'value', 'remove-header': 'value' },
118
+ open: { browser: 'value', config: 'value', device: 'value', headed: 'flag', 'idle-timeout': 'value', mobile: 'flag', persistent: 'flag', profile: 'value' },
119
+ 'cookie-list': { domain: 'value', path: 'value' },
120
+ };
121
+
122
+ // Options each command takes here; the rest of OPTIONS are refused.
123
+ const TAKEN = {
124
+ route: ['status', 'body', 'content-type'],
125
+ fill: ['submit'],
126
+ screenshot: ['filename', 'full-page'],
127
+ open: ['mobile', 'device'],
128
+ click: ['modifiers'],
129
+ find: ['regex'],
130
+ requests: ['static', 'filter'],
131
+ dblclick: ['modifiers'],
132
+ type: ['submit'],
133
+ };
134
+
135
+ // What to do instead of an option that is refused, where there is something.
136
+ const INSTEAD = {
137
+ 'snapshot --depth': 'snapshot <ref> outlines one element, and snapshot --grep <text> finds lines',
138
+ 'snapshot --filename': 'the snapshot is printed; pw-repl send ... > file saves it',
139
+ 'eval --filename': 'the result is printed; pw-repl send ... > file saves it',
140
+ 'request --filename': 'the body is printed; pw-repl send ... > file saves it',
141
+ 'response-body --filename': 'the body is printed; pw-repl send ... > file saves it',
142
+ 'screenshot --hires': 'screenshots are at CSS pixel size',
143
+ 'route --header': 'route <glob> patch <json> changes a JSON response',
144
+ 'route --remove-header': 'route <glob> patch <json> changes a JSON response',
145
+ 'cookie-list --domain': 'cookies lists them all',
146
+ 'cookie-list --path': 'cookies lists them all',
147
+ };
148
+ for (const option of ['browser', 'config', 'headed', 'idle-timeout', 'persistent', 'profile']) {
149
+ INSTEAD[`open --${option}`] = 'the REPL uses the browser it connected to; pw-repl serve --launch [--headed] starts one of its own';
150
+ }
151
+
152
+ // A word, where quoted parts may hold spaces: --device="iPhone 15".
153
+ const TOKEN = /(?:"(?:[^"\\]|\\.)*"|'[^']*'|[^\s"']+|["'])+/g;
154
+
155
+ // The options of spec found in args, and the line without them.
156
+ function takeOptions(args, spec) {
157
+ const tokens = [...args.matchAll(TOKEN)].map(m => ({ text: m[0], start: m.index, end: m.index + m[0].length }));
158
+ const optionAt = i => {
159
+ const match = /^--([A-Za-z][\w-]*)(?:=([\s\S]*))?$/.exec(tokens[i]?.text || '');
160
+ return match && Object.hasOwn(spec, match[1]) ? { name: match[1], value: match[2] } : null;
161
+ };
162
+ const found = [];
163
+ const cuts = [];
164
+ for (let i = 0; i < tokens.length; i++) {
165
+ const option = optionAt(i);
166
+ if (!option) continue;
167
+ const start = tokens[i].start;
168
+ let end = tokens[i].end;
169
+ let { value } = option;
170
+ if (spec[option.name] === 'rest') {
171
+ let next = i + 1;
172
+ while (next < tokens.length && !optionAt(next)) next++;
173
+ end = next < tokens.length ? tokens[next].start : args.length;
174
+ value = args.slice(value === undefined ? tokens[i].end : start + option.name.length + 3, end).trim() || undefined;
175
+ i = next - 1;
176
+ } else if (spec[option.name] === 'value' && value === undefined && tokens[i + 1] && !tokens[i + 1].text.startsWith('--')) {
177
+ value = tokens[++i].text;
178
+ end = tokens[i].end;
179
+ }
180
+ // raw keeps a value as typed, quotes and backslashes and all, for a regexp.
181
+ found.push({ name: option.name, value: value === undefined ? undefined : unquote(value), raw: value });
182
+ cuts.push([start, end]);
183
+ }
184
+ let rest = '';
185
+ let at = 0;
186
+ for (const [start, end] of cuts) { rest += `${args.slice(at, start).trim()} `; at = end; }
187
+ rest += args.slice(at).trim();
188
+ return { found, rest: rest.trim() };
189
+ }
190
+
191
+ // { text } to run instead, { refuse } to explain, or null for any other command.
192
+ function translate(text) {
193
+ const match = /^(\S+)(?:\s+([\s\S]*))?$/.exec(text.trim());
194
+ if (!match) return null;
195
+ const [, name, rest = ''] = match;
196
+ const args = rest.trim();
197
+ if (Object.hasOwn(OPTIONS, name)) {
198
+ const { found, rest: positional } = takeOptions(args, OPTIONS[name]);
199
+ // route <glob> <how> ... is this tool's own, and its body is free text.
200
+ if (name === 'route' && (positional.match(TOKEN) || []).length > 1) return null;
201
+ const refused = found.find(o => !(TAKEN[name] || []).includes(o.name));
202
+ if (refused) {
203
+ const instead = INSTEAD[`${name} --${refused.name}`];
204
+ return { refuse: `playwright-cli's ${name} --${refused.name} is not supported here${instead ? `; ${instead}` : ''}` };
205
+ }
206
+ }
207
+ // route <pattern> [--status=<code>] [--body=<text>] [--content-type=<type>] is how
208
+ // playwright-cli fakes a response; the status is 200 unless given, and a body
209
+ // that is not JSON is text unless its type is given.
210
+ if (name === 'route' && /(?:^|\s)--(?:status|body|content-type)(?:[=\s]|$)/.test(args)) {
211
+ const { found, rest: glob } = takeOptions(args, OPTIONS.route);
212
+ // route <glob> <status> --content-type=<type> <body> is this tool's own.
213
+ if (/\s/.test(unquote(glob))) return null;
214
+ const option = key => found.find(o => o.name === key)?.value;
215
+ const status = option('status') ?? '200';
216
+ const body = option('body') ?? '';
217
+ if (!/^\d{3}$/.test(status) || !glob) return { refuse: 'Usage: route <pattern> [--status=<code>] [--body=<text>] [--content-type=<type>]' };
218
+ let json = true;
219
+ try { if (body) JSON.parse(body); } catch { json = false; }
220
+ const type = option('content-type') ?? (json ? null : 'text/plain');
221
+ const typeWord = type && /\s/.test(type) ? JSON.stringify(type) : type;
222
+ return { text: `route ${unquote(glob)} ${status}${type ? ` --content-type=${typeWord}` : ''}${body ? ` ${body}` : ''}`, as: 'route <glob> <status> [--content-type=<type>] [body]' };
223
+ }
224
+ if (name === 'recording-stop' && !args) return { steps: ['watch off', 'watch'], as: NAMES[name][1] };
225
+ // find --regex takes a regular expression, as snapshot --regex does; requests --filter
226
+ // takes one too, as requests --regex does, and --static is --all.
227
+ if (name === 'find' && /(?:^|\s)--regex(?:[=\s]|$)/.test(args)) {
228
+ // As typed: snapshot --regex takes off the quotes and leaves its backslashes.
229
+ const regex = takeOptions(args, OPTIONS.find).found[0].raw;
230
+ return regex ? { text: `snapshot --regex ${regex}`, as: 'snapshot --regex <pattern>', key: 'find --regex' } : { refuse: 'Usage: find --regex <pattern>' };
231
+ }
232
+ if (name === 'requests' && /(?:^|\s)--(?:static|filter)(?:[=\s]|$)/.test(args)) {
233
+ const { found, rest } = takeOptions(args, OPTIONS.requests);
234
+ const filter = found.find(o => o.name === 'filter');
235
+ if (filter && !filter.value) return { refuse: 'Usage: requests --filter=<regexp>' };
236
+ const all = found.some(o => o.name === 'static');
237
+ // As typed: requests --regex takes off the quotes and leaves its backslashes.
238
+ const text = ['requests', all && '--all', rest, filter && `--regex ${filter.raw}`].filter(Boolean).join(' ');
239
+ return filter ? { text, as: 'requests --regex <pattern>', key: 'requests --filter' } : { text, as: 'requests --all', key: 'requests --static' };
240
+ }
241
+ // open [url] [--mobile | --device=<name>]: the emulation is set before the page loads.
242
+ if (name === 'open') {
243
+ const { found, rest: url } = takeOptions(args, OPTIONS.open);
244
+ const device = found.find(o => o.name === 'device');
245
+ if (!device && !found.length) return { text: `tab new ${url}`.trim(), as: 'tab new [url]' };
246
+ if (device && !device.value) return { refuse: 'Usage: open [url] [--mobile | --device=<name>]' };
247
+ return {
248
+ steps: ['tab new', `emulate mobile${device ? ` ${device.value}` : ''}`, ...(url ? [`goto ${url}`] : [])],
249
+ as: 'tab new, emulate mobile [device], then goto <url>',
250
+ failed: 'The new tab stays open and selected; tab close closes it.',
251
+ };
252
+ }
253
+ if (Object.hasOwn(NAMES, name)) {
254
+ const [make, as, hint] = NAMES[name];
255
+ const made = make(args);
256
+ return made ? { text: made, as } : { refuse: `${name} ${args}`.trim() + ` is not understood here; the command for it is ${as}${args && hint ? `; ${hint}` : ''}` };
257
+ }
258
+ if (Object.hasOwn(NEAREST, name)) return { refuse: `${name} is playwright-cli's${/^not /.test(NEAREST[name]) ? ` and ${NEAREST[name]}` : `; here, ${NEAREST[name]}`}` };
259
+ return null;
260
+ }
261
+
262
+ // Words filled into lines of up to width, each after the first indented.
263
+ function wrap(text, width = 104, indent = ' ') {
264
+ const lines = [];
265
+ let line = '';
266
+ for (const word of text.split(' ')) {
267
+ if (line && `${line} ${word}`.length > width) { lines.push(line); line = `${indent}${word}`; } else line = line ? `${line} ${word}` : word;
268
+ }
269
+ return [...lines, line].join('\n');
270
+ }
271
+
272
+ // For help playwright-cli: names with the same reason are listed together.
273
+ function table() {
274
+ const width = Math.max(...NAME_LIST.map(n => n.length));
275
+ const lines = NAME_LIST.map(n => ` ${n.padEnd(width)} ${NAMES[n][1]}`);
276
+ const byReason = new Map();
277
+ for (const [name, why] of Object.entries(NEAREST)) byReason.set(why, [...(byReason.get(why) || []), name]);
278
+ const nearest = [...byReason].map(([why, names]) => ` ${wrap(`${names.join(', ')}: ${why}`)}`);
279
+ return [...lines, '', 'Not here as such:', ...nearest].join('\n');
280
+ }
281
+
282
+ module.exports = { translate, table, takeOptions, NAME_LIST };
package/lib/client.js CHANGED
@@ -56,9 +56,9 @@ async function health(endpoint, timeoutMs) {
56
56
  // One command. Resolves to { result }, { timeout: true }, { dropped: reason }
57
57
  // (the connection closed after the command was sent, so it may have run) or
58
58
  // { unreachable: reason } (it was never sent).
59
- function request(endpoint, command, timeoutMs) {
59
+ function request(endpoint, command, timeoutMs, client = null) {
60
60
  return new Promise(resolve => {
61
- const body = JSON.stringify({ command });
61
+ const body = JSON.stringify(client ? { command, client } : { command });
62
62
  let connected = false;
63
63
  const req = http.request({
64
64
  ...target(endpoint),