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/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
- // run as whoever is running, so they never switch the client under a command.
68
- async function execute(text, client, direct = false) {
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
- function submit(text, client = null, vars = null) {
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 output = await out.collect(async () => { result = await execute(run, client); });
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 seconds when it is a number (wait text 'Showing 1').
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) ? JSON.stringify(w) : w)) : resolved;
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("'") ? JSON.stringify(option[2]) : `'${option[2]}'`}`;
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
- const command = PATHS.test(words[0]) ? commandLine(words) : line;
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 * 1000;
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
- const result = await runner.submit(command.trim(), client || null, vars || null);
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
- state.page = await openTab();
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. Commands run one at a time, so this is safe.
44
- async function asClient(name, fn) {
45
- const previous = state.client;
46
- state.client = name || null;
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
- // Quotes around the whole of a value are removed; \" inside double quotes is a quote.
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].replace(/\\(.)/g, '$1') : match[2];
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.replace(/\\(.)/g, '$1') : single)));
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].replace(/\\(.)/g, '$1') : match[2] !== undefined ? match[2] : match[3];
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
- // Seconds a saved step waits for its element after the app's own routing (watch save).
26
- const ROUTE_WAIT = 30;
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pw-repl",
3
- "version": "0.12.7",
3
+ "version": "0.13.0",
4
4
  "description": "Text REPL for driving an existing Chromium through Playwright over CDP",
5
5
  "keywords": [
6
6
  "playwright",