pw-repl 0.6.1 → 0.7.2
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 +2 -2
- package/README.md +3 -2
- package/bin/pw-repl.js +17 -7
- package/lib/background.js +16 -1
- package/lib/cli-names.js +14 -4
- package/lib/commands.js +4 -0
- package/lib/dialogs.js +18 -3
- package/lib/elements.js +68 -3
- package/lib/emulation.js +52 -9
- package/lib/help.js +108 -20
- package/lib/inspect.js +7 -4
- package/lib/modes.js +24 -2
- package/lib/record.js +484 -0
- package/lib/runner.js +8 -3
- package/lib/syntax.js +8 -1
- package/lib/tabs.js +3 -0
- package/lib/tabstate.js +2 -0
- package/lib/util.js +6 -1
- package/package.json +1 -1
- package/skill/SKILL.md +15 -9
package/AGENTS.md
CHANGED
|
@@ -15,8 +15,8 @@ If you hit a limitation or write a workaround, consider adding the capability to
|
|
|
15
15
|
- The modules, one per facility, each with its commands: `lib/tabs.js` (tabs, navigating),
|
|
16
16
|
`lib/elements.js` (choosing a match; the interact and mouse commands), `lib/inspect.js` (reading the
|
|
17
17
|
page, screenshots, eval, CDP, waits), `lib/watch.js`, `lib/requestlog.js` (requests, bodies, console),
|
|
18
|
-
`lib/routes.js`, `lib/network.js`, `lib/emulation.js
|
|
19
|
-
`lib/modes.js`. Beneath them, `lib/tabstate.js` is the one record kept per tab, `lib/cdp.js` the CDP
|
|
18
|
+
`lib/routes.js`, `lib/network.js`, `lib/emulation.js` (and viewport), `lib/capture.js`, `lib/record.js`
|
|
19
|
+
(video, through ffmpeg), `lib/dialogs.js` and `lib/modes.js`. Beneath them, `lib/tabstate.js` is the one record kept per tab, `lib/cdp.js` the CDP
|
|
20
20
|
session kept per tab, and `lib/util.js` small shared helpers. No facility requires `lib/modes.js` or
|
|
21
21
|
`lib/commands.js`, and a unit test fails on a require cycle, which CommonJS would not report.
|
|
22
22
|
- `bin/pw-repl.js` is the command line; `lib/start.js` connects and runs the prompt; `lib/runner.js`
|
package/README.md
CHANGED
|
@@ -98,6 +98,7 @@ the Docker bridge gateway, `ip route | awk '/default/ {print $3}'`).
|
|
|
98
98
|
| show an agent what I do | `watch on`, click around in the browser, then `watch` |
|
|
99
99
|
| see each step as I click | `watch on --live` |
|
|
100
100
|
| record requests and console together | `capture on`, then `capture off` |
|
|
101
|
+
| record a video of the page | `record on`, then `record off` (`help video` for the other ways) |
|
|
101
102
|
| break the backend on purpose | `route <glob> <status> <json>` (fake a response), `route <glob> abort`, `network off` |
|
|
102
103
|
| change or slow an API response | `route <glob> patch <json>`, `route <glob> delay <secs>` |
|
|
103
104
|
| slow the whole network | `network slow` |
|
|
@@ -130,8 +131,8 @@ pw-repl differs, it is because someone else may be using the browser:
|
|
|
130
131
|
- `watch` records what a person does as steps with their requests, not as generated code.
|
|
131
132
|
|
|
132
133
|
For a browser of its own per agent, isolated sessions, traces, video, PDFs or generated test code,
|
|
133
|
-
playwright-cli is the better fit.
|
|
134
|
-
see it, use a screen recorder (`ffmpeg -f x11grab`, macOS `screencapture -v`, OBS).
|
|
134
|
+
playwright-cli is the better fit. `record on` records a tab's page, without the pointer; for the shared
|
|
135
|
+
browser as you see it, use a screen recorder (`ffmpeg -f x11grab`, macOS `screencapture -v`, OBS).
|
|
135
136
|
|
|
136
137
|
## From scripts and agents
|
|
137
138
|
|
package/bin/pw-repl.js
CHANGED
|
@@ -15,7 +15,7 @@ const USAGE = `Usage:
|
|
|
15
15
|
see everything a background REPL does, and type commands to it; Ctrl-C leaves it running
|
|
16
16
|
|
|
17
17
|
pw-repl stop [-e endpoint]
|
|
18
|
-
stop a background REPL
|
|
18
|
+
stop a background REPL, and say what became of each recording it ended
|
|
19
19
|
|
|
20
20
|
pw-repl send [-e endpoint | -s session] [-c client] [-t seconds] <command...>
|
|
21
21
|
run one command in a running REPL and print its output
|
|
@@ -41,16 +41,17 @@ send exit status: 0 ok, 1 command error, 2 completion not confirmed, 64 usage or
|
|
|
41
41
|
The browser must be running with --remote-debugging-port (default http://localhost:9222; set $PW_CDP_URL).
|
|
42
42
|
|
|
43
43
|
--launch is the quick start instead: it starts a Chromium of the REPL's own (headless unless --headed, in
|
|
44
|
-
a temporary profile, on a free port of its own), prints the command it ran,
|
|
45
|
-
(Ctrl-C while it starts too); stop returns once that Chromium has exited.
|
|
46
|
-
--user-data-dir=<dir> among them is used instead of the temporary profile, and
|
|
47
|
-
which one. To set a browser up your own way, start it yourself and use PW_CDP_URL.
|
|
44
|
+
a temporary profile, on a free port of its own, which pw-repl where names), prints the command it ran,
|
|
45
|
+
and stops it with the REPL (Ctrl-C while it starts too); stop returns once that Chromium has exited.
|
|
46
|
+
Flags after -- go to it; a --user-data-dir=<dir> among them is used instead of the temporary profile, and
|
|
47
|
+
kept. PW_CHROME picks which one. To set a browser up your own way, start it yourself and use PW_CDP_URL.
|
|
48
48
|
|
|
49
49
|
In a tmux session named playwright-repl, pw-repl send reaches pw-repl run without the server.`;
|
|
50
50
|
|
|
51
51
|
const REPL_HELP = `The REPL's own commands: help at the pw> prompt, or pw-repl send help here (no REPL needed).
|
|
52
52
|
|
|
53
|
-
pw-repl help <topic | command | --all> shows one part of it
|
|
53
|
+
pw-repl help <topic | command | --all> shows one part of it; pw-repl help video and pw-repl help
|
|
54
|
+
playwright-cli are guides.`;
|
|
54
55
|
|
|
55
56
|
function usage() {
|
|
56
57
|
console.error(USAGE);
|
|
@@ -102,12 +103,21 @@ function parseSendArgs(args, allowCommand) {
|
|
|
102
103
|
// words are joined as they are.
|
|
103
104
|
const { SELECTOR_FIRST } = require('../lib/syntax');
|
|
104
105
|
// So does playwright-cli's storage key (localstorage-set "my key" v).
|
|
105
|
-
|
|
106
|
+
// And record's file, which may have spaces in it.
|
|
107
|
+
const requote = words.length > 1 && (SELECTOR_FIRST.has(words[0]) || /^(?:local|session)storage-/.test(words[0]) || words[0] === 'record' || /^video-(?:start|stop)$/.test(words[0]));
|
|
106
108
|
// An empty word (fill #name "") is the empty value.
|
|
107
109
|
// upload's files are read by the REPL, whose folder may not be this one.
|
|
108
110
|
// So are the files upload reads and screenshot --filename writes.
|
|
109
111
|
const resolve = require('path').resolve;
|
|
110
112
|
let resolved = words[0] === 'upload' ? words.map((w, n) => (n > 1 ? resolve(w) : w)) : words;
|
|
113
|
+
// record on <file> writes it too.
|
|
114
|
+
if (words[0] === 'record' || /^video-(?:start|stop)$/.test(words[0])) {
|
|
115
|
+
resolved = words.map((w, n) => {
|
|
116
|
+
if (n < (words[0] === 'record' ? 2 : 1)) return w;
|
|
117
|
+
if (w.startsWith('--filename=') && w.length > 11) return `--filename=${resolve(w.slice(11))}`;
|
|
118
|
+
return words[n - 1] === '--filename' || !/^(?:\d+|-.*)$/.test(w) ? resolve(w) : w;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
111
121
|
if (words[0] === 'screenshot') {
|
|
112
122
|
resolved = words.map((w, n) => (w.startsWith('--filename=') && w.length > 11 ? `--filename=${resolve(w.slice(11))}` : words[n - 1] === '--filename' ? resolve(w) : w));
|
|
113
123
|
}
|
package/lib/background.js
CHANGED
|
@@ -5,6 +5,7 @@ const path = require('path');
|
|
|
5
5
|
const readline = require('readline');
|
|
6
6
|
const { spawn } = require('child_process');
|
|
7
7
|
const client = require('./client');
|
|
8
|
+
const { STOPPED_RECORDING, ENDED_UNSEEN } = require('./util');
|
|
8
9
|
|
|
9
10
|
const BIN = path.join(__dirname, '..', 'bin', 'pw-repl.js');
|
|
10
11
|
const START_TIMEOUT = 20000;
|
|
@@ -132,12 +133,26 @@ async function stop(options) {
|
|
|
132
133
|
const { pidFile } = filesFor(endpoint);
|
|
133
134
|
const pid = runningPid(pidFile);
|
|
134
135
|
if (!pid) return fail(`no background REPL is serving on ${client.describe(endpoint)}`);
|
|
136
|
+
// What it logs as it stops, from here on, holds what became of each recording it ended.
|
|
137
|
+
const { logFile } = filesFor(endpoint);
|
|
138
|
+
let logFrom = 0;
|
|
139
|
+
try { logFrom = fs.statSync(logFile).size; } catch {}
|
|
135
140
|
// Like Ctrl-C: a command still running is answered, and the socket is removed.
|
|
136
141
|
process.kill(pid, 'SIGTERM');
|
|
137
142
|
const deadline = Date.now() + STOP_TIMEOUT;
|
|
138
143
|
while (alive(pid) && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 100));
|
|
139
144
|
if (alive(pid)) return fail(`the background REPL (pid ${pid}) has not stopped after ${STOP_TIMEOUT / 1000}s`);
|
|
140
|
-
console.log(`Stopped the background REPL (pid ${pid}) on ${client.describe(endpoint)}; its log stays at ${
|
|
145
|
+
console.log(`Stopped the background REPL (pid ${pid}) on ${client.describe(endpoint)}; its log stays at ${logFile}`);
|
|
146
|
+
// The REPL is gone, so record can no longer say where a recording it ended went, or that it was lost.
|
|
147
|
+
let logged = '';
|
|
148
|
+
try {
|
|
149
|
+
const fd = fs.openSync(logFile, 'r');
|
|
150
|
+
const buffer = Buffer.alloc(Math.max(0, fs.fstatSync(fd).size - logFrom));
|
|
151
|
+
fs.readSync(fd, buffer, 0, buffer.length, logFrom);
|
|
152
|
+
fs.closeSync(fd);
|
|
153
|
+
logged = buffer.toString('utf8');
|
|
154
|
+
} catch {}
|
|
155
|
+
for (const line of logged.split('\n')) if (line.startsWith(STOPPED_RECORDING) || line.startsWith(ENDED_UNSEEN)) console.log(line);
|
|
141
156
|
return 0;
|
|
142
157
|
}
|
|
143
158
|
|
package/lib/cli-names.js
CHANGED
|
@@ -31,6 +31,9 @@ const NAMES = {
|
|
|
31
31
|
unroute: [a => (a ? `route off ${a}` : 'route off --all'), 'route off <glob>|--all'],
|
|
32
32
|
request: [a => `body ${a}`, 'body <#>, the response body only'],
|
|
33
33
|
'response-body': [a => `body ${a}`, 'body <#>'],
|
|
34
|
+
// The file is named when recording starts here.
|
|
35
|
+
'video-start': [a => `record on ${a}`.trim(), 'record on [file.webm|file.mp4]; --filename=<file> is the file'],
|
|
36
|
+
'video-stop': [a => `record off ${a}`.trim(), 'record off [file]'],
|
|
34
37
|
};
|
|
35
38
|
|
|
36
39
|
// Storage by eval: a key is the first word, quoted if it has spaces, and a value the rest.
|
|
@@ -66,7 +69,7 @@ const NEAREST = {
|
|
|
66
69
|
list: 'pw-repl where says which REPL is running; one REPL serves everyone using its browser',
|
|
67
70
|
attach: 'pw-repl serve connects to the browser on PW_CDP_URL; pw-repl skill has the rest',
|
|
68
71
|
detach: 'pw-repl stop, or quit at the prompt, disconnects and leaves the browser running',
|
|
69
|
-
drag: 'not here yet', drop: 'not here yet',
|
|
72
|
+
drag: 'not here yet', drop: 'not here yet', 'webmcp-list': 'not here yet', 'webmcp-call': 'not here yet',
|
|
70
73
|
keydown: 'not here yet; press <key> presses and releases one', keyup: 'not here yet; press <key> presses and releases one',
|
|
71
74
|
pdf: 'not here yet; screenshot --full saves the whole page as a PNG',
|
|
72
75
|
'generate-locator': 'not here yet; a snapshot ref (e5) works as a selector in any command',
|
|
@@ -88,8 +91,8 @@ for (const what of ['reduced-motion', 'forced-colors', 'contrast', 'media']) {
|
|
|
88
91
|
NEAREST[`clear-${what}`] = 'not here yet; emulate dark|light sets the color scheme';
|
|
89
92
|
}
|
|
90
93
|
for (const name of ['request-headers', 'response-headers', 'request-body']) NEAREST[name] = 'not here yet; body <#> shows the response body';
|
|
91
|
-
for (const name of ['video-
|
|
92
|
-
NEAREST[name] = 'not here:
|
|
94
|
+
for (const name of ['video-chapter', 'video-show-actions', 'video-hide-actions']) {
|
|
95
|
+
NEAREST[name] = 'not here: record on|off records a video, without chapters or action labels';
|
|
93
96
|
}
|
|
94
97
|
for (const name of ['pause-at', 'resume', 'step-over']) NEAREST[name] = 'not here: they step through a Playwright test';
|
|
95
98
|
|
|
@@ -277,4 +280,11 @@ function table() {
|
|
|
277
280
|
return [...lines, '', 'Not here as such:', ...nearest].join('\n');
|
|
278
281
|
}
|
|
279
282
|
|
|
280
|
-
|
|
283
|
+
// What help says for a playwright-cli name: what it is here, or why it is not.
|
|
284
|
+
function describe(name) {
|
|
285
|
+
if (Object.hasOwn(NAMES, name)) return { as: NAMES[name][1] };
|
|
286
|
+
if (Object.hasOwn(NEAREST, name)) return { nearest: NEAREST[name] };
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
module.exports = { translate, table, takeOptions, describe, NAME_LIST };
|
package/lib/commands.js
CHANGED
|
@@ -11,6 +11,7 @@ const { commands: routesCommands, routesFor } = require('./routes');
|
|
|
11
11
|
const { commands: networkCommands } = require('./network');
|
|
12
12
|
const { commands: emulationCommands, EMULATE_KINDS } = require('./emulation');
|
|
13
13
|
const { commands: captureCommands } = require('./capture');
|
|
14
|
+
const { commands: recordCommands } = require('./record');
|
|
14
15
|
const { commands: inspectCommands } = require('./inspect');
|
|
15
16
|
const { commands: watchCommands, nextTabWanted, waitingForMe, watchNewTab } = require('./watch');
|
|
16
17
|
const { commands: tabsCommands, isOpening } = require('./tabs');
|
|
@@ -27,6 +28,7 @@ const commands = {
|
|
|
27
28
|
...networkCommands,
|
|
28
29
|
...emulationCommands,
|
|
29
30
|
...captureCommands,
|
|
31
|
+
...recordCommands,
|
|
30
32
|
...dialogsCommands,
|
|
31
33
|
...modesCommands,
|
|
32
34
|
|
|
@@ -110,6 +112,8 @@ function complete(line) {
|
|
|
110
112
|
if (command === 'dialog') return match(['accept', 'dismiss']);
|
|
111
113
|
if (command === 'watch') return match(['on', 'off', 'new', '--all']);
|
|
112
114
|
if (command === 'capture') return match(['on', 'off']);
|
|
115
|
+
if (command === 'record') return match(['on', 'off']);
|
|
116
|
+
if (command === 'viewport') return match(['off']);
|
|
113
117
|
if (command === 'route') return match(['off']);
|
|
114
118
|
}
|
|
115
119
|
if (words.length >= 3 && command === 'watch' && words[1] === 'on') return match(['--changes', '--live', '--next-tab'].filter(f => !words.slice(2, -1).includes(f)));
|
package/lib/dialogs.js
CHANGED
|
@@ -5,11 +5,17 @@ const { OUTPUT_LIMIT } = out;
|
|
|
5
5
|
const { unquote } = require('./syntax');
|
|
6
6
|
const { hints } = require('./util');
|
|
7
7
|
const { tabState } = require('./tabstate');
|
|
8
|
+
const { keptSession } = require('./cdp');
|
|
8
9
|
|
|
9
10
|
// page -> its open dialog. The page, and every command that reads it, waits
|
|
10
11
|
// until the dialog is answered, in the browser or with the dialog command.
|
|
11
12
|
const openDialogs = new Map();
|
|
12
13
|
|
|
14
|
+
// cdp's session is new, and Chrome lets only one that saw a dialog open answer it: it would wait out its
|
|
15
|
+
// timeout. Refused ahead of the queue too, which the dialog it means holds up.
|
|
16
|
+
const CDP_ANSWER = /^cdp(?:\s+--all)?\s+Page\.handleJavaScriptDialog(?:\s|$)/;
|
|
17
|
+
const CDP_REFUSED = 'cdp cannot answer a dialog, which Chrome lets only a session that saw it open do; dialog accept or dialog dismiss answers it';
|
|
18
|
+
|
|
13
19
|
function ensureDialogHandler(p) {
|
|
14
20
|
if ('dialogHandled' in tabState(p)) return;
|
|
15
21
|
tabState(p).dialogHandled = true;
|
|
@@ -19,6 +25,12 @@ function ensureDialogHandler(p) {
|
|
|
19
25
|
out.notice('It waits to be answered in the browser, or with dialog accept [text] | dialog dismiss; until then the page, and commands that read it, wait too.', p);
|
|
20
26
|
});
|
|
21
27
|
p.on('close', () => openDialogs.delete(p));
|
|
28
|
+
// One answered in the browser is gone too, which Playwright does not report. The tab's own session
|
|
29
|
+
// shares Playwright's connection, so a dialog's closing comes in before the next one opening.
|
|
30
|
+
keptSession(p).then(async session => {
|
|
31
|
+
session.on('Page.javascriptDialogClosed', () => openDialogs.delete(p));
|
|
32
|
+
await session.send('Page.enable');
|
|
33
|
+
}).catch(() => {});
|
|
22
34
|
}
|
|
23
35
|
|
|
24
36
|
// Runs ahead of the command queue (see runner.js), so it still works while
|
|
@@ -30,7 +42,9 @@ async function dialogCommand(args, selected = state.page) {
|
|
|
30
42
|
const describe = (p, d) => `[${pages.indexOf(p)}] ${p.url()}: ${d.type()} ${JSON.stringify(String(d.message()).slice(0, 200))}`;
|
|
31
43
|
if (!action) {
|
|
32
44
|
if (!openDialogs.size) return 'No dialog is open.';
|
|
33
|
-
|
|
45
|
+
// With several, the one accept and dismiss answer is marked, as tab marks the selected tab.
|
|
46
|
+
const mark = p => (openDialogs.size > 1 ? (p === selected ? '* ' : ' ') : '');
|
|
47
|
+
return [...[...openDialogs].map(([p, d]) => `${mark(p)}${describe(p, d)}`), hints([['dialog accept [text]', 'accept it (text answers a prompt)'], ['dialog dismiss', 'dismiss it']])].join('\n');
|
|
34
48
|
}
|
|
35
49
|
if (action !== 'accept' && action !== 'dismiss') throw new Error('Usage: dialog [accept [text] | dismiss]');
|
|
36
50
|
// The selected tab's dialog, or the only one open.
|
|
@@ -40,7 +54,8 @@ async function dialogCommand(args, selected = state.page) {
|
|
|
40
54
|
openDialogs.delete(page);
|
|
41
55
|
const line = describe(page, dialog);
|
|
42
56
|
try {
|
|
43
|
-
|
|
57
|
+
// Without text, as OK in the browser: a prompt's default stays its answer.
|
|
58
|
+
if (action === 'accept') await dialog.accept(rest.length ? unquote(rest.join(' ')) : dialog.defaultValue());
|
|
44
59
|
else await dialog.dismiss();
|
|
45
60
|
} catch (error) {
|
|
46
61
|
// Answered in the browser meanwhile.
|
|
@@ -55,4 +70,4 @@ const commands = {
|
|
|
55
70
|
},
|
|
56
71
|
};
|
|
57
72
|
|
|
58
|
-
module.exports = { commands, ensureDialogHandler, dialogCommand };
|
|
73
|
+
module.exports = { commands, ensureDialogHandler, dialogCommand, CDP_ANSWER, CDP_REFUSED };
|
package/lib/elements.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// Elements and the mouse: finding the match to act on, and the commands that click, type and choose.
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const fs = require('fs');
|
|
4
|
-
const { state } = require('./state');
|
|
4
|
+
const { state, onShutdown } = require('./state');
|
|
5
5
|
const out = require('./output');
|
|
6
6
|
const { takeOptions } = require('./cli-names');
|
|
7
7
|
const { unquote, toSelector, keyName, splitSelector, REF } = require('./syntax');
|
|
8
|
-
const { numbers } = require('./util');
|
|
8
|
+
const { numbers, hints } = require('./util');
|
|
9
|
+
const { tabState } = require('./tabstate');
|
|
9
10
|
|
|
10
11
|
// The type a page sees for a file it is given, from its extension.
|
|
11
12
|
const MIME_TYPES = {
|
|
@@ -198,6 +199,34 @@ async function pointerAction(selector, act, options = {}) {
|
|
|
198
199
|
}
|
|
199
200
|
}
|
|
200
201
|
|
|
202
|
+
// Highlights: Playwright's own overlay, as playwright-cli's highlight draws it. Each is a mode of its
|
|
203
|
+
// tab, so modes shows it and modes off hides it, and none is left on the page when the REPL stops.
|
|
204
|
+
function highlightsOf(p) {
|
|
205
|
+
if (!tabState(p).highlights) {
|
|
206
|
+
tabState(p).highlights = new Map();
|
|
207
|
+
// A new document drops the overlay; a page that routes on the client keeps it.
|
|
208
|
+
p.on('domcontentloaded', () => tabState(p).highlights?.clear());
|
|
209
|
+
}
|
|
210
|
+
return tabState(p).highlights;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Hides a tab's highlights, or only the running client's (mine).
|
|
214
|
+
async function hideHighlights(p, mine = false) {
|
|
215
|
+
const highlights = tabState(p).highlights;
|
|
216
|
+
if (!highlights?.size) return 0;
|
|
217
|
+
const hidden = [...highlights].filter(([, h]) => !mine || (h.owner ?? null) === (state.client ?? null));
|
|
218
|
+
if (hidden.length === highlights.size) await p.hideHighlight();
|
|
219
|
+
else for (const [selector] of hidden) await p.locator(selector).hideHighlight();
|
|
220
|
+
for (const [selector] of hidden) highlights.delete(selector);
|
|
221
|
+
return hidden.length;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
onShutdown(async () => {
|
|
225
|
+
if (!state.browser || state.connectionLost) return;
|
|
226
|
+
const pages = state.browser.contexts().flatMap(c => c.pages()).filter(p => !p.isClosed());
|
|
227
|
+
await Promise.all(pages.map(p => hideHighlights(p).catch(() => {})));
|
|
228
|
+
});
|
|
229
|
+
|
|
201
230
|
const commands = {
|
|
202
231
|
async click(args) {
|
|
203
232
|
const { selector, shown, options } = clickArgs(args, 'click <selector> [left|right|middle] [--modifiers=<key>[,<key>]]');
|
|
@@ -217,6 +246,42 @@ const commands = {
|
|
|
217
246
|
out.log(`Hovered: ${args.trim()}${which}`);
|
|
218
247
|
},
|
|
219
248
|
|
|
249
|
+
async highlight(args) {
|
|
250
|
+
const usage = 'Usage: highlight [<selector> [--style=<css>] | --hide [<selector>] | off]';
|
|
251
|
+
const { found, rest } = takeOptions((args || '').trim(), { style: 'value', hide: 'flag' });
|
|
252
|
+
const style = found.find(o => o.name === 'style');
|
|
253
|
+
const hide = found.some(o => o.name === 'hide');
|
|
254
|
+
const highlights = highlightsOf(state.page);
|
|
255
|
+
if (!rest && !found.length) {
|
|
256
|
+
if (!highlights.size) { out.log(`Nothing is highlighted in the selected tab.\n${hints([['highlight <selector>', 'draw a box around it']])}`); return; }
|
|
257
|
+
for (const [selector, h] of highlights) out.log(`${selector}${h.style ? ` --style=${JSON.stringify(h.style)}` : ''}${h.owner ? ` (by ${h.owner})` : ''}`);
|
|
258
|
+
out.log(hints([['highlight --hide [<selector>]', 'hide one, or all']]));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (rest === 'off' && !found.length || hide && !rest) {
|
|
262
|
+
const count = await hideHighlights(state.page);
|
|
263
|
+
out.log(count ? `Hid ${count} highlight${count === 1 ? '' : 's'}` : 'Nothing was highlighted in the selected tab.');
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (!rest || style && (hide || !style.value)) throw new Error(usage);
|
|
267
|
+
const selector = soleSelector(rest, usage);
|
|
268
|
+
const locator = state.page.locator(selector);
|
|
269
|
+
if (hide) {
|
|
270
|
+
if (!highlights.has(selector)) throw new Error(`${rest} is not highlighted; highlight lists what is`);
|
|
271
|
+
await locator.hideHighlight();
|
|
272
|
+
highlights.delete(selector);
|
|
273
|
+
out.log(`Hid the highlight on ${rest}`);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const count = await locator.count();
|
|
277
|
+
if (!count) throw new Error(`No element matches ${rest}; nothing was highlighted`);
|
|
278
|
+
// Drawn again, with its new style, rather than twice.
|
|
279
|
+
if (highlights.has(selector)) await locator.hideHighlight();
|
|
280
|
+
await locator.highlight(style ? { style: style.value } : {});
|
|
281
|
+
highlights.set(selector, { owner: state.client, style: style?.value || null });
|
|
282
|
+
out.log(`Highlighted ${rest}${count > 1 ? ` (all ${count} matches)` : ''}; highlight --hide ${rest} hides it`);
|
|
283
|
+
},
|
|
284
|
+
|
|
220
285
|
// At a point, in CSS pixels from the top left of the tab's viewport, as playwright-cli's are.
|
|
221
286
|
async mousemove(args) {
|
|
222
287
|
const [x, y, extra] = numbers(args, 'mousemove <x> <y>');
|
|
@@ -357,4 +422,4 @@ const commands = {
|
|
|
357
422
|
},
|
|
358
423
|
};
|
|
359
424
|
|
|
360
|
-
module.exports = { commands, soleSelector, onElement };
|
|
425
|
+
module.exports = { commands, soleSelector, onElement, hideHighlights };
|
package/lib/emulation.js
CHANGED
|
@@ -17,7 +17,7 @@ async function viewportText() {
|
|
|
17
17
|
const wider = laidOut && laidOut > width ? `; the page lays out ${laidOut} wide, shown shrunk: it has no viewport meta tag` : '';
|
|
18
18
|
return `${width}x${height} (emulate mobile: ${device}${wider})`;
|
|
19
19
|
}
|
|
20
|
-
const set = state.page.
|
|
20
|
+
const set = tabState(state.page).viewport;
|
|
21
21
|
if (set) return `${set.width}x${set.height} (set with viewport)`;
|
|
22
22
|
const size = await state.page.evaluate(() => `${innerWidth}x${innerHeight}`).catch(() => null);
|
|
23
23
|
return size ? `${size} (the window's size)` : 'unknown';
|
|
@@ -49,6 +49,35 @@ function deviceText(name) {
|
|
|
49
49
|
return `${name}, ${d.viewport.width}x${d.viewport.height} at ${d.deviceScaleFactor}x${d.hasTouch ? ', touch' : ''}`;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// A viewport set with viewport, on the kept session, as a phone's size is, so that viewport off can
|
|
53
|
+
// undo it: Playwright's setViewportSize has no undo. As Playwright sets it, at one device pixel per
|
|
54
|
+
// CSS pixel, and the window is left as it is.
|
|
55
|
+
function viewportMetrics(session, { width, height }) {
|
|
56
|
+
return session.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false, screenWidth: width, screenHeight: height });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Chrome answers a change of screen size before the page has it (a clear, often), so the size read
|
|
60
|
+
// next, a screenshot or a recording would be the old one: two frames later it has it. Bounded, as a
|
|
61
|
+
// dialog holds the page.
|
|
62
|
+
function settled(p) {
|
|
63
|
+
return Promise.race([
|
|
64
|
+
p.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))).catch(() => {}),
|
|
65
|
+
new Promise(resolve => setTimeout(resolve, 1000)),
|
|
66
|
+
]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function setViewport(p, size) {
|
|
70
|
+
const session = await keptSession(p);
|
|
71
|
+
if (size) {
|
|
72
|
+
await viewportMetrics(session, size);
|
|
73
|
+
tabState(p).viewport = { ...size, owner: state.client };
|
|
74
|
+
} else {
|
|
75
|
+
await session.send('Emulation.clearDeviceMetricsOverride');
|
|
76
|
+
delete tabState(p).viewport;
|
|
77
|
+
}
|
|
78
|
+
await settled(p);
|
|
79
|
+
}
|
|
80
|
+
|
|
52
81
|
// Sends only what changed. Chrome takes the user agent and the languages
|
|
53
82
|
// together, so a change to either sends both.
|
|
54
83
|
function deviceMetrics(session, name) {
|
|
@@ -71,6 +100,7 @@ async function setEmulation(p, next) {
|
|
|
71
100
|
await send('Emulation.clearDeviceMetricsOverride');
|
|
72
101
|
}
|
|
73
102
|
await send('Emulation.setTouchEmulationEnabled', d?.hasTouch ? { enabled: true, maxTouchPoints: 5 } : { enabled: false });
|
|
103
|
+
await settled(p);
|
|
74
104
|
}
|
|
75
105
|
if (next.device !== previous.device || next.locale !== previous.locale) {
|
|
76
106
|
if (next.device || next.locale) {
|
|
@@ -111,23 +141,34 @@ function showEmulation() {
|
|
|
111
141
|
out.log(hints([...forms, ['emulate <what> off | emulate off', 'stop one, or all']]));
|
|
112
142
|
}
|
|
113
143
|
|
|
114
|
-
// Resets every tab's emulation, which detaching would only partly undo.
|
|
144
|
+
// Resets every tab's emulation and viewport, which detaching would only partly undo.
|
|
115
145
|
async function resetEmulations() {
|
|
116
146
|
if (!state.browser || state.connectionLost) return;
|
|
117
|
-
const pages = state.browser.contexts().flatMap(c => c.pages()).filter(p =>
|
|
118
|
-
await Promise.all(pages.map(
|
|
147
|
+
const pages = state.browser.contexts().flatMap(c => c.pages()).filter(p => !p.isClosed());
|
|
148
|
+
await Promise.all(pages.map(async p => {
|
|
149
|
+
if ('emulation' in tabState(p)) await setEmulation(p, {}).catch(() => {});
|
|
150
|
+
if ('viewport' in tabState(p)) await setViewport(p, null).catch(() => {});
|
|
151
|
+
}));
|
|
119
152
|
}
|
|
120
153
|
|
|
121
154
|
onShutdown(resetEmulations);
|
|
122
155
|
|
|
123
156
|
const commands = {
|
|
124
157
|
async viewport(args) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (
|
|
158
|
+
const arg = (args || '').trim();
|
|
159
|
+
if (!arg) { out.log(await viewportText()); return; }
|
|
160
|
+
if (arg === 'off') {
|
|
161
|
+
if (!tabState(state.page).viewport) { out.log(`No viewport is set in the selected tab: ${await viewportText()}`); return; }
|
|
162
|
+
await setViewport(state.page, null);
|
|
163
|
+
out.log(`Viewport off: ${await viewportText()}`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const match = /^(\d+)x(\d+)$/.exec(arg);
|
|
167
|
+
const [w, h] = match ? [Number(match[1]), Number(match[2])] : [];
|
|
168
|
+
if (!w || !h) throw new Error('Usage: viewport [<width>x<height> | off]');
|
|
128
169
|
// Both set the screen size; whichever came last would win without saying so.
|
|
129
170
|
if (tabState(state.page).emulation?.device) throw new Error('emulate mobile sets the size of the selected tab; emulate mobile off first');
|
|
130
|
-
await state.page
|
|
171
|
+
await setViewport(state.page, { width: w, height: h });
|
|
131
172
|
out.log(`Viewport set to ${w}x${h}`);
|
|
132
173
|
},
|
|
133
174
|
|
|
@@ -156,6 +197,8 @@ const commands = {
|
|
|
156
197
|
}
|
|
157
198
|
let next;
|
|
158
199
|
if (what === 'mobile') {
|
|
200
|
+
// One screen size at a time, as viewport refuses while a phone's is set.
|
|
201
|
+
if (tabState(state.page).viewport) throw new Error('viewport sets the size of the selected tab; viewport off first');
|
|
159
202
|
next = deviceNamed(value || DEFAULT_DEVICE);
|
|
160
203
|
} else if (what === 'dark' || what === 'light') {
|
|
161
204
|
if (value) throw new Error(usage);
|
|
@@ -177,4 +220,4 @@ const commands = {
|
|
|
177
220
|
},
|
|
178
221
|
};
|
|
179
222
|
|
|
180
|
-
module.exports = { commands, viewportText, EMULATE_KINDS, emulatedKinds, deviceMetrics, setEmulation };
|
|
223
|
+
module.exports = { commands, viewportText, EMULATE_KINDS, emulatedKinds, deviceMetrics, viewportMetrics, setEmulation, setViewport };
|