pw-repl 0.1.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 ADDED
@@ -0,0 +1,210 @@
1
+ // Three levels: `help` (what this is + topics), `help <topic>` (one line per
2
+ // command), `help <command>` (usage and caveats). Keep each level short enough
3
+ // that a reader only goes deeper when they need to. Help describes what the
4
+ // tool does; instructions for agents belong in skill/SKILL.md.
5
+
6
+ const OVERVIEW = `playwright-repl drives Chromium over CDP. Commands act on the selected tab (\`tab\` shows which).
7
+ The browser may be a shared session, with other people's tabs open in it.
8
+
9
+ Common tasks:
10
+ where am I? tab, info
11
+ what is on the page? snapshot, screenshot
12
+ do something on it click, fill, press
13
+ what did the page request? requests, then body <#> for what one got back
14
+ console messages and errors console
15
+ show an agent what I do watch on, click around, then watch
16
+ see each step as I click watch on --live
17
+ requests and console together capture on, then capture off
18
+ break the backend on purpose route <glob> <status> <json>, network off
19
+ clean up modes off
20
+
21
+ Modes (watch, capture, route, network off) stay on until turned off; the prompt shows the selected tab's:
22
+ (watch routes:1) pw>. tab, watch, capture, route, network and modes on their own show their state and
23
+ what you can run next.
24
+
25
+ Topics (help <topic>):
26
+ tabs open, select, close, navigate network requests, bodies, console, fakes, network off
27
+ interact click, fill, type, press, select devtools eval, CDP commands, cookie and storage names
28
+ inspect snapshot, watch, text, screenshots session modes, markers, server, quitting
29
+
30
+ help <command> for usage and caveats (e.g. help route); help --all for everything.`;
31
+
32
+ const TOPICS = {
33
+ tabs: {
34
+ intro: 'The REPL selects tab [0] when it starts; tab on its own lists them all. After tab close, see help tab.',
35
+ commands: ['tab', 'goto', 'back', 'forward', 'reload', 'info'],
36
+ },
37
+ interact: {
38
+ intro: 'Selectors are Playwright selectors (CSS, text=..., role=...). Commands use the first match.',
39
+ commands: ['click', 'dblclick', 'hover', 'fill', 'type', 'press', 'select', 'check', 'uncheck'],
40
+ },
41
+ inspect: {
42
+ intro: 'Output is capped; put --all right after the command for everything (e.g. text --all body).',
43
+ commands: ['snapshot', 'watch', 'text', 'html', 'attrs', 'count', 'visible', 'links', 'inputs', 'screenshot', 'viewport', 'wait', 'sleep'],
44
+ },
45
+ network: {
46
+ intro: 'requests and console record all the time; a capture records only while it runs.',
47
+ commands: ['requests', 'body', 'console', 'capture', 'route', 'network'],
48
+ },
49
+ devtools: {
50
+ intro: 'Cookie and storage listings omit values. eval, html, screenshots and URLs can still show sensitive data.',
51
+ commands: ['eval', 'cdp', 'cookies', 'storage'],
52
+ },
53
+ session: {
54
+ intro: `Completion: @<id> <command> makes the REPL print [[pw-done:<id>:ok|error]] when it finishes.
55
+
56
+ Unknown outcome: a timed-out command may still be running, so the REPL disconnects instead of continuing.
57
+
58
+ Server: pw-repl serve also takes commands on /tmp/playwright-repl.sock; they show here as [server] lines.
59
+
60
+ The prompt is pw[serve]> while the server is on and pw> without it. Before it are the modes on in the
61
+ selected tab, if any: (watch network:off routes:2 capture) pw>. modes lists them for every tab.
62
+
63
+ Times (requests, console, watch) are local, with their UTC offset: 18:16:15.721-06:00.
64
+
65
+ Dialogs are reported and never answered automatically.`,
66
+ commands: ['modes', 'help', 'quit'],
67
+ },
68
+ };
69
+
70
+ const COMMANDS = {
71
+ tab: {
72
+ usage: 'tab [<index>|<url-part>|new [url]|close [url-part]]',
73
+ summary: 'list tabs (* is selected); select, open or close one',
74
+ 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 and selects a tab. 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.',
75
+ },
76
+ goto: { usage: 'goto <url>', summary: 'navigate the selected tab; https:// is assumed' },
77
+ back: { usage: 'back', summary: 'go back one history entry' },
78
+ forward: { usage: 'forward', summary: 'go forward one history entry' },
79
+ reload: { usage: 'reload', summary: 'reload the selected tab' },
80
+ info: { usage: 'info', summary: 'selected tab URL, title and viewport' },
81
+ click: { usage: 'click <selector>', summary: 'click the first match' },
82
+ dblclick: { usage: 'dblclick <selector>', summary: 'double-click the first match' },
83
+ hover: { usage: 'hover <selector>', summary: 'hover the first match' },
84
+ fill: { usage: 'fill <selector> => <value>', summary: 'clear an input and fill it' },
85
+ type: { usage: 'type <selector> => <text>', summary: 'type into an input key by key' },
86
+ press: { usage: 'press <key> | press <selector> => <key>', summary: 'press a key, optionally on an element' },
87
+ select: { usage: 'select <selector> => <value>', summary: 'choose an option in a select' },
88
+ check: { usage: 'check <selector>', summary: 'check a checkbox' },
89
+ uncheck: { usage: 'uncheck <selector>', summary: 'uncheck a checkbox' },
90
+ snapshot: {
91
+ usage: 'snapshot [--full] [--grep <text> | <eN> | selector]',
92
+ summary: 'outline by role and name, with [ref=eN] labels',
93
+ 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 label (e3, or f1e3 in newer Playwright) works as a selector:\nclick aria-ref=e3. Labels can change when the page navigates (e102 may become f4e98), so take a new\nsnapshot after one.\n\nOutput over 60 lines ends with a line count.',
94
+ },
95
+ watch: {
96
+ usage: 'watch on [--changes] [--live] | off | [n|new]',
97
+ summary: 'record what happens in the tab; show the last n steps',
98
+ detail: 'Off until watch on. Records clicks, typing (once it pauses), Enter and Escape, form changes, submits\nand navigations, each described by role and name like snapshot, with up to 5 of the requests it\ncaused underneath (their numbers in requests; body <#> for one), leaving out scripts and what\nrequests 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.',
99
+ },
100
+ text: { usage: 'text [--all] <selector>', summary: 'visible text of the first match' },
101
+ html: { usage: 'html [--all] <selector>', summary: 'outer HTML of the first match' },
102
+ attrs: { usage: 'attrs [--all] <selector>', summary: 'attributes of the first match' },
103
+ count: { usage: 'count <selector>', summary: 'number of matches' },
104
+ visible: { usage: 'visible <selector>', summary: 'whether the first match is visible' },
105
+ links: { usage: 'links [--all]', summary: 'links on the page (text and href)' },
106
+ inputs: { usage: 'inputs [--all]', summary: 'form controls on the page; values are omitted' },
107
+ screenshot: {
108
+ usage: 'screenshot [--full] [--delay|-d <seconds>] [name]',
109
+ summary: 'save a PNG of the viewport (or --full page)',
110
+ detail: 'Saved as screenshot-<name or timestamp>.png in $PW_SCREENSHOT_DIR (default /tmp).\n\n--delay counts down out loud first (maximum 60s), so someone can hold a hover or open a menu.',
111
+ },
112
+ viewport: { usage: 'viewport [WxH]', summary: 'show or set the viewport size' },
113
+ wait: {
114
+ usage: 'wait [text|request] <what> [secs]',
115
+ summary: 'wait for an element, text, or a response (default 10s)',
116
+ 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\nUp to 120s. A wait that times out is an error; the REPL carries on.',
117
+ },
118
+ sleep: { usage: 'sleep <ms>', summary: 'wait a fixed time (maximum 3600000)' },
119
+ requests: {
120
+ usage: 'requests [--all] [n] [url-filter]',
121
+ summary: 'the selected tab\'s last n requests (default 20)',
122
+ 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.',
123
+ },
124
+ body: {
125
+ usage: 'body [--all] <#>',
126
+ summary: 'the response body of request <#> from requests',
127
+ 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.',
128
+ },
129
+ console: {
130
+ usage: 'console [--all] [n] [filter]',
131
+ summary: 'the last n console messages and page errors',
132
+ 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.',
133
+ },
134
+ capture: {
135
+ usage: 'capture on [requests|console] [secs] | off',
136
+ summary: 'record requests and console together, in time order',
137
+ detail: 'capture on records both until capture off, which prints them; requests or console records only one.\nWith secs (1-3600 seconds) it records that long, then prints; the REPL waits meanwhile.\n\nOne capture runs at a time, on the tab selected when it started. Unlike requests and console it\nkeeps more than the last 200 and lists both together.\n\ncapture on its own says whether one is running, or shows the last one.',
138
+ },
139
+ route: {
140
+ usage: 'route <glob> <status> <json> | off <glob>|--all',
141
+ summary: 'answer the selected tab\'s matching requests with fake JSON',
142
+ detail: 'The fake is fulfilled inside the browser, so the page handles it as a real response and the request\nnever reaches the network. Each one prints "Faked: #<n> <METHOD> <url> -> <status>", with #<n> as in\nrequests. If fulfilling fails it prints "Fake failed" and aborts the request, so it never reaches\nthe network.\n\nStatus: 200-599. wait request <glob> after the page loads prints the request with its status, so a\nfake shows as <status> faked.\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/health_check 503 {"detail":{"code":"service_unavailable"}}',
143
+ },
144
+ network: {
145
+ usage: 'network [on|off]',
146
+ summary: 'cut or restore the tab\'s network, like dropped wifi',
147
+ detail: 'Stopping a service is not the same: a dev proxy in front of it usually holds the request open, so\nthe page spins instead of failing.\n\nPer tab; lasts until network on or the REPL exits.',
148
+ },
149
+ eval: {
150
+ usage: 'eval [--all] <JavaScript>',
151
+ summary: 'evaluate JavaScript in the selected tab and print the result',
152
+ 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).',
153
+ },
154
+ cdp: {
155
+ usage: 'cdp [--all] <method> <JSON object>',
156
+ summary: 'send one CDP command through a temporary session',
157
+ detail: 'The session is detached afterwards, so subscriptions and settings do not persist.\n\nBrowser.close and Target.closeTarget are refused.',
158
+ },
159
+ cookies: { usage: 'cookies [--all]', summary: 'cookie names, domains and flags; values are omitted' },
160
+ storage: { usage: 'storage [--all]', summary: 'localStorage keys; values are omitted' },
161
+ modes: {
162
+ usage: 'modes [off]',
163
+ summary: 'the modes on in every tab; modes off turns them all off',
164
+ detail: 'The modes are watch, network off, route and capture. Each is turned on and off with its own command:\nwatch on|off, network off|on, route <glob> ... | route off, capture on|off.\n\nmodes off turns off every one in every tab; a capture it stops is kept for capture to show.',
165
+ },
166
+ help: { usage: 'help [topic | command | --all]', summary: 'this help; --all prints every topic and command in full' },
167
+ quit: {
168
+ usage: 'quit',
169
+ summary: 'disconnect, leaving Chromium running',
170
+ detail: 'Runs immediately, even while another command is waiting. Only available at the prompt.',
171
+ },
172
+ };
173
+
174
+ function commandLines(names) {
175
+ const width = Math.max(...names.map(n => COMMANDS[n].usage.length));
176
+ return names.map(n => ` ${COMMANDS[n].usage.padEnd(width)} ${COMMANDS[n].summary}`);
177
+ }
178
+
179
+ function renderCommand(name) {
180
+ const entry = COMMANDS[name];
181
+ return [`${entry.usage} — ${entry.summary}`, ...(entry.detail ? ['', entry.detail] : [])].join('\n');
182
+ }
183
+
184
+ // Every topic's command list, with each command's details indented under it.
185
+ function renderAll() {
186
+ const sections = Object.entries(TOPICS).map(([name, topic]) => {
187
+ const lines = commandLines(topic.commands).flatMap((line, i) => {
188
+ const detail = COMMANDS[topic.commands[i]].detail;
189
+ return detail ? [line, ...detail.split('\n').map(d => (d ? ` ${d}` : '')), ''] : [line];
190
+ });
191
+ if (lines[lines.length - 1] === '') lines.pop();
192
+ return [`== ${name} ==`, topic.intro, '', ...lines].join('\n');
193
+ });
194
+ return [OVERVIEW, ...sections].join('\n\n');
195
+ }
196
+
197
+ function render(topicOrCommand) {
198
+ if (!topicOrCommand) return OVERVIEW;
199
+ if (topicOrCommand === '--all') return renderAll();
200
+ if (Object.hasOwn(TOPICS, topicOrCommand)) {
201
+ const topic = TOPICS[topicOrCommand];
202
+ const text = [topic.intro, '', ...commandLines(topic.commands)].join('\n');
203
+ // A topic can share its name with a command (network); both are shown.
204
+ return Object.hasOwn(COMMANDS, topicOrCommand) ? `${text}\n\n${renderCommand(topicOrCommand)}` : text;
205
+ }
206
+ if (Object.hasOwn(COMMANDS, topicOrCommand)) return renderCommand(topicOrCommand);
207
+ return null;
208
+ }
209
+
210
+ module.exports = { render, TOPICS, COMMANDS };
package/lib/output.js ADDED
@@ -0,0 +1,70 @@
1
+ // All command output goes through here rather than straight to console, so a
2
+ // command run through the server can have its output collected and returned.
3
+ const OUTPUT_LIMIT = 12000;
4
+
5
+ let sink = null;
6
+
7
+ function log(text) {
8
+ console.log(text);
9
+ if (sink) sink.push(String(text));
10
+ }
11
+
12
+ function error(text) {
13
+ console.error(text);
14
+ if (sink) sink.push(String(text));
15
+ }
16
+
17
+ // Commands run one at a time, so a single sink is enough. Lines printed by
18
+ // background events while it is open (e.g. a Faked line) are collected too.
19
+ async function collect(fn) {
20
+ const lines = sink = [];
21
+ try {
22
+ await fn();
23
+ return lines.join('\n');
24
+ } finally {
25
+ if (sink === lines) sink = null;
26
+ }
27
+ }
28
+
29
+ // Ends the open collection early and returns what it has so far, so a quit
30
+ // can answer the command it interrupts without its own output mixed in.
31
+ function take() {
32
+ const lines = sink || [];
33
+ sink = null;
34
+ return lines.join('\n');
35
+ }
36
+
37
+ // Lines the browser causes on its own (a faked request, a dialog) can arrive
38
+ // while the prompt is showing. The entry point registers how to print above
39
+ // the prompt and redraw it, so the pane still ends on a bare prompt.
40
+ let idlePrinter = null;
41
+
42
+ function onIdlePrint(printer) {
43
+ idlePrinter = printer;
44
+ }
45
+
46
+ function notice(text) {
47
+ if (!sink && idlePrinter && idlePrinter.idle()) idlePrinter.print(String(text));
48
+ else log(text);
49
+ }
50
+
51
+ // Like notice, but never part of a command's answer: for what the person at
52
+ // the browser does, which is not the output of the command running now.
53
+ function aside(text) {
54
+ if (!sink && idlePrinter && idlePrinter.idle()) idlePrinter.print(String(text));
55
+ else console.log(text);
56
+ }
57
+
58
+ function printOutput(value, all = false) {
59
+ let text;
60
+ if (typeof value === 'string') text = value;
61
+ else text = JSON.stringify(value, null, 2);
62
+ if (text === undefined) text = 'undefined';
63
+ if (!all && text.length > OUTPUT_LIMIT) {
64
+ log(`${text.slice(0, OUTPUT_LIMIT)}\n[truncated; use --all after the command to show all]`);
65
+ } else {
66
+ log(text);
67
+ }
68
+ }
69
+
70
+ module.exports = { OUTPUT_LIMIT, log, error, collect, take, notice, aside, onIdlePrint, printOutput };
package/lib/runner.js ADDED
@@ -0,0 +1,163 @@
1
+ // Runs commands one at a time, whether they come from the prompt or the server.
2
+ // A prompt command tagged @<id> reports completion with a marker the caller can wait for.
3
+ const readline = require('readline');
4
+ const { state, shutdown, beforeExit } = require('./state');
5
+ const out = require('./output');
6
+ const { commands, activeModes } = require('./commands');
7
+
8
+ // A timeout here cannot have changed anything, so it is reported as an error
9
+ // and the REPL carries on. Any other command that times out may still be
10
+ // acting on the page, so the REPL disconnects rather than continue.
11
+ const READ_ONLY = new Set(['info', 'text', 'html', 'attrs', 'count', 'visible', 'links', 'inputs',
12
+ 'snapshot', 'screenshot', 'wait', 'sleep', 'requests', 'body', 'console', 'cookies', 'storage', 'capture', 'help']);
13
+
14
+ // Commands that work with no tab selected.
15
+ const NO_TAB_NEEDED = new Set(['tab', 'modes', 'capture', 'help', 'quit']);
16
+
17
+ // Commands that accept a leading --all to lift the output limit.
18
+ const INSPECTION = ['info', 'text', 'html', 'attrs', 'links', 'inputs', 'eval', 'cdp', 'cookies', 'storage', 'capture', 'body', 'console', 'snapshot', 'watch'];
19
+
20
+ let queue = Promise.resolve();
21
+ // The server command running now, so a quit at the prompt can answer it.
22
+ let inFlight = null;
23
+
24
+ function parseInput(line) {
25
+ const trimmed = line.trim();
26
+ const match = /^@([A-Za-z0-9][A-Za-z0-9._-]*)\s+([\s\S]+)$/.exec(trimmed);
27
+ return { text: match ? match[2] : trimmed, token: match ? match[1] : null };
28
+ }
29
+
30
+ function isUncertain(error) {
31
+ return /timed out|timeout|connection (?:lost|closed)/i.test(error.message || '');
32
+ }
33
+
34
+ let running = 0;
35
+
36
+ // True while a command is running: its own output ends with a fresh prompt.
37
+ function busy() {
38
+ return running > 0;
39
+ }
40
+
41
+ async function execute(text) {
42
+ running += 1;
43
+ try {
44
+ return await executeCommand(text);
45
+ } finally {
46
+ running -= 1;
47
+ }
48
+ }
49
+
50
+ async function executeCommand(text) {
51
+ if (state.connectionLost) return { status: 'error', cmd: '' };
52
+ if (!text) return { status: 'ok', cmd: '' };
53
+ const spaceIdx = text.indexOf(' ');
54
+ const cmd = spaceIdx === -1 ? text : text.slice(0, spaceIdx);
55
+ let args = spaceIdx === -1 ? '' : text.slice(spaceIdx + 1);
56
+ const all = INSPECTION.includes(cmd) && /^--all(?:\s|$)/.test(args);
57
+ if (all) args = args.slice(5).trimStart();
58
+ // wait request counts responses since the previous command began.
59
+ state.previousCommandAt = state.currentCommandAt;
60
+ state.currentCommandAt = Date.now();
61
+ if (!Object.hasOwn(commands, cmd)) {
62
+ out.log(`Unknown command: ${cmd}. Type 'help' for commands.`);
63
+ return { status: 'error', cmd };
64
+ }
65
+ if (!NO_TAB_NEEDED.has(cmd) && (!state.page || state.page.isClosed())) {
66
+ out.error('Error: No tab is selected; select one with tab <index|url-part>, or open one with tab new');
67
+ return { status: 'error', cmd };
68
+ }
69
+ try {
70
+ await commands[cmd](args, all);
71
+ } catch (e) {
72
+ out.error(`Error: ${e.message}`);
73
+ return { status: 'error', cmd, uncertain: !READ_ONLY.has(cmd) && isUncertain(e) };
74
+ }
75
+ if (cmd === 'quit' && state.shutdownFailed) return { status: 'error', cmd };
76
+ return { status: 'ok', cmd };
77
+ }
78
+
79
+ // A timed-out browser command may still be running, so nothing after it can
80
+ // be trusted to act on the page the caller thinks it is acting on.
81
+ function giveUp() {
82
+ out.error('Browser command outcome is unknown; disconnecting instead of continuing.');
83
+ void shutdown().then(beforeExit).then(() => process.exit(process.exitCode || 1));
84
+ }
85
+
86
+ // A quit does not wait for the running server command, which may never finish
87
+ // once the browser is gone, so its sender is answered now. A read-only command
88
+ // changed nothing; any other may or may not have done what it was sent to do.
89
+ function answerInterrupted() {
90
+ if (!inFlight) return;
91
+ const flight = inFlight;
92
+ inFlight = null;
93
+ flight.answered = true;
94
+ const cmd = flight.text.split(' ')[0];
95
+ const known = READ_ONLY.has(cmd);
96
+ const note = known ? 'The REPL quit before this command finished.' : 'The REPL quit before this command finished; its outcome is unknown.';
97
+ const output = out.take();
98
+ flight.resolve({ status: 'error', output: output ? `${output}\n${note}` : note, uncertain: !known, interrupted: true });
99
+ }
100
+
101
+ async function handleLine(line) {
102
+ const input = parseInput(line);
103
+ const done = status => { if (input.token) out.log(`[[pw-done:${input.token}:${status}]]`); };
104
+ if (state.stopping) return done('error');
105
+ if (/^quit(?:\s|$)/.test(input.text)) answerInterrupted();
106
+ const result = await execute(input.text);
107
+ done(result.status);
108
+ if (result.cmd === 'quit') { await beforeExit(); process.exit(process.exitCode || 0); }
109
+ if (result.uncertain) return giveUp();
110
+ if (!state.stopping) prompt();
111
+ }
112
+
113
+ // The prompt starts with the modes on in the selected tab: (watch network:off) pw>.
114
+ function prompt(preserveCursor) {
115
+ const modes = state.page && !state.page.isClosed() ? activeModes(state.page) : [];
116
+ state.rl.setPrompt(`${modes.length ? `(${modes.join(' ')}) ` : ''}${state.promptBase}`);
117
+ state.rl.prompt(preserveCursor);
118
+ }
119
+
120
+ // quit skips the queue so it still works while a long command is running.
121
+ function enqueue(line) {
122
+ const direct = /^(?:@[A-Za-z0-9][A-Za-z0-9._-]*\s+)?quit(?:\s|$)/.test(line.trim());
123
+ const run = () => handleLine(line);
124
+ if (direct) run().catch(e => out.error(`Error: ${e.message}`));
125
+ else queue = queue.then(run).catch(e => out.error(`Error: ${e.message}`));
126
+ }
127
+
128
+ // Server commands share the prompt's queue and are echoed to the pane, so
129
+ // someone watching sees everything an agent does.
130
+ function submit(text) {
131
+ return new Promise(resolve => {
132
+ queue = queue.then(async () => {
133
+ if (state.stopping) return resolve({ status: 'error', output: 'The REPL is shutting down.' });
134
+ // Printed above whatever the person at the prompt is typing, which is
135
+ // redrawn afterwards rather than broken up.
136
+ if (process.stdout.isTTY) { readline.clearLine(process.stdout, 0); readline.cursorTo(process.stdout, 0); }
137
+ out.log(`[server] ${text}`);
138
+ // So the person at the prompt can bring an agent's command back with up-arrow.
139
+ if (state.rl.history && state.rl.history[0] !== text) {
140
+ state.rl.history.unshift(text);
141
+ state.rl.history.length = Math.min(state.rl.history.length, 1000);
142
+ }
143
+ if (/^quit(?:\s|$)/.test(text)) {
144
+ const output = await out.collect(async () => out.error('Error: quit is only available at the prompt'));
145
+ resolve({ status: 'error', output });
146
+ } else {
147
+ let result;
148
+ const flight = { text, resolve, answered: false };
149
+ inFlight = flight;
150
+ const output = await out.collect(async () => { result = await execute(text); });
151
+ if (inFlight === flight) inFlight = null;
152
+ if (!flight.answered) resolve({ status: result.status, output, uncertain: result.uncertain });
153
+ }
154
+ if (!state.stopping) prompt(true);
155
+ }).catch(e => { out.error(`Error: ${e.message}`); resolve({ status: 'error', output: e.message }); });
156
+ });
157
+ }
158
+
159
+ function drained() {
160
+ return queue;
161
+ }
162
+
163
+ module.exports = { enqueue, submit, giveUp, answerInterrupted, drained, busy };
package/lib/send.js ADDED
@@ -0,0 +1,127 @@
1
+ // pw-repl send / where: one command to a running REPL, through its server
2
+ // when there is one and through its tmux pane otherwise.
3
+ //
4
+ // Exit status: 0 ok, 1 command error, 2 completion not confirmed, 64 usage or unreachable.
5
+ const fs = require('fs');
6
+ const crypto = require('crypto');
7
+ const { execFileSync } = require('child_process');
8
+ const client = require('./client');
9
+
10
+ // The prompt, after the modes on in the selected tab if any: (watch network:off) pw[serve]>
11
+ const PROMPT = /^(?:\([^()]*\) )?pw(\[serve\])?>\s*$/;
12
+
13
+ function tmux(...args) {
14
+ return execFileSync('tmux', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
15
+ }
16
+
17
+ function hasSession(session) {
18
+ try { tmux('has-session', '-t', session); return true; } catch { return false; }
19
+ }
20
+
21
+ // Which way a command goes: an explicit -e or -s wins; otherwise the server
22
+ // when its socket exists, else tmux.
23
+ function route(options) {
24
+ const socket = process.env.PW_SOCKET || client.DEFAULT_SOCKET;
25
+ const endpoint = options.endpoint || process.env.PW_ENDPOINT || '';
26
+ const session = options.session || process.env.PW_TMUX_SESSION || 'playwright-repl';
27
+ if (options.session) return { kind: 'tmux', session };
28
+ if (endpoint) return { kind: 'server', endpoint: client.parseEndpoint(endpoint), explicit: true };
29
+ if (fs.existsSync(socket) && fs.statSync(socket).isSocket()) return { kind: 'server', endpoint: client.parseEndpoint(socket), explicit: false, socket };
30
+ return { kind: 'tmux', session };
31
+ }
32
+
33
+ function fail(message) {
34
+ console.error(`pw-repl: ${message}`);
35
+ return 64;
36
+ }
37
+
38
+ // Checks a pane is running the REPL and sitting at a bare prompt. A REPL that
39
+ // is exiting is still node for a moment but never shows a fresh prompt again,
40
+ // and its last messages print on the prompt line ("pw> Browser command outcome
41
+ // is unknown; ..."), so only a bare prompt counts. That also refuses while a
42
+ // command runs or someone is typing.
43
+ function paneProblem(session) {
44
+ if (!hasSession(session)) return `no tmux session '${session}'; ask the user before creating one (pw-repl skill: Start it)`;
45
+ const running = tmux('display-message', '-p', '-t', session, '#{pane_current_command}').trim();
46
+ if (running !== 'node') return `the REPL is not running in '${session}' (pane is running '${running}'); start it there with pw-repl run (pw-repl skill: Start it)`;
47
+ const lines = tmux('capture-pane', '-t', session, '-p').split('\n').filter(line => line.trim());
48
+ if (!PROMPT.test(lines[lines.length - 1] || '')) {
49
+ return `the REPL in '${session}' is not at a bare pw> or pw[serve]> prompt (busy, being typed at, or exiting); retry once it is, or check with pw-repl where`;
50
+ }
51
+ return null;
52
+ }
53
+
54
+ async function viaTmux(session, command, timeoutMs) {
55
+ const problem = paneProblem(session);
56
+ if (problem) return fail(problem);
57
+ // Random, so a marker left in the scrollback by an earlier command never matches.
58
+ const id = crypto.randomBytes(4).toString('hex');
59
+ tmux('send-keys', '-t', session, '-l', `@${id} ${command}`);
60
+ tmux('send-keys', '-t', session, 'Enter');
61
+ // -J joins lines tmux wrapped at the pane width, so long URLs stay whole.
62
+ const capture = () => tmux('capture-pane', '-t', session, '-p', '-J', '-S', '-10000');
63
+ const marker = new RegExp(`\\[\\[pw-done:${id}:(ok|error)\\]\\]`);
64
+ const deadline = Date.now() + timeoutMs;
65
+ let status = null;
66
+ let screen = '';
67
+ while (Date.now() < deadline) {
68
+ await new Promise(resolve => setTimeout(resolve, 500));
69
+ screen = capture();
70
+ const match = marker.exec(screen);
71
+ if (match) { status = match[1]; break; }
72
+ }
73
+ // Everything after the echoed input line; up to the marker when there is one.
74
+ const lines = screen.split('\n');
75
+ const start = lines.findIndex(line => line.includes(`@${id} `));
76
+ const output = [];
77
+ for (const line of start === -1 ? [] : lines.slice(start + 1)) {
78
+ if (line.includes(`[[pw-done:${id}:`)) break;
79
+ output.push(line);
80
+ }
81
+ if (output.length) console.log(output.join('\n'));
82
+ if (status === 'ok') return 0;
83
+ if (status === 'error') return 1;
84
+ console.error('completion not confirmed');
85
+ return 2;
86
+ }
87
+
88
+ async function send(options) {
89
+ const command = options.command;
90
+ if (!command) return fail('no command given');
91
+ // A newline would reach the REPL as a second, unmarked command.
92
+ if (/[\r\n]/.test(command)) return fail('the command must be a single line');
93
+ const timeoutMs = options.timeout * 1000;
94
+ const way = route(options);
95
+ if (way.kind === 'tmux') return viaTmux(way.session, command, timeoutMs);
96
+ const answer = await client.request(way.endpoint, command, timeoutMs);
97
+ if (answer.result) {
98
+ if (answer.result.output) console.log(answer.result.output);
99
+ if (answer.result.unconfirmed) { console.error('completion not confirmed'); return 2; }
100
+ return answer.result.status === 'ok' ? 0 : 1;
101
+ }
102
+ if (answer.timeout) { console.error('completion not confirmed'); return 2; }
103
+ if (answer.dropped) { console.error(`completion not confirmed: the REPL closed the connection (${answer.dropped})`); return 2; }
104
+ if (way.explicit) return fail(`cannot reach the REPL server at ${client.describe(way.endpoint)} (${answer.unreachable})`);
105
+ // The server was expected, so the REPL has most likely just exited or is
106
+ // exiting. Falling back to tmux now could type into the shell it leaves behind.
107
+ return fail(`the REPL server at ${way.socket} is not answering (${answer.unreachable}); not falling back to tmux. Check with pw-repl where; if the REPL now runs without the server, use -s or remove ${way.socket}`);
108
+ }
109
+
110
+ // Reports the route a command would take, checking it the way a command would.
111
+ async function where(options) {
112
+ const way = route(options);
113
+ if (way.kind === 'server') {
114
+ const name = client.describe(way.endpoint);
115
+ if (await client.health(way.endpoint, 5000)) { console.log(`server: ${name} (the REPL was started with pw-repl serve)`); return 0; }
116
+ console.error(`server: ${name} is not answering`);
117
+ if (way.explicit) return 64;
118
+ }
119
+ const session = options.session || process.env.PW_TMUX_SESSION || 'playwright-repl';
120
+ if (!hasSession(session)) { console.error(`tmux: no session '${session}'`); return 64; }
121
+ const running = tmux('display-message', '-p', '-t', session, '#{pane_current_command}').trim();
122
+ if (running === 'node') { console.log(`tmux: session '${session}' (the REPL was started with pw-repl run; no server)`); return 0; }
123
+ console.error(`tmux: session '${session}' is running '${running}', not the REPL`);
124
+ return 64;
125
+ }
126
+
127
+ module.exports = { send, where };