pw-repl 0.12.7 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +13 -7
- package/README.md +76 -74
- package/bin/pw-repl.js +65 -40
- package/lib/background.js +2 -2
- package/lib/capture.js +11 -12
- package/lib/cli-names.js +4 -4
- package/lib/commands.js +3 -0
- package/lib/elements.js +115 -26
- package/lib/emulation.js +2 -1
- package/lib/help.js +166 -73
- package/lib/inspect.js +103 -41
- package/lib/launch.js +3 -1
- package/lib/output.js +10 -3
- package/lib/profile.js +16 -14
- package/lib/record.js +18 -14
- package/lib/routes.js +8 -9
- package/lib/runner.js +39 -9
- package/lib/send.js +28 -6
- package/lib/server.js +4 -1
- package/lib/start.js +7 -2
- package/lib/state.js +26 -9
- package/lib/syntax.js +11 -5
- package/lib/toast.js +1 -6
- package/lib/util.js +25 -1
- package/lib/watch.js +4 -2
- package/lib/written.js +67 -0
- package/package.json +1 -1
- package/skill/SKILL.md +39 -44
package/lib/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 {
|
|
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 =
|
|
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 =
|
|
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
|
|
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',
|
|
132
|
-
if (error.message === 'Page evaluation timed out') error.message += ` after ${
|
|
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 <
|
|
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
|
|
338
|
-
|
|
339
|
-
delay
|
|
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
|
|
349
|
-
out.log(`${
|
|
350
|
-
|
|
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 [
|
|
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
|
-
|
|
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
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
if (
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
|
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 =
|
|
468
|
-
await state.page.getByText(what).first().waitFor({ state: 'visible', timeout }).catch(plainly(`No visible text matches ${what} within ${
|
|
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 ${
|
|
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
|
-
|
|
480
|
-
|
|
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(
|
|
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
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 &&
|
|
59
|
-
const
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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 ${
|
|
110
|
+
out.log(`Profiling the selected tab for ${durationText(ms)}...`);
|
|
110
111
|
await new Promise(resolve => {
|
|
111
|
-
const timer = setTimeout(resolve,
|
|
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 [
|
|
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 [
|
|
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
|
-
|
|
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
|
|
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,
|
|
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 ${
|
|
256
|
-
},
|
|
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 (
|
|
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] [
|
|
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
|
|
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
|
|
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,
|
|
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 ${
|
|
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
|
|
81
|
-
if (
|
|
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 ${
|
|
82
|
+
text: `delay ${durationText(ms)}`,
|
|
84
83
|
async handle(r, req, tag, notice) {
|
|
85
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
84
|
+
await new Promise(resolve => setTimeout(resolve, ms));
|
|
86
85
|
await r.continue();
|
|
87
|
-
notice(`Delayed ${
|
|
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 <
|
|
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 <
|
|
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);
|