pw-repl 0.12.8 → 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 +10 -6
- package/README.md +2 -2
- package/bin/pw-repl.js +12 -10
- package/lib/background.js +2 -2
- package/lib/capture.js +11 -12
- package/lib/cli-names.js +1 -1
- package/lib/commands.js +3 -0
- package/lib/help.js +53 -27
- package/lib/inspect.js +82 -37
- package/lib/output.js +10 -3
- package/lib/profile.js +16 -14
- package/lib/record.js +17 -13
- package/lib/routes.js +8 -9
- package/lib/runner.js +28 -9
- package/lib/send.js +16 -6
- package/lib/server.js +4 -1
- package/lib/state.js +26 -9
- 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 +12 -4
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,26 +467,36 @@ 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>');
|
|
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
|
+
}
|
|
487
500
|
let ref = null;
|
|
488
501
|
// eval --file=<path> [<ref or selector>]: the file's text is what the line would be, over as many lines as
|
|
489
502
|
// it likes. The rest of the line can only be the element, so any selector goes there, not only a ref.
|
|
@@ -509,9 +522,9 @@ const commands = {
|
|
|
509
522
|
try {
|
|
510
523
|
// Made from its text by the debugger, as eval is, so a page's CSP cannot
|
|
511
524
|
// refuse it, and in the element's own frame, which may be an iframe.
|
|
512
|
-
const fn = await evaluated((await el.ownerFrame()).evaluateHandle(args));
|
|
525
|
+
const fn = await evaluated((await el.ownerFrame()).evaluateHandle(args), timeout);
|
|
513
526
|
try {
|
|
514
|
-
printOutput(await el.evaluate((e, f) => f(e), fn), all);
|
|
527
|
+
printOutput(await evaluated(el.evaluate((e, f) => f(e), fn), timeout), all);
|
|
515
528
|
} finally {
|
|
516
529
|
await fn.dispose().catch(() => {});
|
|
517
530
|
}
|
|
@@ -531,13 +544,13 @@ const commands = {
|
|
|
531
544
|
const call = `const value = (${args}\n); return typeof value === 'function' ? value() : value;`;
|
|
532
545
|
if (awaits && !single && compiles(AsyncFunction, args)) {
|
|
533
546
|
const session = await keptSession(state.page);
|
|
534
|
-
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);
|
|
535
548
|
if (exceptionDetails) throw new Error(exceptionDetails.exception?.description || exceptionDetails.text);
|
|
536
549
|
printOutput(result.value !== undefined ? result.value : result.description ?? 'undefined', all);
|
|
537
550
|
return;
|
|
538
551
|
}
|
|
539
552
|
const expression = single ? `(${awaits ? 'async ' : ''}() => { ${call} })()` : args;
|
|
540
|
-
const value = await evaluated(state.page.evaluate(expression));
|
|
553
|
+
const value = await evaluated(state.page.evaluate(expression), timeout);
|
|
541
554
|
// An empty string is shown as one, not as no output at all.
|
|
542
555
|
printOutput(value === '' ? '""' : value, all);
|
|
543
556
|
// Quotes around the whole of it, as a shell's quoting sent on, make it a string, not code to run:
|
|
@@ -547,6 +560,38 @@ const commands = {
|
|
|
547
560
|
}
|
|
548
561
|
},
|
|
549
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
|
+
|
|
550
595
|
async cdp(args, all) {
|
|
551
596
|
const match = /^(\S+)\s+([\s\S]+)$/.exec(args || '');
|
|
552
597
|
if (!match) throw new Error('Usage: cdp <method> <JSON object>');
|
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
|
|
@@ -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);
|
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
|
-
//
|
|
70
|
-
|
|
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
|
-
|
|
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
|
|
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 }); });
|