pw-repl 0.12.8 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +19 -37
- package/README.md +9 -4
- 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/elements.js +21 -6
- package/lib/help.js +75 -33
- package/lib/inspect.js +95 -38
- 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 +19 -8
- package/lib/server.js +4 -1
- package/lib/state.js +26 -9
- package/lib/syntax.js +4 -1
- package/lib/tabs.js +6 -0
- 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/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 }); });
|
package/lib/send.js
CHANGED
|
@@ -6,7 +6,7 @@ const fs = require('fs');
|
|
|
6
6
|
const crypto = require('crypto');
|
|
7
7
|
const { execFileSync } = require('child_process');
|
|
8
8
|
const client = require('./client');
|
|
9
|
-
const { shellWords, variablesIn } = require('./syntax');
|
|
9
|
+
const { shellWords, variablesIn, SCREENSHOT_FILE } = require('./syntax');
|
|
10
10
|
|
|
11
11
|
// The prompt, after the modes on in the selected tab if any: (watch network:off) pw[serve]>
|
|
12
12
|
const PROMPT = /^(?:\([^()]*\) )?pw(\[serve\])?>\s*$/;
|
|
@@ -93,6 +93,15 @@ function doubleQuote(word) {
|
|
|
93
93
|
return `"${word.replace(/[\\"]/g, '\\$&').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t')}"`;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
// Where eval's --file is, after its --timeout if it has one; -1 when it has none.
|
|
97
|
+
function evalFileAt(words) {
|
|
98
|
+
if (words[0] !== 'eval') return -1;
|
|
99
|
+
let at = 1;
|
|
100
|
+
if (/^--timeout=/.test(words[at] || '')) at += 1;
|
|
101
|
+
else if (words[at] === '--timeout') at += 2;
|
|
102
|
+
return /^--file(?:=|$)/.test(words[at] || '') ? at : -1;
|
|
103
|
+
}
|
|
104
|
+
|
|
96
105
|
// A command's words as send was given them, made into the line the REPL reads: quoted again where the
|
|
97
106
|
// shell had kept a word whole, and with the files it reads or writes made absolute from the sender's
|
|
98
107
|
// folder, since the REPL's may be another.
|
|
@@ -104,7 +113,7 @@ function commandLine(words) {
|
|
|
104
113
|
const { SELECTOR_FIRST } = require('./syntax');
|
|
105
114
|
// So does playwright-cli's storage key (localstorage-set "my key" v).
|
|
106
115
|
// And record's file, which may have spaces in it, and wait's text, whose last word would otherwise be
|
|
107
|
-
// taken as the
|
|
116
|
+
// taken as the time when it is a number (wait text 'Showing 1').
|
|
108
117
|
const requote = words.length > 1 && (SELECTOR_FIRST.has(words[0]) || words[0] === 'drag' || /^(?:local|session)storage-/.test(words[0]) || words[0] === 'record' || words[0] === 'wait' || /^video-(?:start|stop)$/.test(words[0]));
|
|
109
118
|
// An empty word (fill #name "") is the empty value.
|
|
110
119
|
// upload's files are read by the REPL, whose folder may not be this one.
|
|
@@ -123,13 +132,15 @@ function commandLine(words) {
|
|
|
123
132
|
if (words[0] === 'profile' && words[1] === 'save' && words.length > 2) resolved = ['profile', 'save', resolve(words.slice(2).join(' '))];
|
|
124
133
|
if (words[0] === 'watch' && words[1] === 'save' && words.length > 2) resolved = ['watch', 'save', resolve(words.slice(2).join(' '))];
|
|
125
134
|
// So does the file eval --file reads.
|
|
126
|
-
|
|
135
|
+
const at = evalFileAt(words);
|
|
136
|
+
if (at > 0) {
|
|
127
137
|
// As one word, --file=<path>, which is quoted below when the path has spaces.
|
|
128
|
-
const [file, rest] = words[
|
|
129
|
-
if (file) resolved = [
|
|
138
|
+
const [file, rest] = words[at] === '--file' ? [words[at + 1], words.slice(at + 2)] : [words[at].slice(7), words.slice(at + 1)];
|
|
139
|
+
if (file) resolved = [...words.slice(0, at), `--file=${resolve(file)}`, ...rest];
|
|
130
140
|
}
|
|
131
141
|
if (words[0] === 'screenshot') {
|
|
132
|
-
|
|
142
|
+
// A file as a word of its own is sent as --filename, so a space in it stays in it.
|
|
143
|
+
resolved = words.map((w, n) => (w.startsWith('--filename=') && w.length > 11 ? `--filename=${resolve(w.slice(11))}` : words[n - 1] === '--filename' ? resolve(w) : n && !w.startsWith('-') && SCREENSHOT_FILE.test(w) ? `--filename=${resolve(w)}` : w));
|
|
133
144
|
}
|
|
134
145
|
const quoted = requote ? resolved.map(w => (w === '' || /[\s"']/.test(w) ? doubleQuote(w) : w)) : resolved;
|
|
135
146
|
// An option's value the shell kept whole stays whole too: --device='iPhone 15'.
|
|
@@ -167,7 +178,7 @@ async function sendFile(options) {
|
|
|
167
178
|
// Read as the prompt reads it; a file it reads or writes is the sender's, as on send's command line.
|
|
168
179
|
const words = shellWords(line);
|
|
169
180
|
// eval's own line keeps its quotes; only eval --file names a file.
|
|
170
|
-
const command = PATHS.test(words[0]) || (words
|
|
181
|
+
const command = PATHS.test(words[0]) || evalFileAt(words) > 0 ? commandLine(words) : line;
|
|
171
182
|
const found = options.noVars ? null : variablesFor(command);
|
|
172
183
|
if (found?.error) { console.error(`pw-repl: stopped at line ${i + 1} of ${options.file} (${line}): ${found.error}`); return 1; }
|
|
173
184
|
const status = await send({ ...options, file: null, command, vars: found?.vars || null });
|
|
@@ -194,7 +205,7 @@ async function send(options) {
|
|
|
194
205
|
if (found?.error) return fail(found.error);
|
|
195
206
|
options = { ...options, vars: found?.vars || null };
|
|
196
207
|
}
|
|
197
|
-
const timeoutMs = options.timeout
|
|
208
|
+
const timeoutMs = options.timeout;
|
|
198
209
|
const way = route(options);
|
|
199
210
|
const name = options.client || process.env.PW_CLIENT || null;
|
|
200
211
|
if (name && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$/.test(name)) return fail('a client name is 1-40 letters, digits, dots, dashes and underscores');
|
package/lib/server.js
CHANGED
|
@@ -56,7 +56,10 @@ function handle(req, res) {
|
|
|
56
56
|
}
|
|
57
57
|
// Bound before the browser is reached, so where and send find it while it starts.
|
|
58
58
|
if (!state.serving) return send(res, 503, { status: 'error', starting: true, output: `The REPL is starting (${state.startingStep}); retry shortly` });
|
|
59
|
-
|
|
59
|
+
// The sender gave up (send -t) once its connection closes before the answer is written.
|
|
60
|
+
const gone = new AbortController();
|
|
61
|
+
res.once('close', () => { if (!res.writableEnded) gone.abort(); });
|
|
62
|
+
const result = await runner.submit(command.trim(), client || null, vars || null, gone.signal);
|
|
60
63
|
if (!result.uncertain) return send(res, 200, { status: result.status, output: result.output });
|
|
61
64
|
// unconfirmed: the command may or may not have done what it was sent to do.
|
|
62
65
|
send(res, 200, { status: result.status, output: result.output, unconfirmed: true });
|
package/lib/state.js
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
// Session state shared by the entry point, the runner, and the commands, plus
|
|
2
2
|
// the shutdown that ends the session.
|
|
3
|
+
const { AsyncLocalStorage } = require('async_hooks');
|
|
3
4
|
const out = require('./output');
|
|
4
5
|
|
|
5
6
|
const SHUTDOWN_TIMEOUT = 5000;
|
|
6
7
|
|
|
7
8
|
const state = {
|
|
8
9
|
browser: null,
|
|
9
|
-
// The client whose command runs now: null for the prompt and senders without
|
|
10
|
-
// a name, who share one selected tab, as before clients had names.
|
|
11
|
-
client: null,
|
|
12
10
|
rl: null,
|
|
13
11
|
promptBase: 'pw> ',
|
|
14
12
|
// The browser the REPL is connected to, and whether --launch started it.
|
|
@@ -36,18 +34,31 @@ function clientRecord(name = state.client) {
|
|
|
36
34
|
return clients.get(name);
|
|
37
35
|
}
|
|
38
36
|
|
|
37
|
+
// The command running in this async context: its client, and whether its sender gave up on it.
|
|
38
|
+
// Code outside any command (a browser event, quit and dialog, which skip the queue) goes by the
|
|
39
|
+
// queued command running now.
|
|
40
|
+
const running = new AsyncLocalStorage();
|
|
41
|
+
let queued = null;
|
|
42
|
+
|
|
43
|
+
// The client whose command runs now: null for the prompt and senders without a name, who share one
|
|
44
|
+
// selected tab, as before clients had names. Each command keeps its own for its whole life, so one
|
|
45
|
+
// its sender gave up on, still running, never acts as the command after it.
|
|
46
|
+
Object.defineProperty(state, 'client', { enumerable: true, get: () => (running.getStore() ?? queued)?.client ?? null });
|
|
47
|
+
|
|
48
|
+
out.onCancelled(() => running.getStore()?.cancelled === true);
|
|
49
|
+
|
|
39
50
|
for (const key of Object.keys(PER_CLIENT)) {
|
|
40
51
|
Object.defineProperty(state, key, { enumerable: true, get: () => clientRecord()[key], set: value => { clientRecord()[key] = value; } });
|
|
41
52
|
}
|
|
42
53
|
|
|
43
|
-
// Runs fn as the named client.
|
|
44
|
-
async function asClient(name, fn) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
try { return await fn(); } finally { state.client = previous; }
|
|
54
|
+
// Runs fn as the named client. run is the command's record, whose cancelled the runner sets.
|
|
55
|
+
async function asClient(name, fn, run = {}) {
|
|
56
|
+
queued = Object.assign(run, { client: name || null });
|
|
57
|
+
try { return await running.run(run, fn); } finally { if (queued === run) queued = null; }
|
|
48
58
|
}
|
|
49
59
|
|
|
50
60
|
const cleanups = [];
|
|
61
|
+
const endings = [];
|
|
51
62
|
const exitWaits = [];
|
|
52
63
|
let shutdownPromise = null;
|
|
53
64
|
|
|
@@ -65,6 +76,11 @@ function onShutdown(fn) {
|
|
|
65
76
|
cleanups.push(fn);
|
|
66
77
|
}
|
|
67
78
|
|
|
79
|
+
// Run once every cleanup has finished, before the browser is closed.
|
|
80
|
+
function onShutdownEnd(fn) {
|
|
81
|
+
endings.push(fn);
|
|
82
|
+
}
|
|
83
|
+
|
|
68
84
|
// Things to finish before the process exits, e.g. answers still being sent.
|
|
69
85
|
function onBeforeExit(fn) {
|
|
70
86
|
exitWaits.push(fn);
|
|
@@ -80,6 +96,7 @@ async function shutdown() {
|
|
|
80
96
|
shutdownPromise = (async () => {
|
|
81
97
|
// Some undo what the REPL changed in the browser, so they finish first.
|
|
82
98
|
await withTimeout(Promise.all(cleanups.map(async fn => fn())), 'Cleanup').catch(() => {});
|
|
99
|
+
for (const fn of endings) try { await fn(); } catch {}
|
|
83
100
|
if (state.browser) {
|
|
84
101
|
try { await withTimeout(state.browser.close(), 'Chromium shutdown'); }
|
|
85
102
|
catch (error) { state.shutdownFailed = true; process.exitCode = 1; out.error(`Could not confirm Chromium shutdown: ${error.message}`); }
|
|
@@ -88,4 +105,4 @@ async function shutdown() {
|
|
|
88
105
|
return shutdownPromise;
|
|
89
106
|
}
|
|
90
107
|
|
|
91
|
-
module.exports = { state, clientRecord, asClient, withTimeout, onShutdown, onBeforeExit, beforeExit, shutdown, SHUTDOWN_TIMEOUT };
|
|
108
|
+
module.exports = { state, clientRecord, asClient, withTimeout, onShutdown, onShutdownEnd, onBeforeExit, beforeExit, shutdown, SHUTDOWN_TIMEOUT };
|
package/lib/syntax.js
CHANGED
|
@@ -89,4 +89,7 @@ function expandVariables(line, values) {
|
|
|
89
89
|
});
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
|
|
92
|
+
// A screenshot's file given as a word of its own: a name has neither a / nor an extension.
|
|
93
|
+
const SCREENSHOT_FILE = /\/|\.(?:png|jpe?g)$/i;
|
|
94
|
+
|
|
95
|
+
module.exports = { SELECTOR_FIRST, REF, SCREENSHOT_FILE, toSelector, unescapeDouble, unquote, shellWords, splitSelector, keyName, variablesIn, expandVariables };
|
package/lib/tabs.js
CHANGED
|
@@ -21,6 +21,12 @@ async function navigating(go) {
|
|
|
21
21
|
for (let waited = 0; !find() && waited < 1000; waited += 50) await new Promise(resolve => setTimeout(resolve, 50));
|
|
22
22
|
const navigation = find();
|
|
23
23
|
if (!navigation) throw error;
|
|
24
|
+
// Chrome commits its error page just after the request fails: waited for, so info right after reads it.
|
|
25
|
+
// Capped at 1s, for a failure with no error page; a tab already on one matches at once, which info
|
|
26
|
+
// still reads right, from the log.
|
|
27
|
+
if (!stayedPut(navigation) && !/ERR_ABORTED/.test(`${navigation.status} ${navigation.failed}`)) {
|
|
28
|
+
for (let waited = 0; !state.page.url().startsWith('chrome-error://') && waited < 1000; waited += 50) await new Promise(resolve => setTimeout(resolve, 50));
|
|
29
|
+
}
|
|
24
30
|
throw new Error(notLoaded(navigation));
|
|
25
31
|
}
|
|
26
32
|
}
|
package/lib/toast.js
CHANGED
|
@@ -7,6 +7,7 @@ const { unquote } = require('./syntax');
|
|
|
7
7
|
const { takeOptions } = require('./cli-names');
|
|
8
8
|
const { inOverlay, keepAcrossPages, forget } = require('./overlay');
|
|
9
9
|
const { recordingOf, beforeRecordingEnds } = require('./record');
|
|
10
|
+
const { parseDuration } = require('./util');
|
|
10
11
|
|
|
11
12
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
12
13
|
|
|
@@ -81,12 +82,6 @@ async function toastOff(p, fade = true) {
|
|
|
81
82
|
beforeRecordingEnds(p => (p && !p.isClosed() ? waitToRead(p) : null));
|
|
82
83
|
|
|
83
84
|
// 3s, 1.5s, 800ms, or a number of milliseconds.
|
|
84
|
-
function parseDuration(value) {
|
|
85
|
-
const match = /^(\d+(?:\.\d+)?)(ms|s)?$/.exec(value || '');
|
|
86
|
-
if (!match) return null;
|
|
87
|
-
return Math.round(Number(match[1]) * (match[2] === 's' ? 1000 : 1));
|
|
88
|
-
}
|
|
89
|
-
|
|
90
85
|
onShutdown(async () => {
|
|
91
86
|
if (!state.browser || state.connectionLost) return;
|
|
92
87
|
const pages = state.browser.contexts().flatMap(c => c.pages()).filter(p => !p.isClosed());
|
package/lib/util.js
CHANGED
|
@@ -20,6 +20,28 @@ function hints(pairs) {
|
|
|
20
20
|
return `\n${pairs.map(([command, what]) => ` ${command.padEnd(width)} ${what}`).join('\n')}`;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// A time: milliseconds, as Playwright and JavaScript count it, or seconds with an s (1500, 1.5s, 1500ms).
|
|
24
|
+
const TIME = /^(\d+(?:\.\d+)?)(ms|s)?$/;
|
|
25
|
+
|
|
26
|
+
function parseDuration(value) {
|
|
27
|
+
const match = TIME.exec(value || '');
|
|
28
|
+
if (!match) return null;
|
|
29
|
+
return Math.round(Number(match[1]) * (match[2] === 's' ? 1000 : 1));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// How a time reads back: 1.5s, or 250ms under a second.
|
|
33
|
+
function durationText(ms) {
|
|
34
|
+
return ms >= 1000 ? `${Number((ms / 1000).toFixed(3))}s` : `${ms}ms`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A time that is a second or more, where a smaller one is most likely seconds written as before.
|
|
38
|
+
function longDuration(value, what) {
|
|
39
|
+
const ms = parseDuration(value);
|
|
40
|
+
if (ms === null) throw new Error(`${what} is a time, in milliseconds or with s for seconds: 30000 or 30s (got ${value})`);
|
|
41
|
+
if (ms < 1000) throw new Error(`${what} is in milliseconds, 1000 or more: ${value} is ${durationText(ms)}; ${value}s for seconds`);
|
|
42
|
+
return ms;
|
|
43
|
+
}
|
|
44
|
+
|
|
23
45
|
function clipText(text) {
|
|
24
46
|
return text.length > MAX_EVENT_TEXT ? `${text.slice(0, MAX_EVENT_TEXT)}…` : text;
|
|
25
47
|
}
|
|
@@ -56,6 +78,8 @@ function clock(t) {
|
|
|
56
78
|
// by itself unseen: stop prints those lines.
|
|
57
79
|
const STOPPED_RECORDING = 'The REPL stopped, which ended a recording';
|
|
58
80
|
const ENDED_UNSEEN = 'A recording ended before the REPL stopped, unseen since';
|
|
81
|
+
// Each line stop prints of the files a client left.
|
|
82
|
+
const FILES_LEFT = 'Files left';
|
|
59
83
|
|
|
60
84
|
// Where Playwright installs its browsers and ffmpeg, worked out as playwright-core does it: a cache of
|
|
61
85
|
// each platform's own (~/Library/Caches on macOS), unless PLAYWRIGHT_BROWSERS_PATH says otherwise.
|
|
@@ -69,4 +93,4 @@ function playwrightBrowsersDir() {
|
|
|
69
93
|
return path.join(cache, 'ms-playwright');
|
|
70
94
|
}
|
|
71
95
|
|
|
72
|
-
module.exports = { BUSY_PAGE, STOPPED_RECORDING, ENDED_UNSEEN, COMMAND_TIMEOUT, MAX_EVENT_TEXT, NO_TAB, hints, clipText, numbers, regexOf, patternText, clock, playwrightBrowsersDir };
|
|
96
|
+
module.exports = { TIME, parseDuration, durationText, longDuration, BUSY_PAGE, STOPPED_RECORDING, ENDED_UNSEEN, FILES_LEFT, COMMAND_TIMEOUT, MAX_EVENT_TEXT, NO_TAB, hints, clipText, numbers, regexOf, patternText, clock, playwrightBrowsersDir };
|
package/lib/watch.js
CHANGED
|
@@ -4,6 +4,7 @@ const out = require('./output');
|
|
|
4
4
|
const { printOutput } = out;
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
|
+
const { noteWritten } = require('./written');
|
|
7
8
|
const { clipText, clock, hints, NO_TAB } = require('./util');
|
|
8
9
|
const { tabState } = require('./tabstate');
|
|
9
10
|
const { keptSession, keepDrawing } = require('./cdp');
|
|
@@ -22,8 +23,8 @@ const PW_SETTINGS = ['PW_CDP_URL', 'PW_CHROME', 'PW_CLIENT', 'PW_ENDPOINT', 'PW_
|
|
|
22
23
|
'PW_SOCKET', 'PW_START_URL', 'PW_TEST_CHROME', 'PW_TMUX_SESSION'];
|
|
23
24
|
const PAGE_ACTIONS = new Set(['click', 'check', 'uncheck', 'select', 'fill', 'type', 'press', 'submit']);
|
|
24
25
|
const WATCH_BINDING = '__pwReplWatch';
|
|
25
|
-
//
|
|
26
|
-
const ROUTE_WAIT =
|
|
26
|
+
// How long a saved step waits for its element after the app's own routing (watch save).
|
|
27
|
+
const ROUTE_WAIT = '30s';
|
|
27
28
|
|
|
28
29
|
// Runs in the page. Describes elements the way snapshot does: role and name.
|
|
29
30
|
const WATCH_SCRIPT = `(() => {
|
|
@@ -811,6 +812,7 @@ const commands = {
|
|
|
811
812
|
if (variables.length) header.push(`# It needs ${variables.join(', ')} set in the environment: what was typed into ${where}, not saved here`);
|
|
812
813
|
// It holds what was typed, so it is the user's own to read.
|
|
813
814
|
fs.writeFileSync(file, `${[...header, ...lines].join('\n')}\n`, { mode: 0o600, flag: 'wx' });
|
|
815
|
+
noteWritten(file, 'watch file');
|
|
814
816
|
out.log(`Saved ${watch.events.length} step${watch.events.length === 1 ? '' : 's'} to ${file}${skipped ? `; ${skipped} could not be written as commands, and are comments there saying why` : ''}.`);
|
|
815
817
|
if (variables.length) out.log(`What was typed into ${where} is a variable there, filled in from the environment: ${variables.map(name => `{{ ${name} }}`).join(', ')}.`);
|
|
816
818
|
out.log(`${variables.map(name => `${name}=... `).join('')}pw-repl send --file ${file} runs them.`);
|