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/inspect.js CHANGED
@@ -6,15 +6,16 @@ const out = require('./output');
6
6
  const { printOutput } = out;
7
7
  const { devices } = require('playwright-core');
8
8
  const { toSelector, REF, unquote } = require('./syntax');
9
- const { regexOf, COMMAND_TIMEOUT, BUSY_PAGE } = require('./util');
9
+ const { regexOf, COMMAND_TIMEOUT, BUSY_PAGE, TIME, parseDuration, durationText } = require('./util');
10
10
  const { tabState } = require('./tabstate');
11
- const { keptSession } = require('./cdp');
11
+ const { noteWritten } = require('./written');
12
+ const { keptSession, keepDrawing } = require('./cdp');
12
13
  const { CDP_REFUSED } = require('./dialogs');
13
14
  const { onElement, soleSelector } = require('./elements');
14
15
  const { waitForLoad, waitForRequest } = require('./requestlog');
15
16
  const { deviceMetrics, viewportMetrics, reapplyMetrics } = require('./emulation');
16
17
 
17
- const SCREENSHOT_DELAY_MAX = 60;
18
+ const SCREENSHOT_DELAY_MAX = 60000;
18
19
  // What inputs lists: native form controls, and elements that are one by their ARIA role.
19
20
  const INPUTS = ['input', 'select', 'textarea', ...['checkbox', 'radio', 'switch', 'combobox', 'listbox', 'textbox', 'searchbox', 'slider', 'spinbutton'].map(role => `[role=${role}]`)].join(', ');
20
21
 
@@ -111,15 +112,14 @@ function compactSnapshot(text) {
111
112
  return lines.join('\n');
112
113
  }
113
114
 
114
- const WAIT_DEFAULT = 10;
115
- const WAIT_MAX = 120;
115
+ const WAIT_DEFAULT = 10000;
116
116
 
117
117
  // Gone once no match is visible, whether it was removed or hidden.
118
118
  async function waitGone(locator, what, timeout) {
119
119
  try {
120
120
  await locator.filter({ visible: true }).first().waitFor({ state: 'detached', timeout });
121
121
  } catch (error) {
122
- if (/Timeout \d+ms exceeded/.test(error.message)) throw new Error(`Still visible after ${timeout / 1000}s: ${what}`);
122
+ if (/Timeout \d+ms exceeded/.test(error.message)) throw new Error(`Still visible after ${durationText(timeout)}: ${what}`);
123
123
  throw error;
124
124
  }
125
125
  out.log(`Gone: ${what}`);
@@ -127,9 +127,9 @@ async function waitGone(locator, what, timeout) {
127
127
 
128
128
  const SNAPSHOT_HINT_LINES = 60;
129
129
 
130
- function evaluated(promise) {
131
- return withTimeout(promise, 'Page evaluation', COMMAND_TIMEOUT).catch(error => {
132
- if (error.message === 'Page evaluation timed out') error.message += ` after ${COMMAND_TIMEOUT / 1000}s; ${BUSY_PAGE}`;
130
+ function evaluated(promise, timeout = COMMAND_TIMEOUT) {
131
+ return withTimeout(promise, 'Page evaluation', timeout).catch(error => {
132
+ if (error.message === 'Page evaluation timed out') error.message += ` after ${durationText(timeout)}: eval --timeout=<time> waits longer (and send -t with it), but ${BUSY_PAGE}`;
133
133
  throw error;
134
134
  });
135
135
  }
@@ -316,7 +316,7 @@ const commands = {
316
316
  },
317
317
 
318
318
  async screenshot(args) {
319
- const usage = 'Usage: screenshot [<ref>] [--full] [--delay|-d <seconds>] [name | --filename=<file>]\nNames may contain letters, digits, underscores, and hyphens.';
319
+ const usage = 'Usage: screenshot [<ref>] [--full] [--delay|-d <time>] [name | --filename=<file>]\nNames may contain letters, digits, underscores, and hyphens.';
320
320
  // The file first: a path may be quoted, with spaces in it.
321
321
  let file = null;
322
322
  const rest = (args || '').replace(/(?:^|\s)--filename(?:=|\s+)("(?:[^"\\]|\\.)*"|'[^']*'|\S+)/, (_, value) => { file = unquote(value); return ' '; });
@@ -334,9 +334,9 @@ const commands = {
334
334
  if (token === '--delay' || token === '-d') {
335
335
  const value = tokens[++i];
336
336
  // Capped low on purpose: a timed shot is human-in-the-loop, and a typo
337
- // like `-d 300` would otherwise park the serialized command queue.
338
- if (!value || !/^\d+$/.test(value) || Number(value) > SCREENSHOT_DELAY_MAX) throw new Error(`Usage: screenshot --delay <seconds> (maximum ${SCREENSHOT_DELAY_MAX})`);
339
- delay = Number(value);
337
+ // like `-d 300s` would otherwise park the serialized command queue.
338
+ delay = parseDuration(value);
339
+ if (delay === null || delay > SCREENSHOT_DELAY_MAX) throw new Error(`Usage: screenshot --delay <time> (ms, or 5s; at most ${durationText(SCREENSHOT_DELAY_MAX)})`);
340
340
  continue;
341
341
  }
342
342
  names.push(token);
@@ -345,9 +345,11 @@ const commands = {
345
345
  const name = names[0] || null;
346
346
  // Counted down out loud so a watcher can time a hover or menu state, and so
347
347
  // the wait is distinguishable from a hung command in a tmux capture.
348
- for (let remaining = delay; remaining > 0; remaining--) {
349
- out.log(`${remaining}...`);
350
- await state.page.waitForTimeout(1000);
348
+ for (let left = delay; left > 0;) {
349
+ out.log(`${Math.ceil(left / 1000)}...`);
350
+ const step = left % 1000 || 1000;
351
+ await state.page.waitForTimeout(step);
352
+ left -= step;
351
353
  }
352
354
  // Named after the countdown so a default filename timestamps the capture.
353
355
  // A file given is relative to the REPL's folder; pw-repl send makes it the sender's.
@@ -388,6 +390,7 @@ const commands = {
388
390
  if (error.code === 'EEXIST') throw new Error(`Screenshot already exists: ${filepath}`);
389
391
  throw error;
390
392
  }
393
+ noteWritten(filepath, 'screenshot');
391
394
  out.log(`Saved: ${filepath}`);
392
395
  },
393
396
 
@@ -433,22 +436,22 @@ const commands = {
433
436
  },
434
437
 
435
438
  async wait(args) {
436
- const usage = 'Usage: wait <selector> | wait text <text> | wait request <url-part|glob> | wait load, with optional [seconds]; --gone for a selector or text';
439
+ const usage = 'Usage: wait <selector> | wait text <text> | wait request <url-part|glob> | wait load, with an optional [time] (ms, or 30s); --gone for a selector or text';
437
440
  let tokens = (args || '').trim().split(/\s+/).filter(Boolean);
438
441
  const gone = tokens.includes('--gone');
439
442
  tokens = tokens.filter(t => t !== '--gone');
440
- let seconds = WAIT_DEFAULT;
441
- // A number last is the seconds, unless it is all there is to wait for: wait text 990.
443
+ // A time last is how long, unless it is all there is to wait for: wait text 990.
442
444
  const onlyWhat = (tokens[0] === 'text' || tokens[0] === 'request') && tokens.length === 2;
443
- const tookSeconds = tokens.length > 1 && !onlyWhat && /^\d+$/.test(tokens[tokens.length - 1]);
444
- if (tookSeconds) seconds = Number(tokens.pop());
445
- if (!tokens.length) throw new Error(`${usage} (1-${WAIT_MAX})`);
446
- if (seconds < 1 || seconds > WAIT_MAX) {
447
- // Most likely text that ends in a number, not seconds at all.
448
- const text = tokens[0] === 'text' && !/^["']/.test(tokens[1]) ? `; for text that ends in a number, quote it: wait text "${tokens.slice(1).join(' ')} ${seconds}"` : '';
449
- throw new Error(`wait takes 1-${WAIT_MAX} seconds (got ${seconds})${text}`);
445
+ const tookTime = tokens.length > 1 && !onlyWhat && TIME.test(tokens[tokens.length - 1]);
446
+ const given = tookTime ? tokens.pop() : null;
447
+ const timeout = given ? parseDuration(given) : WAIT_DEFAULT;
448
+ if (!tokens.length) throw new Error(usage);
449
+ if (!timeout) {
450
+ // Most likely text that ends in a number, not a time at all.
451
+ const text = tokens[0] === 'text' && !/^["']/.test(tokens[1]) ? `; for text that ends in a number, quote it: wait text "${tokens.slice(1).join(' ')} ${given}"` : '';
452
+ throw new Error(`wait takes a time over 0 (got ${given})${text}`);
450
453
  }
451
- const timeout = seconds * 1000;
454
+ const within = durationText(timeout);
452
455
  // Said plainly, not as Playwright's call log.
453
456
  const plainly = message => error => { throw /Timeout \d+ms exceeded/.test(error.message) ? new Error(message) : error; };
454
457
  const kind = tokens[0];
@@ -464,37 +467,64 @@ const commands = {
464
467
  }
465
468
  if (gone) return waitGone(state.page.getByText(what), what, timeout);
466
469
  // Text that ends in a number gives it up as the seconds, unless quoted: said when it times out.
467
- const quote = tookSeconds && !/^["']/.test(tokens[1]) ? ` (${seconds} was taken as the seconds: wait text "${what} ${seconds}" waits for that text)` : '';
468
- await state.page.getByText(what).first().waitFor({ state: 'visible', timeout }).catch(plainly(`No visible text matches ${what} within ${seconds}s${quote}`));
470
+ const quote = tookTime && !/^["']/.test(tokens[1]) ? ` (${given} was taken as the time: wait text "${what} ${given}" waits for that text)` : '';
471
+ await state.page.getByText(what).first().waitFor({ state: 'visible', timeout }).catch(plainly(`No visible text matches ${what} within ${within}${quote}`));
469
472
  out.log(`Visible: ${what}`);
470
473
  return;
471
474
  }
472
475
  const selector = toSelector(unquote(tokens.join(' ')));
473
476
  if (gone) return waitGone(state.page.locator(selector), tokens.join(' '), timeout);
474
- await state.page.waitForSelector(selector, { state: 'visible', timeout }).catch(plainly(`No visible element matches ${tokens.join(' ')} within ${seconds}s`));
477
+ await state.page.waitForSelector(selector, { state: 'visible', timeout }).catch(plainly(`No visible element matches ${tokens.join(' ')} within ${within}`));
475
478
  out.log(`Visible: ${tokens.join(' ')}`);
476
479
  },
477
480
 
478
481
  async sleep(args) {
479
- if (!args || !/^\d+$/.test(args) || Number(args) > 3600000) throw new Error('Usage: sleep <ms> (maximum 3600000)');
480
- await state.page.waitForTimeout(Number(args));
482
+ const ms = parseDuration(args);
483
+ if (ms === null) throw new Error('Usage: sleep <time> (ms, or 3s)');
484
+ await state.page.waitForTimeout(ms);
481
485
  // sleep 3 is easily meant as seconds.
482
- out.log(Number(args) < 100 ? `Slept ${args}ms (sleep takes milliseconds: sleep ${args}000 is ${args}s)` : `Slept ${args}ms`);
486
+ out.log(/^\d+$/.test(args) && ms < 100 ? `Slept ${args}ms (sleep takes milliseconds: sleep ${args}s for ${args}s)` : `Slept ${durationText(ms)}`);
483
487
  },
484
488
 
485
489
  async eval(args, all) {
486
490
  if (!args) throw new Error('Usage: eval <js-expression>');
487
- // eval <function> <ref> calls the function with that element, as playwright-cli's eval does.
488
- const onRef = /^([\s\S]+?)\s+((?:f\d+)?e\d+)$/.exec(args.trim());
489
- if (onRef && isFunctionText(unquote(onRef[1]))) {
490
- const selector = toSelector(onRef[2]);
491
+ // eval --timeout=<time> [--file=...] <JavaScript>: how long the page has to answer.
492
+ let timeout = COMMAND_TIMEOUT;
493
+ const timed = /^--timeout(?:=|\s+)(\S+)(?:\s+([\s\S]*))?$/.exec(args.trim());
494
+ if (/^--timeout(?:[=\s]|$)/.test(args.trim())) {
495
+ timeout = parseDuration(timed?.[1]);
496
+ if (!timeout) throw new Error('Usage: eval --timeout=<time> <JavaScript>, a time in ms or with s: eval --timeout=60s ...');
497
+ args = timed[2] || '';
498
+ if (!args.trim()) throw new Error('Usage: eval --timeout=<time> <JavaScript>');
499
+ }
500
+ let ref = null;
501
+ // eval --file=<path> [<ref or selector>]: the file's text is what the line would be, over as many lines as
502
+ // it likes. The rest of the line can only be the element, so any selector goes there, not only a ref.
503
+ const fromFile = /^--file(?:=|\s+)("(?:[^"\\]|\\.)*"|'[^']*'|\S+)(?:\s+([\s\S]*))?$/.exec(args.trim());
504
+ if (/^--file(?:[=\s]|$)/.test(args.trim())) {
505
+ if (!fromFile) throw new Error('Usage: eval --file=<path> [<ref or selector>]: the JavaScript is in the file');
506
+ const file = path.resolve(unquote(fromFile[1]));
507
+ const target = fromFile[2]?.trim() || null;
508
+ try { args = fs.readFileSync(file, 'utf8').trim(); } catch (error) {
509
+ // eval --file=my dir/x.js reads "my" as the file and "dir/x.js" as the element.
510
+ throw new Error(`eval --file: cannot read ${file} (${error.code || error.message})${target ? '; quote a path with spaces' : ''}`);
511
+ }
512
+ if (!args) throw new Error(`eval --file: ${file} is empty`);
513
+ ref = target && unquote(target);
514
+ } else {
515
+ // eval <function> <ref> calls the function with that element, as playwright-cli's eval does.
516
+ const onRef = /^([\s\S]+?)\s+((?:f\d+)?e\d+)$/.exec(args.trim());
517
+ if (onRef && isFunctionText(unquote(onRef[1]))) [args, ref] = [unquote(onRef[1]), onRef[2]];
518
+ }
519
+ if (ref) {
520
+ const selector = toSelector(ref);
491
521
  const el = await onElement(selector, () => state.page.locator(selector).first().elementHandle({ timeout: 5000 }));
492
522
  try {
493
523
  // Made from its text by the debugger, as eval is, so a page's CSP cannot
494
524
  // refuse it, and in the element's own frame, which may be an iframe.
495
- const fn = await evaluated((await el.ownerFrame()).evaluateHandle(unquote(onRef[1])));
525
+ const fn = await evaluated((await el.ownerFrame()).evaluateHandle(args), timeout);
496
526
  try {
497
- printOutput(await el.evaluate((e, f) => f(e), fn), all);
527
+ printOutput(await evaluated(el.evaluate((e, f) => f(e), fn), timeout), all);
498
528
  } finally {
499
529
  await fn.dispose().catch(() => {});
500
530
  }
@@ -514,13 +544,13 @@ const commands = {
514
544
  const call = `const value = (${args}\n); return typeof value === 'function' ? value() : value;`;
515
545
  if (awaits && !single && compiles(AsyncFunction, args)) {
516
546
  const session = await keptSession(state.page);
517
- const { result, exceptionDetails } = await evaluated(session.send('Runtime.evaluate', { expression: args, replMode: true, awaitPromise: true, returnByValue: true }));
547
+ const { result, exceptionDetails } = await evaluated(session.send('Runtime.evaluate', { expression: args, replMode: true, awaitPromise: true, returnByValue: true }), timeout);
518
548
  if (exceptionDetails) throw new Error(exceptionDetails.exception?.description || exceptionDetails.text);
519
549
  printOutput(result.value !== undefined ? result.value : result.description ?? 'undefined', all);
520
550
  return;
521
551
  }
522
552
  const expression = single ? `(${awaits ? 'async ' : ''}() => { ${call} })()` : args;
523
- const value = await evaluated(state.page.evaluate(expression));
553
+ const value = await evaluated(state.page.evaluate(expression), timeout);
524
554
  // An empty string is shown as one, not as no output at all.
525
555
  printOutput(value === '' ? '""' : value, all);
526
556
  // Quotes around the whole of it, as a shell's quoting sent on, make it a string, not code to run:
@@ -530,6 +560,38 @@ const commands = {
530
560
  }
531
561
  },
532
562
 
563
+ // Read through the selected tab, granted clipboard-read for that moment only. A browser with a
564
+ // window reads the system's clipboard, the user's, so only with --shared-clipboard.
565
+ async clipboard(args, all) {
566
+ const words = (args || '').trim().split(/\s+/).filter(Boolean);
567
+ if (words.some(w => w !== '--shared-clipboard')) throw new Error('Usage: clipboard [--shared-clipboard]');
568
+ const browser = await state.browser.newBrowserCDPSession();
569
+ try {
570
+ // Headless says so in its user agent (its product name says Chrome either way).
571
+ const { userAgent } = await browser.send('Browser.getVersion');
572
+ if (!/\bHeadlessChrome\//.test(userAgent) && !words.length) {
573
+ throw new Error('This browser has a window, so its clipboard is the computer\'s: whatever its user last copied, a password too. clipboard --shared-clipboard reads it anyway; ask them first. A headless browser (--launch, without --headed) has a clipboard of its own.');
574
+ }
575
+ const p = state.page;
576
+ const origin = await evaluated(p.evaluate(() => (isSecureContext && location.origin !== 'null' ? location.origin : null)));
577
+ if (!origin) throw new Error(`The selected tab (${p.url()}) is not a secure page, and only one can read the clipboard: https, or http on localhost`);
578
+ const permission = { name: 'clipboard-read' };
579
+ const before = await p.evaluate(() => navigator.permissions.query({ name: 'clipboard-read' }).then(s => s.state));
580
+ // readText also needs the page focused: a tab the REPL uses is told it is (help tab).
581
+ await keepDrawing(p);
582
+ await browser.send('Browser.setPermission', { permission, setting: 'granted', origin });
583
+ try {
584
+ const granted = await p.evaluate(() => navigator.permissions.query({ name: 'clipboard-read' }).then(s => s.state));
585
+ if (granted !== 'granted') throw new Error('The browser did not let the selected tab read the clipboard: a tab in a private window, or in a browser context of its own, does not take the grant');
586
+ printOutput(await evaluated(p.evaluate(() => navigator.clipboard.readText())), all);
587
+ } finally {
588
+ await browser.send('Browser.setPermission', { permission, setting: before, origin }).catch(() => {});
589
+ }
590
+ } finally {
591
+ await browser.detach().catch(() => {});
592
+ }
593
+ },
594
+
533
595
  async cdp(args, all) {
534
596
  const match = /^(\S+)\s+([\s\S]+)$/.exec(args || '');
535
597
  if (!match) throw new Error('Usage: cdp <method> <JSON object>');
package/lib/launch.js CHANGED
@@ -139,7 +139,9 @@ async function launch({ headed = false, extraArgs = [] }) {
139
139
  const onExit = () => { if (group) killGroup(group); try { removeProfile(); } catch {} };
140
140
  process.on('exit', onExit);
141
141
  const onSpawn = proc => { group = proc.pid; };
142
- const ours = [...(headed ? [] : ['--headless=new']), '--remote-debugging-port=0', ...(given ? [] : [`--user-data-dir=${profile}`]),
142
+ // A laptop's window, rather than Chromium's headless 800x600 or so, unless the flags after -- size it.
143
+ const sized = extraArgs.some(a => a.startsWith('--window-size=')) ? [] : ['--window-size=1280,800'];
144
+ const ours = [...(headed ? [] : ['--headless=new']), ...sized, '--remote-debugging-port=0', ...(given ? [] : [`--user-data-dir=${profile}`]),
143
145
  '--no-first-run', '--no-default-browser-check', ...(smallShm() ? ['--disable-dev-shm-usage'] : [])];
144
146
  // A profile already used has tabs to restore; a blank tab of its own each time would pile up there.
145
147
  // Local State is written at its root on the first run, whichever --profile-directory is used.
package/lib/output.js CHANGED
@@ -4,14 +4,21 @@ const OUTPUT_LIMIT = 12000;
4
4
 
5
5
  let sink = null;
6
6
 
7
+ // A cancelled command still running prints to the pane only, never into the next command's answer.
8
+ let cancelled = () => false;
9
+
10
+ function onCancelled(fn) {
11
+ cancelled = fn;
12
+ }
13
+
7
14
  function log(text) {
8
15
  console.log(text);
9
- if (sink) sink.push(String(text));
16
+ if (sink && !cancelled()) sink.push(String(text));
10
17
  }
11
18
 
12
19
  function error(text) {
13
20
  console.error(text);
14
- if (sink) sink.push(String(text));
21
+ if (sink && !cancelled()) sink.push(String(text));
15
22
  }
16
23
 
17
24
  // Commands run one at a time, so a single sink is enough. Lines printed by
@@ -86,4 +93,4 @@ function printOutput(value, all = false, footer = '') {
86
93
  log(footer ? `${text}\n${footer}` : text);
87
94
  }
88
95
 
89
- module.exports = { OUTPUT_LIMIT, log, error, collect, take, notice, aside, onIdlePrint, onSelectedTab, printOutput };
96
+ module.exports = { OUTPUT_LIMIT, log, error, collect, take, notice, aside, onIdlePrint, onSelectedTab, onCancelled, printOutput };
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
@@ -391,7 +393,7 @@ function videoTime(rec) {
391
393
  // What a step is, for its line: the command and what it acts on, never what it types or chooses, the
392
394
  // code it runs or the URL it goes to, which can hold a password or a token. A steps file goes where the
393
395
  // video goes, and the video shows a password as dots.
394
- const WHOLE = new Set(['click', 'dblclick', 'hover', 'check', 'uncheck', 'mousemove', 'mouseclick', 'mousedown', 'mouseup', 'mousewheel']);
396
+ const WHOLE = new Set(['click', 'dblclick', 'hover', 'drag', 'check', 'uncheck', 'mousemove', 'mouseclick', 'mousedblclick', 'mousedown', 'mouseup', 'mousewheel']);
395
397
  const TARGET_FIRST = new Set(['fill', 'type', 'select', 'press', 'upload']);
396
398
  function stepLabel(cmd, args) {
397
399
  if (WHOLE.has(cmd)) {
@@ -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);