pw-repl 0.12.7 → 0.13.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 +13 -7
- package/README.md +76 -74
- package/bin/pw-repl.js +65 -40
- package/lib/background.js +2 -2
- package/lib/capture.js +11 -12
- package/lib/cli-names.js +4 -4
- package/lib/commands.js +3 -0
- package/lib/elements.js +115 -26
- package/lib/emulation.js +2 -1
- package/lib/help.js +166 -73
- package/lib/inspect.js +103 -41
- package/lib/launch.js +3 -1
- package/lib/output.js +10 -3
- package/lib/profile.js +16 -14
- package/lib/record.js +18 -14
- package/lib/routes.js +8 -9
- package/lib/runner.js +39 -9
- package/lib/send.js +28 -6
- package/lib/server.js +4 -1
- package/lib/start.js +7 -2
- package/lib/state.js +26 -9
- package/lib/syntax.js +11 -5
- package/lib/toast.js +1 -6
- package/lib/util.js +25 -1
- package/lib/watch.js +4 -2
- package/lib/written.js +67 -0
- package/package.json +1 -1
- package/skill/SKILL.md +39 -44
package/lib/runner.js
CHANGED
|
@@ -29,14 +29,16 @@ const DIALOG = /^dialog(?:-accept|-dismiss)?(?:\s|$)/;
|
|
|
29
29
|
// A timeout here cannot have changed anything, so it is an ordinary error. Any
|
|
30
30
|
// other command that times out may or may not have done what it was sent to
|
|
31
31
|
// do; that is reported, and the REPL carries on.
|
|
32
|
-
const READ_ONLY = new Set(['info', 'listeners', 'text', 'html', 'attrs', 'count', 'visible', 'links', 'inputs',
|
|
32
|
+
const READ_ONLY = new Set(['info', 'clipboard', 'listeners', 'text', 'html', 'attrs', 'count', 'visible', 'links', 'inputs',
|
|
33
33
|
'snapshot', 'screenshot', 'wait', 'sleep', 'requests', 'body', 'console', 'cookies', 'storage', 'capture', 'help']);
|
|
34
34
|
|
|
35
35
|
// Commands that work with no tab selected.
|
|
36
36
|
const NO_TAB_NEEDED = new Set(['tab', 'modes', 'capture', 'profile', 'record', 'dialog', 'help', 'quit', 'tab-select', 'close']);
|
|
37
37
|
|
|
38
38
|
// Commands that accept --all, right after the command or at the end, to lift the output limit.
|
|
39
|
-
const INSPECTION = ['info', 'listeners', 'text', 'html', 'attrs', 'links', 'inputs', 'eval', 'cdp', 'cookies', 'storage', 'capture', 'profile', 'body', 'console', 'snapshot', 'watch'];
|
|
39
|
+
const INSPECTION = ['info', 'listeners', 'text', 'html', 'attrs', 'links', 'inputs', 'eval', 'cdp', 'clipboard', 'cookies', 'storage', 'capture', 'profile', 'body', 'console', 'snapshot', 'watch'];
|
|
40
|
+
|
|
41
|
+
const SUBCOMMANDS = ['run', 'serve', 'attach', 'stop', 'send', 'where', 'skill'];
|
|
40
42
|
|
|
41
43
|
let queue = Promise.resolve();
|
|
42
44
|
// The server command running now, so a quit at the prompt can answer it.
|
|
@@ -63,12 +65,13 @@ function busy() {
|
|
|
63
65
|
return running > 0;
|
|
64
66
|
}
|
|
65
67
|
|
|
66
|
-
// A queued command runs as its client; quit and dialog, which skip the queue,
|
|
67
|
-
//
|
|
68
|
-
|
|
68
|
+
// A queued command runs as its client; quit and dialog, which skip the queue, run as the client of
|
|
69
|
+
// the queued command running now, so a dialog that holds a client's click answers in that client's tab.
|
|
70
|
+
// run is the command's record (asClient).
|
|
71
|
+
async function execute(text, client, direct = false, run = {}) {
|
|
69
72
|
running += 1;
|
|
70
73
|
try {
|
|
71
|
-
return direct ? await executeCommand(text) : await asClient(client, () => executeCommand(text));
|
|
74
|
+
return direct ? await executeCommand(text) : await asClient(client, () => executeCommand(text), run);
|
|
72
75
|
} finally {
|
|
73
76
|
running -= 1;
|
|
74
77
|
}
|
|
@@ -100,6 +103,11 @@ async function executeCommand(text) {
|
|
|
100
103
|
state.currentCommandAt = Date.now();
|
|
101
104
|
const run = Object.hasOwn(commands, cmd) ? commands[cmd] : translated && Object.hasOwn(cliCommands, cmd) ? cliCommands[cmd] : null;
|
|
102
105
|
if (!run) {
|
|
106
|
+
// An agent fresh from the skill tries send where; those are the command line's, not the REPL's.
|
|
107
|
+
if (SUBCOMMANDS.includes(cmd)) {
|
|
108
|
+
out.error(`Error: ${cmd} is a pw-repl subcommand, run at the shell: pw-repl ${cmd}${args ? ' ...' : ''}`);
|
|
109
|
+
return { status: 'error', cmd };
|
|
110
|
+
}
|
|
103
111
|
out.log(`Unknown command: ${cmd}. Type 'help' for commands.`);
|
|
104
112
|
return { status: 'error', cmd };
|
|
105
113
|
}
|
|
@@ -117,6 +125,10 @@ async function executeCommand(text) {
|
|
|
117
125
|
stepEnded(step, true);
|
|
118
126
|
// Playwright colours its call log; the codes are noise anywhere but a terminal.
|
|
119
127
|
e.message = String(e.message || '').replace(/\x1b\[[0-9;]*m/g, '');
|
|
128
|
+
// A page's own error (eval's SyntaxError, a cdp method not found) without Playwright's method name
|
|
129
|
+
// or the stack of its injected script, which point at Playwright, not at the line that was sent.
|
|
130
|
+
e.message = e.message.split('\n').filter(line => !/^\s+at /.test(line)).join('\n')
|
|
131
|
+
.replace(/^(?:page|frame|elementHandle|jsHandle|cdpSession)\.(?:evaluate|evaluateHandle|send): /, '');
|
|
120
132
|
out.error(`Error: ${e.message}`);
|
|
121
133
|
const uncertain = !READ_ONLY.has(cmd) && isUncertain(e);
|
|
122
134
|
if (uncertain) out.error('Outcome unknown: it timed out after it began acting on the page. Check the page before trying again.');
|
|
@@ -189,7 +201,9 @@ function enqueue(line) {
|
|
|
189
201
|
// client names the sender: its commands show as [server:<client>] and use its own selected tab.
|
|
190
202
|
// vars: values for the {{ PW_NAME }} variables in text (from send), filled in to run it; text is shown
|
|
191
203
|
// as written, so a password in one is not in the command as the pane, the log and the prompt's history show it.
|
|
192
|
-
|
|
204
|
+
// signal aborts when the sender gives up (send -t): a command still queued is dropped, and one
|
|
205
|
+
// running is no longer waited for, so the queue moves on; whatever it set going may still run.
|
|
206
|
+
function submit(text, client = null, vars = null, signal = null) {
|
|
193
207
|
let run = text;
|
|
194
208
|
if (vars) {
|
|
195
209
|
try { run = expandVariables(text, vars); } catch (error) { return Promise.resolve({ status: 'error', output: `Error: ${error.message}` }); }
|
|
@@ -202,6 +216,10 @@ function submit(text, client = null, vars = null) {
|
|
|
202
216
|
return new Promise(resolve => {
|
|
203
217
|
queue = queue.then(async () => {
|
|
204
218
|
if (state.stopping) return resolve({ status: 'error', output: 'The REPL is shutting down.' });
|
|
219
|
+
if (signal?.aborted) {
|
|
220
|
+
console.log(`[server${client ? `:${client}` : ''}] ${text}: dropped, its sender gave up before it ran`);
|
|
221
|
+
return resolve({ status: 'error', output: 'Dropped: the sender gave up before it ran.' });
|
|
222
|
+
}
|
|
205
223
|
// Printed above whatever the user at the prompt is typing, which is
|
|
206
224
|
// redrawn afterwards rather than broken up.
|
|
207
225
|
if (process.stdout.isTTY) { readline.clearLine(process.stdout, 0); readline.cursorTo(process.stdout, 0); }
|
|
@@ -218,9 +236,21 @@ function submit(text, client = null, vars = null) {
|
|
|
218
236
|
let result;
|
|
219
237
|
const flight = { text, resolve, answered: false };
|
|
220
238
|
inFlight = flight;
|
|
221
|
-
const
|
|
239
|
+
const record = { cancelled: false };
|
|
240
|
+
let giveUp;
|
|
241
|
+
const gaveUp = new Promise(r => { giveUp = r; });
|
|
242
|
+
const onAbort = () => {
|
|
243
|
+
record.cancelled = true;
|
|
244
|
+
out.take();
|
|
245
|
+
const cmd = text.split(' ')[0];
|
|
246
|
+
console.log(`[server${client ? `:${client}` : ''}] ${text}: cancelled, its sender gave up${READ_ONLY.has(cmd) ? '' : '; it may still be acting on the page'}`);
|
|
247
|
+
giveUp(null);
|
|
248
|
+
};
|
|
249
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
250
|
+
const output = await Promise.race([out.collect(async () => { result = await execute(run, client, false, record); }), gaveUp]);
|
|
251
|
+
signal?.removeEventListener('abort', onAbort);
|
|
222
252
|
if (inFlight === flight) inFlight = null;
|
|
223
|
-
if (!flight.answered) resolve({ status: result.status, output, uncertain: result.uncertain });
|
|
253
|
+
if (!flight.answered) resolve(output === null ? { status: 'error', output: 'Cancelled: the sender gave up.' } : { status: result.status, output, uncertain: result.uncertain });
|
|
224
254
|
}
|
|
225
255
|
if (!state.stopping) prompt(true);
|
|
226
256
|
}).catch(e => { out.error(`Error: ${e.message}`); resolve({ status: 'error', output: e.message }); });
|
package/lib/send.js
CHANGED
|
@@ -88,6 +88,20 @@ async function viaTmux(session, command, timeoutMs) {
|
|
|
88
88
|
return 2;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
// A word in double quotes, as the REPL reads them (unescapeDouble): every other character goes as it is.
|
|
92
|
+
function doubleQuote(word) {
|
|
93
|
+
return `"${word.replace(/[\\"]/g, '\\$&').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t')}"`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Where eval's --file is, after its --timeout if it has one; -1 when it has none.
|
|
97
|
+
function evalFileAt(words) {
|
|
98
|
+
if (words[0] !== 'eval') return -1;
|
|
99
|
+
let at = 1;
|
|
100
|
+
if (/^--timeout=/.test(words[at] || '')) at += 1;
|
|
101
|
+
else if (words[at] === '--timeout') at += 2;
|
|
102
|
+
return /^--file(?:=|$)/.test(words[at] || '') ? at : -1;
|
|
103
|
+
}
|
|
104
|
+
|
|
91
105
|
// A command's words as send was given them, made into the line the REPL reads: quoted again where the
|
|
92
106
|
// shell had kept a word whole, and with the files it reads or writes made absolute from the sender's
|
|
93
107
|
// folder, since the REPL's may be another.
|
|
@@ -99,8 +113,8 @@ function commandLine(words) {
|
|
|
99
113
|
const { SELECTOR_FIRST } = require('./syntax');
|
|
100
114
|
// So does playwright-cli's storage key (localstorage-set "my key" v).
|
|
101
115
|
// And record's file, which may have spaces in it, and wait's text, whose last word would otherwise be
|
|
102
|
-
// taken as the
|
|
103
|
-
const requote = words.length > 1 && (SELECTOR_FIRST.has(words[0]) || /^(?:local|session)storage-/.test(words[0]) || words[0] === 'record' || words[0] === 'wait' || /^video-(?:start|stop)$/.test(words[0]));
|
|
116
|
+
// taken as the time when it is a number (wait text 'Showing 1').
|
|
117
|
+
const requote = words.length > 1 && (SELECTOR_FIRST.has(words[0]) || words[0] === 'drag' || /^(?:local|session)storage-/.test(words[0]) || words[0] === 'record' || words[0] === 'wait' || /^video-(?:start|stop)$/.test(words[0]));
|
|
104
118
|
// An empty word (fill #name "") is the empty value.
|
|
105
119
|
// upload's files are read by the REPL, whose folder may not be this one.
|
|
106
120
|
// So are the files upload reads and screenshot --filename writes.
|
|
@@ -117,15 +131,22 @@ function commandLine(words) {
|
|
|
117
131
|
// watch save and profile save write their files.
|
|
118
132
|
if (words[0] === 'profile' && words[1] === 'save' && words.length > 2) resolved = ['profile', 'save', resolve(words.slice(2).join(' '))];
|
|
119
133
|
if (words[0] === 'watch' && words[1] === 'save' && words.length > 2) resolved = ['watch', 'save', resolve(words.slice(2).join(' '))];
|
|
134
|
+
// So does the file eval --file reads.
|
|
135
|
+
const at = evalFileAt(words);
|
|
136
|
+
if (at > 0) {
|
|
137
|
+
// As one word, --file=<path>, which is quoted below when the path has spaces.
|
|
138
|
+
const [file, rest] = words[at] === '--file' ? [words[at + 1], words.slice(at + 2)] : [words[at].slice(7), words.slice(at + 1)];
|
|
139
|
+
if (file) resolved = [...words.slice(0, at), `--file=${resolve(file)}`, ...rest];
|
|
140
|
+
}
|
|
120
141
|
if (words[0] === 'screenshot') {
|
|
121
142
|
resolved = words.map((w, n) => (w.startsWith('--filename=') && w.length > 11 ? `--filename=${resolve(w.slice(11))}` : words[n - 1] === '--filename' ? resolve(w) : w));
|
|
122
143
|
}
|
|
123
|
-
const quoted = requote ? resolved.map(w => (w === '' || /[\s"']/.test(w) ?
|
|
144
|
+
const quoted = requote ? resolved.map(w => (w === '' || /[\s"']/.test(w) ? doubleQuote(w) : w)) : resolved;
|
|
124
145
|
// An option's value the shell kept whole stays whole too: --device='iPhone 15'.
|
|
125
146
|
// Single quotes escape nothing, so a backslash (a regexp's \d) reaches it as typed.
|
|
126
147
|
for (const [n, w] of quoted.entries()) {
|
|
127
148
|
const option = /^(--[A-Za-z][\w-]*=)([\s\S]*\s[\s\S]*)$/.exec(w);
|
|
128
|
-
if (n && option && !requote) quoted[n] = `${option[1]}${option[2].includes("'") ?
|
|
149
|
+
if (n && option && !requote) quoted[n] = `${option[1]}${option[2].includes("'") ? doubleQuote(option[2]) : `'${option[2]}'`}`;
|
|
129
150
|
}
|
|
130
151
|
return quoted.join(' ').trim();
|
|
131
152
|
}
|
|
@@ -155,7 +176,8 @@ async function sendFile(options) {
|
|
|
155
176
|
console.log(`> ${line}`);
|
|
156
177
|
// Read as the prompt reads it; a file it reads or writes is the sender's, as on send's command line.
|
|
157
178
|
const words = shellWords(line);
|
|
158
|
-
|
|
179
|
+
// eval's own line keeps its quotes; only eval --file names a file.
|
|
180
|
+
const command = PATHS.test(words[0]) || evalFileAt(words) > 0 ? commandLine(words) : line;
|
|
159
181
|
const found = options.noVars ? null : variablesFor(command);
|
|
160
182
|
if (found?.error) { console.error(`pw-repl: stopped at line ${i + 1} of ${options.file} (${line}): ${found.error}`); return 1; }
|
|
161
183
|
const status = await send({ ...options, file: null, command, vars: found?.vars || null });
|
|
@@ -182,7 +204,7 @@ async function send(options) {
|
|
|
182
204
|
if (found?.error) return fail(found.error);
|
|
183
205
|
options = { ...options, vars: found?.vars || null };
|
|
184
206
|
}
|
|
185
|
-
const timeoutMs = options.timeout
|
|
207
|
+
const timeoutMs = options.timeout;
|
|
186
208
|
const way = route(options);
|
|
187
209
|
const name = options.client || process.env.PW_CLIENT || null;
|
|
188
210
|
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');
|
package/lib/server.js
CHANGED
|
@@ -56,7 +56,10 @@ function handle(req, res) {
|
|
|
56
56
|
}
|
|
57
57
|
// Bound before the browser is reached, so where and send find it while it starts.
|
|
58
58
|
if (!state.serving) return send(res, 503, { status: 'error', starting: true, output: `The REPL is starting (${state.startingStep}); retry shortly` });
|
|
59
|
-
|
|
59
|
+
// The sender gave up (send -t) once its connection closes before the answer is written.
|
|
60
|
+
const gone = new AbortController();
|
|
61
|
+
res.once('close', () => { if (!res.writableEnded) gone.abort(); });
|
|
62
|
+
const result = await runner.submit(command.trim(), client || null, vars || null, gone.signal);
|
|
60
63
|
if (!result.uncertain) return send(res, 200, { status: result.status, output: result.output });
|
|
61
64
|
// unconfirmed: the command may or may not have done what it was sent to do.
|
|
62
65
|
send(res, 200, { status: result.status, output: result.output, unconfirmed: true });
|
package/lib/start.js
CHANGED
|
@@ -5,6 +5,7 @@ const { state, withTimeout, shutdown, onBeforeExit, beforeExit } = require('./st
|
|
|
5
5
|
const out = require('./output');
|
|
6
6
|
const { watchPage, complete } = require('./commands');
|
|
7
7
|
const { listTabs, openTab, markStartTab } = require('./tabs');
|
|
8
|
+
const { tabState } = require('./tabstate');
|
|
8
9
|
const runner = require('./runner');
|
|
9
10
|
|
|
10
11
|
// A browser --launch starts has its own address instead.
|
|
@@ -88,10 +89,14 @@ async function start(options) {
|
|
|
88
89
|
|
|
89
90
|
if (START_URL) {
|
|
90
91
|
state.startingStep = 'opening the start URL';
|
|
91
|
-
|
|
92
|
+
// A Chromium the REPL launched has only its first blank tab, which nobody else is using: the start
|
|
93
|
+
// URL goes there, rather than into a second tab behind a blank one.
|
|
94
|
+
const first = state.launched && pages.length === 1 && pages[0].url() === 'about:blank' ? pages[0] : null;
|
|
95
|
+
if (first) Object.assign(tabState(first), { opened: true, owner: state.client });
|
|
96
|
+
state.page = first || await openTab();
|
|
92
97
|
markStartTab(state.page);
|
|
93
98
|
await state.page.goto(START_URL, { waitUntil: 'networkidle', timeout: 15000 });
|
|
94
|
-
out.log(`Opened ${state.page.url()} in a new tab`);
|
|
99
|
+
out.log(`Opened ${state.page.url()}${first ? '' : ' in a new tab'}`);
|
|
95
100
|
}
|
|
96
101
|
|
|
97
102
|
out.log('');
|
package/lib/state.js
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
// Session state shared by the entry point, the runner, and the commands, plus
|
|
2
2
|
// the shutdown that ends the session.
|
|
3
|
+
const { AsyncLocalStorage } = require('async_hooks');
|
|
3
4
|
const out = require('./output');
|
|
4
5
|
|
|
5
6
|
const SHUTDOWN_TIMEOUT = 5000;
|
|
6
7
|
|
|
7
8
|
const state = {
|
|
8
9
|
browser: null,
|
|
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
10
|
rl: null,
|
|
13
11
|
promptBase: 'pw> ',
|
|
14
12
|
// The browser the REPL is connected to, and whether --launch started it.
|
|
@@ -36,18 +34,31 @@ function clientRecord(name = state.client) {
|
|
|
36
34
|
return clients.get(name);
|
|
37
35
|
}
|
|
38
36
|
|
|
37
|
+
// The command running in this async context: its client, and whether its sender gave up on it.
|
|
38
|
+
// Code outside any command (a browser event, quit and dialog, which skip the queue) goes by the
|
|
39
|
+
// queued command running now.
|
|
40
|
+
const running = new AsyncLocalStorage();
|
|
41
|
+
let queued = null;
|
|
42
|
+
|
|
43
|
+
// The client whose command runs now: null for the prompt and senders without a name, who share one
|
|
44
|
+
// selected tab, as before clients had names. Each command keeps its own for its whole life, so one
|
|
45
|
+
// its sender gave up on, still running, never acts as the command after it.
|
|
46
|
+
Object.defineProperty(state, 'client', { enumerable: true, get: () => (running.getStore() ?? queued)?.client ?? null });
|
|
47
|
+
|
|
48
|
+
out.onCancelled(() => running.getStore()?.cancelled === true);
|
|
49
|
+
|
|
39
50
|
for (const key of Object.keys(PER_CLIENT)) {
|
|
40
51
|
Object.defineProperty(state, key, { enumerable: true, get: () => clientRecord()[key], set: value => { clientRecord()[key] = value; } });
|
|
41
52
|
}
|
|
42
53
|
|
|
43
|
-
// Runs fn as the named client.
|
|
44
|
-
async function asClient(name, fn) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
try { return await fn(); } finally { state.client = previous; }
|
|
54
|
+
// Runs fn as the named client. run is the command's record, whose cancelled the runner sets.
|
|
55
|
+
async function asClient(name, fn, run = {}) {
|
|
56
|
+
queued = Object.assign(run, { client: name || null });
|
|
57
|
+
try { return await running.run(run, fn); } finally { if (queued === run) queued = null; }
|
|
48
58
|
}
|
|
49
59
|
|
|
50
60
|
const cleanups = [];
|
|
61
|
+
const endings = [];
|
|
51
62
|
const exitWaits = [];
|
|
52
63
|
let shutdownPromise = null;
|
|
53
64
|
|
|
@@ -65,6 +76,11 @@ function onShutdown(fn) {
|
|
|
65
76
|
cleanups.push(fn);
|
|
66
77
|
}
|
|
67
78
|
|
|
79
|
+
// Run once every cleanup has finished, before the browser is closed.
|
|
80
|
+
function onShutdownEnd(fn) {
|
|
81
|
+
endings.push(fn);
|
|
82
|
+
}
|
|
83
|
+
|
|
68
84
|
// Things to finish before the process exits, e.g. answers still being sent.
|
|
69
85
|
function onBeforeExit(fn) {
|
|
70
86
|
exitWaits.push(fn);
|
|
@@ -80,6 +96,7 @@ async function shutdown() {
|
|
|
80
96
|
shutdownPromise = (async () => {
|
|
81
97
|
// Some undo what the REPL changed in the browser, so they finish first.
|
|
82
98
|
await withTimeout(Promise.all(cleanups.map(async fn => fn())), 'Cleanup').catch(() => {});
|
|
99
|
+
for (const fn of endings) try { await fn(); } catch {}
|
|
83
100
|
if (state.browser) {
|
|
84
101
|
try { await withTimeout(state.browser.close(), 'Chromium shutdown'); }
|
|
85
102
|
catch (error) { state.shutdownFailed = true; process.exitCode = 1; out.error(`Could not confirm Chromium shutdown: ${error.message}`); }
|
|
@@ -88,4 +105,4 @@ async function shutdown() {
|
|
|
88
105
|
return shutdownPromise;
|
|
89
106
|
}
|
|
90
107
|
|
|
91
|
-
module.exports = { state, clientRecord, asClient, withTimeout, onShutdown, onBeforeExit, beforeExit, shutdown, SHUTDOWN_TIMEOUT };
|
|
108
|
+
module.exports = { state, clientRecord, asClient, withTimeout, onShutdown, onShutdownEnd, onBeforeExit, beforeExit, shutdown, SHUTDOWN_TIMEOUT };
|
package/lib/syntax.js
CHANGED
|
@@ -11,25 +11,31 @@ function toSelector(word) {
|
|
|
11
11
|
return REF.test(word) ? `aria-ref=${word}` : word;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
//
|
|
14
|
+
// Inside double quotes, \n, \t and \r are a newline, tab and carriage return, so a value send quotes
|
|
15
|
+
// keeps them on one line; any other \X is X (\" a quote, \\ a backslash).
|
|
16
|
+
function unescapeDouble(text) {
|
|
17
|
+
return text.replace(/\\(.)/g, (_, c) => (c === 'n' ? '\n' : c === 't' ? '\t' : c === 'r' ? '\r' : c));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Quotes around the whole of a value are removed.
|
|
15
21
|
function unquote(text) {
|
|
16
22
|
const match = /^"((?:[^"\\]|\\.)*)"$|^'([^']*)'$/.exec(text);
|
|
17
23
|
if (!match) return text;
|
|
18
|
-
return match[1] !== undefined ? match[1]
|
|
24
|
+
return match[1] !== undefined ? unescapeDouble(match[1]) : match[2];
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
// The words of a line as a shell reads them: quoted parts may hold spaces, and lose their quotes
|
|
22
28
|
// (--filename="my dir/a.webm" is one word, --filename=my dir/a.webm).
|
|
23
29
|
function shellWords(text) {
|
|
24
30
|
return [...(text || '').matchAll(/(?:"(?:[^"\\]|\\.)*"|'[^']*'|[^\s"']+|["'])+/g)]
|
|
25
|
-
.map(m => m[0].replace(/"((?:[^"\\]|\\.)*)"|'([^']*)'/g, (_, double, single) => (double !== undefined ? double
|
|
31
|
+
.map(m => m[0].replace(/"((?:[^"\\]|\\.)*)"|'([^']*)'/g, (_, double, single) => (double !== undefined ? unescapeDouble(double) : single)));
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
// The first word of args, or a quoted selector with spaces in it, and the rest of the line.
|
|
29
35
|
function splitSelector(args) {
|
|
30
36
|
const match = /^(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+))(?:\s+([\s\S]*))?$/.exec((args || '').trim());
|
|
31
37
|
if (!match) return null;
|
|
32
|
-
const word = match[1] !== undefined ? match[1]
|
|
38
|
+
const word = match[1] !== undefined ? unescapeDouble(match[1]) : match[2] !== undefined ? match[2] : match[3];
|
|
33
39
|
return { word, selector: toSelector(word), rest: match[4] };
|
|
34
40
|
}
|
|
35
41
|
|
|
@@ -83,4 +89,4 @@ function expandVariables(line, values) {
|
|
|
83
89
|
});
|
|
84
90
|
}
|
|
85
91
|
|
|
86
|
-
module.exports = { SELECTOR_FIRST, REF, toSelector, unquote, shellWords, splitSelector, keyName, variablesIn, expandVariables };
|
|
92
|
+
module.exports = { SELECTOR_FIRST, REF, toSelector, unescapeDouble, unquote, shellWords, splitSelector, keyName, variablesIn, expandVariables };
|
package/lib/toast.js
CHANGED
|
@@ -7,6 +7,7 @@ const { unquote } = require('./syntax');
|
|
|
7
7
|
const { takeOptions } = require('./cli-names');
|
|
8
8
|
const { inOverlay, keepAcrossPages, forget } = require('./overlay');
|
|
9
9
|
const { recordingOf, beforeRecordingEnds } = require('./record');
|
|
10
|
+
const { parseDuration } = require('./util');
|
|
10
11
|
|
|
11
12
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
12
13
|
|
|
@@ -81,12 +82,6 @@ async function toastOff(p, fade = true) {
|
|
|
81
82
|
beforeRecordingEnds(p => (p && !p.isClosed() ? waitToRead(p) : null));
|
|
82
83
|
|
|
83
84
|
// 3s, 1.5s, 800ms, or a number of milliseconds.
|
|
84
|
-
function parseDuration(value) {
|
|
85
|
-
const match = /^(\d+(?:\.\d+)?)(ms|s)?$/.exec(value || '');
|
|
86
|
-
if (!match) return null;
|
|
87
|
-
return Math.round(Number(match[1]) * (match[2] === 's' ? 1000 : 1));
|
|
88
|
-
}
|
|
89
|
-
|
|
90
85
|
onShutdown(async () => {
|
|
91
86
|
if (!state.browser || state.connectionLost) return;
|
|
92
87
|
const pages = state.browser.contexts().flatMap(c => c.pages()).filter(p => !p.isClosed());
|
package/lib/util.js
CHANGED
|
@@ -20,6 +20,28 @@ function hints(pairs) {
|
|
|
20
20
|
return `\n${pairs.map(([command, what]) => ` ${command.padEnd(width)} ${what}`).join('\n')}`;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// A time: milliseconds, as Playwright and JavaScript count it, or seconds with an s (1500, 1.5s, 1500ms).
|
|
24
|
+
const TIME = /^(\d+(?:\.\d+)?)(ms|s)?$/;
|
|
25
|
+
|
|
26
|
+
function parseDuration(value) {
|
|
27
|
+
const match = TIME.exec(value || '');
|
|
28
|
+
if (!match) return null;
|
|
29
|
+
return Math.round(Number(match[1]) * (match[2] === 's' ? 1000 : 1));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// How a time reads back: 1.5s, or 250ms under a second.
|
|
33
|
+
function durationText(ms) {
|
|
34
|
+
return ms >= 1000 ? `${Number((ms / 1000).toFixed(3))}s` : `${ms}ms`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A time that is a second or more, where a smaller one is most likely seconds written as before.
|
|
38
|
+
function longDuration(value, what) {
|
|
39
|
+
const ms = parseDuration(value);
|
|
40
|
+
if (ms === null) throw new Error(`${what} is a time, in milliseconds or with s for seconds: 30000 or 30s (got ${value})`);
|
|
41
|
+
if (ms < 1000) throw new Error(`${what} is in milliseconds, 1000 or more: ${value} is ${durationText(ms)}; ${value}s for seconds`);
|
|
42
|
+
return ms;
|
|
43
|
+
}
|
|
44
|
+
|
|
23
45
|
function clipText(text) {
|
|
24
46
|
return text.length > MAX_EVENT_TEXT ? `${text.slice(0, MAX_EVENT_TEXT)}…` : text;
|
|
25
47
|
}
|
|
@@ -56,6 +78,8 @@ function clock(t) {
|
|
|
56
78
|
// by itself unseen: stop prints those lines.
|
|
57
79
|
const STOPPED_RECORDING = 'The REPL stopped, which ended a recording';
|
|
58
80
|
const ENDED_UNSEEN = 'A recording ended before the REPL stopped, unseen since';
|
|
81
|
+
// Each line stop prints of the files a client left.
|
|
82
|
+
const FILES_LEFT = 'Files left';
|
|
59
83
|
|
|
60
84
|
// Where Playwright installs its browsers and ffmpeg, worked out as playwright-core does it: a cache of
|
|
61
85
|
// each platform's own (~/Library/Caches on macOS), unless PLAYWRIGHT_BROWSERS_PATH says otherwise.
|
|
@@ -69,4 +93,4 @@ function playwrightBrowsersDir() {
|
|
|
69
93
|
return path.join(cache, 'ms-playwright');
|
|
70
94
|
}
|
|
71
95
|
|
|
72
|
-
module.exports = { BUSY_PAGE, STOPPED_RECORDING, ENDED_UNSEEN, COMMAND_TIMEOUT, MAX_EVENT_TEXT, NO_TAB, hints, clipText, numbers, regexOf, patternText, clock, playwrightBrowsersDir };
|
|
96
|
+
module.exports = { TIME, parseDuration, durationText, longDuration, BUSY_PAGE, STOPPED_RECORDING, ENDED_UNSEEN, FILES_LEFT, COMMAND_TIMEOUT, MAX_EVENT_TEXT, NO_TAB, hints, clipText, numbers, regexOf, patternText, clock, playwrightBrowsersDir };
|
package/lib/watch.js
CHANGED
|
@@ -4,6 +4,7 @@ const out = require('./output');
|
|
|
4
4
|
const { printOutput } = out;
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
|
+
const { noteWritten } = require('./written');
|
|
7
8
|
const { clipText, clock, hints, NO_TAB } = require('./util');
|
|
8
9
|
const { tabState } = require('./tabstate');
|
|
9
10
|
const { keptSession, keepDrawing } = require('./cdp');
|
|
@@ -22,8 +23,8 @@ const PW_SETTINGS = ['PW_CDP_URL', 'PW_CHROME', 'PW_CLIENT', 'PW_ENDPOINT', 'PW_
|
|
|
22
23
|
'PW_SOCKET', 'PW_START_URL', 'PW_TEST_CHROME', 'PW_TMUX_SESSION'];
|
|
23
24
|
const PAGE_ACTIONS = new Set(['click', 'check', 'uncheck', 'select', 'fill', 'type', 'press', 'submit']);
|
|
24
25
|
const WATCH_BINDING = '__pwReplWatch';
|
|
25
|
-
//
|
|
26
|
-
const ROUTE_WAIT =
|
|
26
|
+
// How long a saved step waits for its element after the app's own routing (watch save).
|
|
27
|
+
const ROUTE_WAIT = '30s';
|
|
27
28
|
|
|
28
29
|
// Runs in the page. Describes elements the way snapshot does: role and name.
|
|
29
30
|
const WATCH_SCRIPT = `(() => {
|
|
@@ -811,6 +812,7 @@ const commands = {
|
|
|
811
812
|
if (variables.length) header.push(`# It needs ${variables.join(', ')} set in the environment: what was typed into ${where}, not saved here`);
|
|
812
813
|
// It holds what was typed, so it is the user's own to read.
|
|
813
814
|
fs.writeFileSync(file, `${[...header, ...lines].join('\n')}\n`, { mode: 0o600, flag: 'wx' });
|
|
815
|
+
noteWritten(file, 'watch file');
|
|
814
816
|
out.log(`Saved ${watch.events.length} step${watch.events.length === 1 ? '' : 's'} to ${file}${skipped ? `; ${skipped} could not be written as commands, and are comments there saying why` : ''}.`);
|
|
815
817
|
if (variables.length) out.log(`What was typed into ${where} is a variable there, filled in from the environment: ${variables.map(name => `{{ ${name} }}`).join(', ')}.`);
|
|
816
818
|
out.log(`${variables.map(name => `${name}=... `).join('')}pw-repl send --file ${file} runs them.`);
|
package/lib/written.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The files the REPL wrote this session, and for whom, so close and stop can say which are still
|
|
2
|
+
// there to clean up. Kept in memory only, and nothing is removed.
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { state, onShutdownEnd } = require('./state');
|
|
6
|
+
const out = require('./output');
|
|
7
|
+
const { FILES_LEFT } = require('./util');
|
|
8
|
+
|
|
9
|
+
const written = new Map();
|
|
10
|
+
|
|
11
|
+
// kind: what it is, as the summary counts it (screenshot, video, steps file, profile, watch file).
|
|
12
|
+
function noteWritten(file, kind, client = state.client) {
|
|
13
|
+
written.set(path.resolve(file), { kind, client: client ?? null });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// A file renamed (record off <file>) is still the same one.
|
|
17
|
+
function noteMoved(from, to) {
|
|
18
|
+
const entry = written.get(path.resolve(from));
|
|
19
|
+
if (!entry) return;
|
|
20
|
+
written.delete(path.resolve(from));
|
|
21
|
+
written.set(path.resolve(to), entry);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function remaining(client) {
|
|
25
|
+
const files = [];
|
|
26
|
+
for (const [file, entry] of written) {
|
|
27
|
+
if (entry.client !== (client ?? null)) continue;
|
|
28
|
+
try { files.push({ file, kind: entry.kind, size: fs.statSync(file).size }); } catch {}
|
|
29
|
+
}
|
|
30
|
+
return files;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sizeText(bytes) {
|
|
34
|
+
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
|
35
|
+
return `${Number((bytes / 1024 / 1024).toFixed(1))} MB`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// For close: the client's own files, in full.
|
|
39
|
+
function ownFilesText(client) {
|
|
40
|
+
const files = remaining(client);
|
|
41
|
+
if (!files.length) return null;
|
|
42
|
+
return [`Files you wrote that are still there; remove what is no longer needed:`, ...files.map(f => ` ${f.file} (${sizeText(f.size)})`)].join('\n');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const plural = (n, kind) => `${n} ${kind}${n === 1 ? '' : 's'}`;
|
|
46
|
+
|
|
47
|
+
// For stop: one line for each client that left files, each with what and where.
|
|
48
|
+
function summaryLines() {
|
|
49
|
+
const clients = [...new Set([...written.values()].map(e => e.client))];
|
|
50
|
+
const lines = [];
|
|
51
|
+
for (const client of clients) {
|
|
52
|
+
const files = remaining(client);
|
|
53
|
+
if (!files.length) continue;
|
|
54
|
+
const kinds = new Map();
|
|
55
|
+
for (const f of files) kinds.set(f.kind, (kinds.get(f.kind) || 0) + 1);
|
|
56
|
+
const folders = [...new Set(files.map(f => `${path.dirname(f.file)}/`))];
|
|
57
|
+
const bytes = files.reduce((sum, f) => sum + f.size, 0);
|
|
58
|
+
const who = client ? `by ${client}` : 'without a client name';
|
|
59
|
+
lines.push(`${FILES_LEFT} ${who}: ${plural(files.length, 'file')}, ${sizeText(bytes)} (${[...kinds].map(([k, n]) => plural(n, k)).join(', ')}) in ${folders.join(' and ')}`);
|
|
60
|
+
}
|
|
61
|
+
return lines;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// After every recording has been saved, so its file is whole.
|
|
65
|
+
onShutdownEnd(() => { for (const line of summaryLines()) out.log(line); });
|
|
66
|
+
|
|
67
|
+
module.exports = { noteWritten, noteMoved, ownFilesText, summaryLines };
|