pw-repl 0.12.8 → 0.14.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/profile.js CHANGED
@@ -3,13 +3,15 @@ const fs = require('fs');
3
3
  const os = require('os');
4
4
  const path = require('path');
5
5
  const { state, onShutdown } = require('./state');
6
+ const { noteWritten } = require('./written');
6
7
  const out = require('./output');
7
- const { NO_TAB, clock, hints } = require('./util');
8
+ const { NO_TAB, clock, hints, TIME, longDuration, durationText } = require('./util');
8
9
  const { tabState } = require('./tabstate');
9
10
  const { targetIdOf } = require('./tabs');
10
11
  const { summarize, LONG_TASK } = require('./trace');
11
12
 
12
- const MAX_SECONDS = 120;
13
+ // Without a time, a profile left on stops by itself after this long.
14
+ const UNTIMED_MAX = 120000;
13
15
  const SHOWN = 10;
14
16
  // DevTools' Performance panel's categories, less the browser-wide ones the summary does not use.
15
17
  const CATEGORIES = ['devtools.timeline', 'disabled-by-default-devtools.timeline', 'disabled-by-default-devtools.timeline.frame',
@@ -52,12 +54,11 @@ function forget(profile) {
52
54
 
53
55
  async function startProfile(tokens, quiet = false) {
54
56
  if (prof) throw new Error(`Already profiling ${prof.page.url()}; profile off stops it`);
55
- const usage = `Usage: profile on [seconds] [--cpu] (seconds from 1 to ${MAX_SECONDS})`;
57
+ const usage = 'Usage: profile on [time] [--cpu] (ms, or 30s)';
56
58
  const cpu = tokens.includes('--cpu');
57
59
  const rest = tokens.filter(t => t !== '--cpu');
58
- if (rest.length > 1 || (rest.length && !/^\d+$/.test(rest[0]))) throw new Error(usage);
59
- const seconds = rest.length ? Number(rest[0]) : null;
60
- if (seconds !== null && (seconds < 1 || seconds > MAX_SECONDS)) throw new Error(usage);
60
+ if (rest.length > 1 || (rest.length && !TIME.test(rest[0]))) throw new Error(usage);
61
+ const ms = rest.length ? longDuration(rest[0], 'profile on\'s time') : null;
61
62
  if (!state.page || state.page.isClosed()) throw new Error(NO_TAB);
62
63
  await finishing;
63
64
  const page = state.page;
@@ -98,17 +99,17 @@ async function startProfile(tokens, quiet = false) {
98
99
  };
99
100
  session.on('Tracing.bufferUsage', onUsage);
100
101
  profile.unlisten = () => { page.off('close', onClose); session.off('Tracing.bufferUsage', onUsage); };
101
- profile.cap = setTimeout(() => {
102
- if (prof === profile) stop(`it reached ${MAX_SECONDS}s, the most a profile records.`, `The profile stopped at ${MAX_SECONDS}s, the most it records`);
103
- }, MAX_SECONDS * 1000);
104
- if (!seconds) {
102
+ if (!ms) {
103
+ profile.cap = setTimeout(() => {
104
+ if (prof === profile) stop(`it reached ${durationText(UNTIMED_MAX)}, the most a profile records without a time (profile on <time> for longer).`, `The profile stopped at ${durationText(UNTIMED_MAX)}, the most it records without a time`);
105
+ }, UNTIMED_MAX);
105
106
  if (quiet) return;
106
107
  out.log(`Profiling the selected tab${cpu ? ' with a JS CPU profile' : ''}; profile off stops it and prints a summary.`);
107
108
  return;
108
109
  }
109
- out.log(`Profiling the selected tab for ${seconds}s...`);
110
+ out.log(`Profiling the selected tab for ${durationText(ms)}...`);
110
111
  await new Promise(resolve => {
111
- const timer = setTimeout(resolve, seconds * 1000);
112
+ const timer = setTimeout(resolve, ms);
112
113
  profile.wake = () => { clearTimeout(timer); resolve(); };
113
114
  });
114
115
  // profile off may have ended it first, and printed it; anything else that ended it (its tab closed,
@@ -257,7 +258,7 @@ const commands = {
257
258
  await finishing;
258
259
  if (sub === 'save') return saveProfile(rest.join(' '));
259
260
  if (sub === 'reload' && !rest.length) return profileReload(all);
260
- if (sub) throw new Error(`Usage: profile on [seconds] [--cpu] | off | save <file> | reload`);
261
+ if (sub) throw new Error(`Usage: profile on [time] [--cpu] | off | save <file> | reload`);
261
262
  if (prof) {
262
263
  out.log(`Profiling ${prof.page.url()} since ${clock(prof.startedAt)}${prof.owner ? `, for ${prof.owner}` : ''}`);
263
264
  out.log(hints([['profile off', 'stop and print a summary']]));
@@ -270,7 +271,7 @@ const commands = {
270
271
  out.log('Not profiling.');
271
272
  }
272
273
  out.log(hints([
273
- ['profile on [seconds]', 'record what the selected tab\'s main thread does'],
274
+ ['profile on [time]', 'record what the selected tab\'s main thread does'],
274
275
  ['profile reload', 'profile the selected tab\'s page as it loads'],
275
276
  ]));
276
277
  },
@@ -301,6 +302,7 @@ function saveProfile(file) {
301
302
  if (fs.existsSync(file)) throw new Error(`${file} exists already; name another file`);
302
303
  if (!fs.existsSync(path.dirname(path.resolve(file)))) throw new Error(`No folder ${path.dirname(path.resolve(file))} to save ${path.basename(file)} in`);
303
304
  fs.copyFileSync(last.saveFile, file, fs.constants.COPYFILE_EXCL);
305
+ noteWritten(file, 'profile');
304
306
  const kb = Math.round(fs.statSync(file).size / 1024);
305
307
  out.log(`Saved the profile of ${last.url} to ${file} (${kb} KB): the tab's events only. Chrome DevTools' Performance panel (Load profile) and ui.perfetto.dev open it.`);
306
308
  }
package/lib/record.js CHANGED
@@ -5,12 +5,14 @@ const path = require('path');
5
5
  const { state, onShutdown } = require('./state');
6
6
  const out = require('./output');
7
7
  const { tabState } = require('./tabstate');
8
- const { clock, NO_TAB, STOPPED_RECORDING, ENDED_UNSEEN, playwrightBrowsersDir } = require('./util');
8
+ const { clock, NO_TAB, STOPPED_RECORDING, ENDED_UNSEEN, playwrightBrowsersDir, TIME, longDuration, durationText } = require('./util');
9
9
  const { shellWords, splitSelector } = require('./syntax');
10
10
  const { reapplyMetrics } = require('./emulation');
11
+ const { noteWritten, noteMoved } = require('./written');
11
12
 
12
13
  const FPS = 25;
13
- const MAX_SECONDS = 3600;
14
+ // Without a time, a recording left on stops by itself after this long.
15
+ const UNTIMED_MAX = 3600000;
14
16
  // While recording: a moment after each action for its result to be seen, and still page at both ends,
15
17
  // so a video neither starts nor stops on an action (record on --pause, --lead, --tail).
16
18
  const PACING = { pause: 750, lead: 1000, tail: 1000 };
@@ -121,7 +123,7 @@ function sizeText(bytes) {
121
123
  }
122
124
 
123
125
  // Each client's last recording that ended, and how, for record to report: one that ends on
124
- // its own (its seconds, its tab closing) says so only in the pane.
126
+ // its own (its time, its tab closing) says so only in the pane.
125
127
  const lastEnded = new Map();
126
128
  // Each client's recordings still being saved, which record waits for before it reports.
127
129
  const saving = new Map();
@@ -141,7 +143,7 @@ function recordingPages() {
141
143
  return state.browser.contexts().flatMap(c => c.pages()).filter(p => recordingOf(p));
142
144
  }
143
145
 
144
- async function startRecording(p, file, seconds, format, keepSteps, pacing) {
146
+ async function startRecording(p, file, ms, format, keepSteps, pacing) {
145
147
  const exe = findFfmpeg(format);
146
148
  // As screenshot does: a tab that has not been in front since it was emulated as a phone can
147
149
  // be drawn at the window's size.
@@ -252,8 +254,8 @@ async function startRecording(p, file, seconds, format, keepSteps, pacing) {
252
254
  // drawn yet (a tab still opening) starts it when it does.
253
255
  await Promise.race([gotFirstFrame, new Promise(resolve => setTimeout(resolve, FIRST_FRAME_TIMEOUT))]);
254
256
  rec.limit = setTimeout(() => {
255
- finishRecording(rec, `It reached its ${seconds}s and stopped`).then(text => out.notice(text, p));
256
- }, seconds * 1000);
257
+ finishRecording(rec, `It reached its ${durationText(ms)} and stopped`).then(text => out.notice(text, p));
258
+ }, ms);
257
259
  rec.onClose = () => finishRecording(rec, TAB_CLOSED).then(text => out.notice(text, p));
258
260
  p.once('close', rec.onClose);
259
261
  // A new page, loaded or routed to by a single-page app, is a step of its own, for whoever edits the
@@ -501,6 +503,7 @@ function writeSteps(rec, seconds) {
501
503
  } catch (error) {
502
504
  return { file, error: error.code === 'EEXIST' ? `${file} already exists` : error.message };
503
505
  }
506
+ noteWritten(file, 'steps file', rec.owner);
504
507
  rec.stepsFile = file;
505
508
  return { file, count: rec.steps.length };
506
509
  }
@@ -565,7 +568,7 @@ function fileWords(words, usage) {
565
568
  // playwright-cli's video-start --size: here the video is the page's size.
566
569
  else if (/^--size(?:=|$)/.test(word)) throw new Error(`record takes no --size: the video is the page's size; set it first with viewport <width>x<height>`);
567
570
  else if (word.startsWith('-')) throw new Error(`record takes no ${word}. ${usage}`);
568
- else if (/^\d+$/.test(word)) rest.push(word);
571
+ else if (TIME.test(word)) rest.push(word);
569
572
  else files.push(word);
570
573
  }
571
574
  if (files.length > 1) throw new Error(`record takes one file, and ${files.map(f => `"${f}"`).join(' and ')} are two: quote a file name with spaces in it. ${usage}`);
@@ -592,13 +595,14 @@ function moveRecording(from, to) {
592
595
  fs.chmodSync(to, 0o600);
593
596
  fs.unlinkSync(from);
594
597
  }
598
+ noteMoved(from, to);
595
599
  }
596
600
 
597
601
  const commands = {
598
602
  async record(args) {
599
603
  // A file may be quoted, with spaces in it.
600
604
  const words = shellWords(args);
601
- const usage = `Usage: record [on [file.webm|file.mp4] [seconds] [--steps] [--pause=<ms>] [--lead=<ms>] [--tail=<ms>] | off [file]] (seconds from 1 to ${MAX_SECONDS})`;
605
+ const usage = `Usage: record [on [file.webm|file.mp4] [time] [--steps] [--pause=<ms>] [--lead=<ms>] [--tail=<ms>] | off [file]] (a time in ms, or 30s)`;
602
606
  if (!words.length) {
603
607
  await Promise.allSettled([...(saving.get(state.client ?? null) || [])]);
604
608
  out.log(statusText(state.page));
@@ -636,7 +640,7 @@ const commands = {
636
640
  const other = (rec.owner ?? null) !== (state.client ?? null);
637
641
  const clientName = c => c ?? 'the unnamed client';
638
642
  // What must be seen before the end (a toast's read time), then the tail: still page after the last
639
- // action. Only record off has them; the seconds limit is a hard stop.
643
+ // action. Only record off has them; the time limit is a hard stop.
640
644
  if (!rec.finishing) for (const wait of beforeEnding) await wait(state.page);
641
645
  if (!rec.finishing) await sleep(rec.pacing.tail);
642
646
  const text = await finishRecording(rec, other ? `${clientName(state.client)}'s record off ended ${clientName(rec.owner)}'s recording` : null);
@@ -662,8 +666,7 @@ const commands = {
662
666
  return;
663
667
  }
664
668
  if (numbers.length > 1) throw new Error(usage);
665
- const seconds = numbers.length ? Number(numbers[0]) : MAX_SECONDS;
666
- if (seconds < 1 || seconds > MAX_SECONDS) throw new Error(usage);
669
+ const ms = numbers.length ? longDuration(numbers[0], 'record on\'s time') : UNTIMED_MAX;
667
670
  const file = named || nextRecordingPath();
668
671
  const current = recordingOf(state.page);
669
672
  if (current) throw new Error(`The selected tab is already being recorded to ${current.file}; record off stops it`);
@@ -673,13 +676,14 @@ const commands = {
673
676
  // ffmpeg writes over it and keeps the mode. It never replaces a file.
674
677
  try {
675
678
  fs.closeSync(fs.openSync(file, 'wx', 0o600));
679
+ noteWritten(file, 'video');
676
680
  } catch (error) {
677
681
  if (error.code === 'EEXIST') throw new Error(`Recording already exists: ${file}`);
678
682
  throw error;
679
683
  }
680
684
  let rec;
681
685
  try {
682
- rec = await startRecording(state.page, file, seconds, format, steps, { ...PACING, ...pacing });
686
+ rec = await startRecording(state.page, file, ms, format, steps, { ...PACING, ...pacing });
683
687
  } catch (error) {
684
688
  fs.rmSync(file, { force: true });
685
689
  throw error;
@@ -697,7 +701,7 @@ const commands = {
697
701
  const until = Date.now() + 2000;
698
702
  while (rec.firstFrameAt === null && !rec.finishing && Date.now() < until) await sleep(20);
699
703
  if (rec.firstFrameAt !== null && !rec.finishing) await sleep(Math.max(0, rec.pacing.lead - (Date.now() - rec.firstFrameAt)));
700
- const limit = numbers.length ? `for ${seconds}s` : `until record off (at most ${MAX_SECONDS / 60} minutes)`;
704
+ const limit = numbers.length ? `for ${durationText(ms)}` : `until record off (at most ${UNTIMED_MAX / 60000} minutes)`;
701
705
  out.log(`Recording the selected tab to ${file} ${limit}${steps ? ', with its steps' : ''}${pacingText(rec.pacing)}; record off stops it and saves the file.`);
702
706
  },
703
707
  };
package/lib/routes.js CHANGED
@@ -2,7 +2,7 @@
2
2
  const { state } = require('./state');
3
3
  const out = require('./output');
4
4
  const { unquote, splitSelector } = require('./syntax');
5
- const { hints } = require('./util');
5
+ const { hints, parseDuration, durationText } = require('./util');
6
6
  const { tabState } = require('./tabstate');
7
7
  const { markChanged, requestEntries } = require('./requestlog');
8
8
 
@@ -14,7 +14,6 @@ function routesFor(p) {
14
14
  return tabState(p).routes;
15
15
  }
16
16
 
17
- const ROUTE_DELAY_MAX = 120;
18
17
 
19
18
  // A JSON Merge Patch (RFC 7386): objects merge, null removes a key, anything else replaces.
20
19
  function mergePatch(target, patch) {
@@ -77,14 +76,14 @@ function routeKind(how, rest, usage) {
77
76
  };
78
77
  }
79
78
  if (how === 'delay') {
80
- const seconds = Number(rest);
81
- if (!/^\d+(?:\.\d+)?$/.test(rest || '') || seconds <= 0 || seconds > ROUTE_DELAY_MAX) throw new Error(`Usage: route <url-glob> delay <seconds> (up to ${ROUTE_DELAY_MAX})`);
79
+ const ms = parseDuration(rest);
80
+ if (!ms) throw new Error('Usage: route <url-glob> delay <time> (ms, or 3s)');
82
81
  return {
83
- text: `delay ${seconds}s`,
82
+ text: `delay ${durationText(ms)}`,
84
83
  async handle(r, req, tag, notice) {
85
- await new Promise(resolve => setTimeout(resolve, seconds * 1000));
84
+ await new Promise(resolve => setTimeout(resolve, ms));
86
85
  await r.continue();
87
- notice(`Delayed ${seconds}s: ${tag()}`);
86
+ notice(`Delayed ${durationText(ms)}: ${tag()}`);
88
87
  },
89
88
  };
90
89
  }
@@ -106,7 +105,7 @@ function listRoutes() {
106
105
  ['route <url-glob> <status> [json-body]', 'answer it with this status and JSON'],
107
106
  ['route <url-glob> <status> --content-type=<type> <body>', 'or with a body of another type'],
108
107
  ['route <url-glob> patch <json>', 'let it through, then change its JSON'],
109
- ['route <url-glob> delay <seconds>', 'hold it, then let it through'],
108
+ ['route <url-glob> delay <time>', 'hold it, then let it through'],
110
109
  ['route <url-glob> abort', 'fail it as if the connection broke'],
111
110
  ];
112
111
  if (!routes.size) {
@@ -147,7 +146,7 @@ const commands = {
147
146
  // A glob may be quoted, as it is in a shell.
148
147
  const off = /^off(?:\s+(\S+))?$/.exec(trimmed);
149
148
  if (off) return removeRoutes(off[1] && unquote(off[1]));
150
- const usage = 'Usage: route <url-glob> <status> [json-body] | route <url-glob> patch <json> | route <url-glob> delay <seconds> | route <url-glob> abort | route off <url-glob>|--all';
149
+ const usage = 'Usage: route <url-glob> <status> [json-body] | route <url-glob> patch <json> | route <url-glob> delay <time> | route <url-glob> abort | route off <url-glob>|--all';
151
150
  const parsed = splitSelector(trimmed);
152
151
  const match = /^(\S+)(?:\s+([\s\S]+))?$/.exec(parsed?.rest || '');
153
152
  if (!match) throw new Error(usage);
package/lib/runner.js CHANGED
@@ -29,14 +29,14 @@ 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
40
 
41
41
  const SUBCOMMANDS = ['run', 'serve', 'attach', 'stop', 'send', 'where', 'skill'];
42
42
 
@@ -65,12 +65,13 @@ function busy() {
65
65
  return running > 0;
66
66
  }
67
67
 
68
- // A queued command runs as its client; quit and dialog, which skip the queue,
69
- // run as whoever is running, so they never switch the client under a command.
70
- 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 = {}) {
71
72
  running += 1;
72
73
  try {
73
- return direct ? await executeCommand(text) : await asClient(client, () => executeCommand(text));
74
+ return direct ? await executeCommand(text) : await asClient(client, () => executeCommand(text), run);
74
75
  } finally {
75
76
  running -= 1;
76
77
  }
@@ -200,7 +201,9 @@ function enqueue(line) {
200
201
  // client names the sender: its commands show as [server:<client>] and use its own selected tab.
201
202
  // vars: values for the {{ PW_NAME }} variables in text (from send), filled in to run it; text is shown
202
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.
203
- 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) {
204
207
  let run = text;
205
208
  if (vars) {
206
209
  try { run = expandVariables(text, vars); } catch (error) { return Promise.resolve({ status: 'error', output: `Error: ${error.message}` }); }
@@ -213,6 +216,10 @@ function submit(text, client = null, vars = null) {
213
216
  return new Promise(resolve => {
214
217
  queue = queue.then(async () => {
215
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
+ }
216
223
  // Printed above whatever the user at the prompt is typing, which is
217
224
  // redrawn afterwards rather than broken up.
218
225
  if (process.stdout.isTTY) { readline.clearLine(process.stdout, 0); readline.cursorTo(process.stdout, 0); }
@@ -229,9 +236,21 @@ function submit(text, client = null, vars = null) {
229
236
  let result;
230
237
  const flight = { text, resolve, answered: false };
231
238
  inFlight = flight;
232
- 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);
233
252
  if (inFlight === flight) inFlight = null;
234
- 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 });
235
254
  }
236
255
  if (!state.stopping) prompt(true);
237
256
  }).catch(e => { out.error(`Error: ${e.message}`); resolve({ status: 'error', output: e.message }); });
package/lib/send.js CHANGED
@@ -6,7 +6,7 @@ const fs = require('fs');
6
6
  const crypto = require('crypto');
7
7
  const { execFileSync } = require('child_process');
8
8
  const client = require('./client');
9
- const { shellWords, variablesIn } = require('./syntax');
9
+ const { shellWords, variablesIn, SCREENSHOT_FILE } = require('./syntax');
10
10
 
11
11
  // The prompt, after the modes on in the selected tab if any: (watch network:off) pw[serve]>
12
12
  const PROMPT = /^(?:\([^()]*\) )?pw(\[serve\])?>\s*$/;
@@ -93,6 +93,15 @@ function doubleQuote(word) {
93
93
  return `"${word.replace(/[\\"]/g, '\\$&').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t')}"`;
94
94
  }
95
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
+
96
105
  // A command's words as send was given them, made into the line the REPL reads: quoted again where the
97
106
  // shell had kept a word whole, and with the files it reads or writes made absolute from the sender's
98
107
  // folder, since the REPL's may be another.
@@ -104,7 +113,7 @@ function commandLine(words) {
104
113
  const { SELECTOR_FIRST } = require('./syntax');
105
114
  // So does playwright-cli's storage key (localstorage-set "my key" v).
106
115
  // And record's file, which may have spaces in it, and wait's text, whose last word would otherwise be
107
- // taken as the seconds when it is a number (wait text 'Showing 1').
116
+ // taken as the time when it is a number (wait text 'Showing 1').
108
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]));
109
118
  // An empty word (fill #name "") is the empty value.
110
119
  // upload's files are read by the REPL, whose folder may not be this one.
@@ -123,13 +132,15 @@ function commandLine(words) {
123
132
  if (words[0] === 'profile' && words[1] === 'save' && words.length > 2) resolved = ['profile', 'save', resolve(words.slice(2).join(' '))];
124
133
  if (words[0] === 'watch' && words[1] === 'save' && words.length > 2) resolved = ['watch', 'save', resolve(words.slice(2).join(' '))];
125
134
  // So does the file eval --file reads.
126
- if (words[0] === 'eval' && /^--file(?:=|$)/.test(words[1] || '')) {
135
+ const at = evalFileAt(words);
136
+ if (at > 0) {
127
137
  // As one word, --file=<path>, which is quoted below when the path has spaces.
128
- const [file, rest] = words[1] === '--file' ? [words[2], words.slice(3)] : [words[1].slice(7), words.slice(2)];
129
- if (file) resolved = ['eval', `--file=${resolve(file)}`, ...rest];
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];
130
140
  }
131
141
  if (words[0] === 'screenshot') {
132
- resolved = words.map((w, n) => (w.startsWith('--filename=') && w.length > 11 ? `--filename=${resolve(w.slice(11))}` : words[n - 1] === '--filename' ? resolve(w) : w));
142
+ // A file as a word of its own is sent as --filename, so a space in it stays in it.
143
+ resolved = words.map((w, n) => (w.startsWith('--filename=') && w.length > 11 ? `--filename=${resolve(w.slice(11))}` : words[n - 1] === '--filename' ? resolve(w) : n && !w.startsWith('-') && SCREENSHOT_FILE.test(w) ? `--filename=${resolve(w)}` : w));
133
144
  }
134
145
  const quoted = requote ? resolved.map(w => (w === '' || /[\s"']/.test(w) ? doubleQuote(w) : w)) : resolved;
135
146
  // An option's value the shell kept whole stays whole too: --device='iPhone 15'.
@@ -167,7 +178,7 @@ async function sendFile(options) {
167
178
  // Read as the prompt reads it; a file it reads or writes is the sender's, as on send's command line.
168
179
  const words = shellWords(line);
169
180
  // eval's own line keeps its quotes; only eval --file names a file.
170
- const command = PATHS.test(words[0]) || (words[0] === 'eval' && /^--file(?:=|$)/.test(words[1] || '')) ? commandLine(words) : line;
181
+ const command = PATHS.test(words[0]) || evalFileAt(words) > 0 ? commandLine(words) : line;
171
182
  const found = options.noVars ? null : variablesFor(command);
172
183
  if (found?.error) { console.error(`pw-repl: stopped at line ${i + 1} of ${options.file} (${line}): ${found.error}`); return 1; }
173
184
  const status = await send({ ...options, file: null, command, vars: found?.vars || null });
@@ -194,7 +205,7 @@ async function send(options) {
194
205
  if (found?.error) return fail(found.error);
195
206
  options = { ...options, vars: found?.vars || null };
196
207
  }
197
- const timeoutMs = options.timeout * 1000;
208
+ const timeoutMs = options.timeout;
198
209
  const way = route(options);
199
210
  const name = options.client || process.env.PW_CLIENT || null;
200
211
  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/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
@@ -89,4 +89,7 @@ function expandVariables(line, values) {
89
89
  });
90
90
  }
91
91
 
92
- module.exports = { SELECTOR_FIRST, REF, toSelector, unescapeDouble, unquote, shellWords, splitSelector, keyName, variablesIn, expandVariables };
92
+ // A screenshot's file given as a word of its own: a name has neither a / nor an extension.
93
+ const SCREENSHOT_FILE = /\/|\.(?:png|jpe?g)$/i;
94
+
95
+ module.exports = { SELECTOR_FIRST, REF, SCREENSHOT_FILE, toSelector, unescapeDouble, unquote, shellWords, splitSelector, keyName, variablesIn, expandVariables };
package/lib/tabs.js CHANGED
@@ -21,6 +21,12 @@ async function navigating(go) {
21
21
  for (let waited = 0; !find() && waited < 1000; waited += 50) await new Promise(resolve => setTimeout(resolve, 50));
22
22
  const navigation = find();
23
23
  if (!navigation) throw error;
24
+ // Chrome commits its error page just after the request fails: waited for, so info right after reads it.
25
+ // Capped at 1s, for a failure with no error page; a tab already on one matches at once, which info
26
+ // still reads right, from the log.
27
+ if (!stayedPut(navigation) && !/ERR_ABORTED/.test(`${navigation.status} ${navigation.failed}`)) {
28
+ for (let waited = 0; !state.page.url().startsWith('chrome-error://') && waited < 1000; waited += 50) await new Promise(resolve => setTimeout(resolve, 50));
29
+ }
24
30
  throw new Error(notLoaded(navigation));
25
31
  }
26
32
  }
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.`);