pw-repl 0.3.0 → 0.3.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 +33 -6
- package/README.md +13 -9
- package/bin/pw-repl.js +3 -1
- package/lib/commands.js +317 -44
- package/lib/help.js +36 -18
- package/lib/send.js +2 -1
- package/lib/state.js +2 -1
- package/lib/syntax.js +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +9 -7
package/AGENTS.md
CHANGED
|
@@ -11,18 +11,45 @@ If you hit a limitation or write a workaround, consider adding the capability to
|
|
|
11
11
|
- A command is a function in the `commands` object in `lib/commands.js`, plus an entry in `lib/help.js`
|
|
12
12
|
under one topic.
|
|
13
13
|
- `bin/pw-repl.js` is the command line; `lib/start.js` connects and runs the prompt; `lib/runner.js`
|
|
14
|
-
runs commands one at a time
|
|
15
|
-
are `send` and `where`; `lib/
|
|
14
|
+
runs commands one at a time (`quit` and `dialog` skip its queue); `lib/server.js` is the command
|
|
15
|
+
server; `lib/send.js` and `lib/client.js` are `send` and `where`; `lib/background.js` is
|
|
16
|
+
`serve --background`, `attach` and `stop`; `lib/launch.js` is `--launch`; `lib/syntax.js` is how a
|
|
17
|
+
command line's words are read, shared with `send`; `lib/state.js` holds the session state;
|
|
18
|
+
`lib/output.js` routes all output so the server can return it.
|
|
16
19
|
- Comment the *why* when it isn't obvious from the code.
|
|
17
|
-
- `npm test` runs the suite (about
|
|
18
|
-
REPL with its server. Nothing is mocked, and the shared browser is
|
|
19
|
-
|
|
20
|
-
none. Add a test with each new
|
|
20
|
+
- `npm test` runs the suite (about a minute): a private headless Chromium, a local test site
|
|
21
|
+
(`test/harness.js`), and the real REPL with its server. Nothing is mocked, and the shared browser is
|
|
22
|
+
never touched. It finds Chromium through `PW_TEST_CHROME`, or where `--launch` looks (Playwright's
|
|
23
|
+
browsers, then the `PATH`), and skips the browser tests if there is none. Add a test with each new
|
|
24
|
+
command or behaviour.
|
|
21
25
|
- For anything the tests can't reach, exercise the change in a running REPL. Use your own tmux session
|
|
22
26
|
and a new tab, not the user's.
|
|
23
27
|
- `skill/SKILL.md` is how agents learn to use the REPL: keep it in step with a change to how it is
|
|
24
28
|
used, and leave its Custom rules section empty.
|
|
25
29
|
|
|
30
|
+
## Waves
|
|
31
|
+
|
|
32
|
+
Work goes in waves, and each one ends with a release that is ready to push.
|
|
33
|
+
|
|
34
|
+
1. Build what was asked, with tests, and try it in a running REPL.
|
|
35
|
+
2. Try it on a fresh agent that has not seen the code or the change (a guinea pig), on something real: a
|
|
36
|
+
small local page whose planted bugs need the change to find.
|
|
37
|
+
- Brief it with goals only: what to find out or do in the browser, never the commands.
|
|
38
|
+
- First it reads back its plan, learned from `pw-repl skill` and help alone: the commands, where it
|
|
39
|
+
found them, and what is unclear. It runs nothing yet.
|
|
40
|
+
- A wrong or unsure plan means the docs or the brief were unclear: find out which, fix that, then
|
|
41
|
+
ask again.
|
|
42
|
+
- Then it carries the plan out, in a browser and on a socket of its own, and reports as it goes:
|
|
43
|
+
above all, where the tool did something other than what the docs led it to expect.
|
|
44
|
+
3. Triage what it reports. Fix what matters, and what is small and useful; don't put off something
|
|
45
|
+
useful for later. Leave trivia alone, and say why.
|
|
46
|
+
4. Repeat 2 and 3, with a fresh agent each time, until a pass comes back clean: no real bugs, and
|
|
47
|
+
nothing unclear in the docs or the brief that changed the plan. A pass after fixes tries the fixes,
|
|
48
|
+
and something not tried yet.
|
|
49
|
+
5. Only then cut the release (Releasing, steps 1-3), and say it is ready. The maintainer pushes and
|
|
50
|
+
publishes it, and the next wave starts from what they find. A release cut too early and not pushed
|
|
51
|
+
yet is undone (its commit and tag) and cut again after the fixes.
|
|
52
|
+
|
|
26
53
|
## Releasing
|
|
27
54
|
|
|
28
55
|
Commits follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `docs:`,
|
package/README.md
CHANGED
|
@@ -71,6 +71,9 @@ Commands act on the selected tab (`tab` lists the tabs, with `*` on the selected
|
|
|
71
71
|
| see where I am | `tab`, `info` |
|
|
72
72
|
| see what is on the page | `snapshot`, `screenshot` |
|
|
73
73
|
| do something on it | `click`, `fill`, `press` |
|
|
74
|
+
| wait for a page or element | `wait load`, `wait <selector>`, `wait <selector> --gone` |
|
|
75
|
+
| choose a file in a file input | `upload <selector> <file>` |
|
|
76
|
+
| see it as a phone, or in dark mode | `emulate mobile`, `emulate dark` (also `emulate locale`, `emulate timezone`) |
|
|
74
77
|
| see what the page requested | `requests`, then `body <#>` for what one got back |
|
|
75
78
|
| see console messages and errors | `console` |
|
|
76
79
|
| show an agent what I do | `watch on`, click around in the browser, then `watch` |
|
|
@@ -78,19 +81,20 @@ Commands act on the selected tab (`tab` lists the tabs, with `*` on the selected
|
|
|
78
81
|
| record requests and console together | `capture on`, then `capture off` |
|
|
79
82
|
| break the backend on purpose | `route <glob> <status> <json>` (fake a response), `route <glob> abort`, `network off` |
|
|
80
83
|
| change or slow an API response | `route <glob> patch <json>`, `route <glob> delay <secs>` |
|
|
84
|
+
| slow the whole network | `network slow` |
|
|
81
85
|
| clean up | `modes off` |
|
|
82
86
|
|
|
83
87
|
Everything else is in `help <topic>`; `help <command>` has usage and caveats.
|
|
84
88
|
|
|
85
89
|
### Modes
|
|
86
90
|
|
|
87
|
-
`watch`, `capture`, `route
|
|
88
|
-
`capture on|off`, `route ...|route off`, `network off|on
|
|
89
|
-
shows them: `(watch network:off routes:2) pw>`. `modes`
|
|
90
|
-
all off.
|
|
91
|
+
`watch`, `capture`, `route`, `network off` or `slow`, and `emulate` stay on until you turn them off:
|
|
92
|
+
`watch on|off`, `capture on|off`, `route ...|route off`, `network off|slow|on`, `emulate ...|emulate off`.
|
|
93
|
+
While any are on in the selected tab, the prompt shows them: `(watch network:off routes:2) pw>`. `modes`
|
|
94
|
+
lists them for every tab, and `modes off` turns them all off.
|
|
91
95
|
|
|
92
|
-
`tab`, `watch`, `capture`, `route`, `network` and `modes` on their own show their state and what
|
|
93
|
-
run next.
|
|
96
|
+
`tab`, `watch`, `capture`, `route`, `network`, `emulate` and `modes` on their own show their state and what
|
|
97
|
+
you can run next.
|
|
94
98
|
|
|
95
99
|
## Options
|
|
96
100
|
|
|
@@ -156,6 +160,6 @@ The same text is in `skill/SKILL.md`. Working on the REPL itself: see `AGENTS.md
|
|
|
156
160
|
npm test
|
|
157
161
|
```
|
|
158
162
|
|
|
159
|
-
Runs against a private headless Chromium it starts itself (
|
|
160
|
-
|
|
161
|
-
Chromium is found.
|
|
163
|
+
Runs against a private headless Chromium it starts itself (`PW_TEST_CHROME`, or the one `--launch` would
|
|
164
|
+
use: Playwright's, e.g. from `npx playwright-core install chromium`, then one on the `PATH`) and a local
|
|
165
|
+
test site; the browser tests are skipped when no Chromium is found.
|
package/bin/pw-repl.js
CHANGED
|
@@ -79,7 +79,9 @@ function parseSendArgs(args, allowCommand) {
|
|
|
79
79
|
const { SELECTOR_FIRST } = require('../lib/syntax');
|
|
80
80
|
const requote = words.length > 1 && SELECTOR_FIRST.has(words[0]);
|
|
81
81
|
// An empty word (fill #name "") is the empty value.
|
|
82
|
-
|
|
82
|
+
// upload's files are read by the REPL, whose folder may not be this one.
|
|
83
|
+
const resolved = words[0] === 'upload' ? words.map((w, n) => (n > 1 ? require('path').resolve(w) : w)) : words;
|
|
84
|
+
const quoted = requote ? resolved.map(w => (w === '' || /[\s"']/.test(w) ? JSON.stringify(w) : w)) : resolved;
|
|
83
85
|
options.command = quoted.join(' ').trim();
|
|
84
86
|
return options;
|
|
85
87
|
}
|
package/lib/commands.js
CHANGED
|
@@ -3,6 +3,7 @@ const fs = require('fs');
|
|
|
3
3
|
const { state, withTimeout, onShutdown, shutdown } = require('./state');
|
|
4
4
|
const out = require('./output');
|
|
5
5
|
const HELP = require('./help');
|
|
6
|
+
const { devices } = require('playwright-core');
|
|
6
7
|
const { toSelector, unquote, splitSelector } = require('./syntax');
|
|
7
8
|
|
|
8
9
|
const { printOutput, OUTPUT_LIMIT } = out;
|
|
@@ -143,6 +144,8 @@ async function dialogCommand(args) {
|
|
|
143
144
|
// A tab in a browser the REPL connected to has no viewport set, so its size is
|
|
144
145
|
// the window's; it is read from the page.
|
|
145
146
|
async function viewportText() {
|
|
147
|
+
const device = emulations.get(state.page)?.device;
|
|
148
|
+
if (device) return `${devices[device].viewport.width}x${devices[device].viewport.height} (emulate mobile: ${device})`;
|
|
146
149
|
const set = state.page.viewportSize();
|
|
147
150
|
if (set) return `${set.width}x${set.height} (set with viewport)`;
|
|
148
151
|
const size = await state.page.evaluate(() => `${innerWidth}x${innerHeight}`).catch(() => null);
|
|
@@ -151,12 +154,12 @@ async function viewportText() {
|
|
|
151
154
|
|
|
152
155
|
// A page restored from the back/forward cache never fires its load events
|
|
153
156
|
// again, so back and forward wait only for the navigation, then briefly for
|
|
154
|
-
// the
|
|
157
|
+
// the document to be parsed, which a restored one already is.
|
|
155
158
|
async function historyStep(go, direction) {
|
|
156
159
|
const before = state.page.url();
|
|
157
160
|
const response = await go();
|
|
158
161
|
if (response === null && state.page.url() === before) throw new Error(`No page to go ${direction} to`);
|
|
159
|
-
await state.page.
|
|
162
|
+
await state.page.waitForFunction(() => document.readyState !== 'loading', null, { timeout: 3000 }).catch(() => {});
|
|
160
163
|
}
|
|
161
164
|
|
|
162
165
|
const SCREENSHOT_DELAY_MAX = 60;
|
|
@@ -166,25 +169,135 @@ function nextScreenshotPath(name) {
|
|
|
166
169
|
return path.join(SCREENSHOT_DIR, `${filename}.png`);
|
|
167
170
|
}
|
|
168
171
|
|
|
169
|
-
// Per page: emulation applies to the page
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const networkCut = new WeakSet();
|
|
172
|
+
// Per page: a CDP session's emulation applies to the page it is attached to
|
|
173
|
+
// and resets, in part, when it detaches, so one is kept for each tab.
|
|
174
|
+
const keptSessions = new WeakMap();
|
|
173
175
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
176
|
+
function keptSession(p) {
|
|
177
|
+
if (!keptSessions.has(p)) keptSessions.set(p, p.context().newCDPSession(p).catch(error => { keptSessions.delete(p); throw error; }));
|
|
178
|
+
return keptSessions.get(p);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const networkEnabled = new WeakSet();
|
|
182
|
+
// page -> how its network is changed: { offline: true }, or slowed { latency, down, up } (ms, kbps).
|
|
183
|
+
const networkChanged = new WeakMap();
|
|
184
|
+
// DevTools' "Slow 4G".
|
|
185
|
+
const SLOW_DEFAULT = { latency: 563, down: 1440, up: 675 };
|
|
186
|
+
const SLOW_LATENCY_MAX = 10000;
|
|
187
|
+
|
|
188
|
+
// setting null restores the network.
|
|
189
|
+
async function setNetwork(p, setting) {
|
|
190
|
+
const session = await keptSession(p);
|
|
191
|
+
if (!networkEnabled.has(p)) { await session.send('Network.enable'); networkEnabled.add(p); }
|
|
181
192
|
await session.send('Network.emulateNetworkConditions', {
|
|
182
|
-
offline:
|
|
183
|
-
latency: 0,
|
|
184
|
-
|
|
185
|
-
|
|
193
|
+
offline: !!setting?.offline,
|
|
194
|
+
latency: setting?.latency || 0,
|
|
195
|
+
// kbps to bytes per second; -1 is no limit.
|
|
196
|
+
downloadThroughput: setting?.down ? setting.down * 125 : -1,
|
|
197
|
+
uploadThroughput: setting?.up ? setting.up * 125 : -1,
|
|
186
198
|
});
|
|
187
|
-
if (
|
|
199
|
+
if (setting) networkChanged.set(p, setting); else networkChanged.delete(p);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function networkText(setting) {
|
|
203
|
+
return setting.offline ? 'off (offline)' : `slow (${setting.latency}ms latency, ${setting.down} kbps down, ${setting.up} kbps up)`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// page -> what emulate changed: { device, scheme, locale, timezone }, each unset when off.
|
|
207
|
+
const emulations = new WeakMap();
|
|
208
|
+
const DEFAULT_DEVICE = 'Pixel 7';
|
|
209
|
+
const EMULATE_KINDS = ['mobile', 'dark', 'light', 'locale', 'timezone'];
|
|
210
|
+
|
|
211
|
+
function emulatedKinds(e) {
|
|
212
|
+
return [e?.device && 'mobile', e?.scheme, e?.locale && 'locale', e?.timezone && 'timezone'].filter(Boolean);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const DEVICES_SHOWN = 20;
|
|
216
|
+
|
|
217
|
+
function deviceNamed(name) {
|
|
218
|
+
const names = Object.keys(devices);
|
|
219
|
+
const found = names.find(d => d.toLowerCase() === name.toLowerCase());
|
|
220
|
+
if (found) return found;
|
|
221
|
+
// The names that have every word typed, so a near miss finds the exact name.
|
|
222
|
+
const words = name.toLowerCase().split(/\s+/);
|
|
223
|
+
const close = names.filter(d => !/ landscape$/.test(d) && words.every(w => d.toLowerCase().includes(w)));
|
|
224
|
+
if (!close.length) throw new Error(`No device ${JSON.stringify(name)}; names are Playwright's, e.g. Pixel 7, iPhone 13, iPad Mini, Galaxy S9+`);
|
|
225
|
+
const more = close.length > DEVICES_SHOWN ? `, and ${close.length - DEVICES_SHOWN} more` : '';
|
|
226
|
+
throw new Error(`No device ${JSON.stringify(name)}; matching: ${close.slice(0, DEVICES_SHOWN).join(', ')}${more} (each also as "<name> landscape")`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function deviceText(name) {
|
|
230
|
+
const d = devices[name];
|
|
231
|
+
return `${name}, ${d.viewport.width}x${d.viewport.height} at ${d.deviceScaleFactor}x${d.hasTouch ? ', touch' : ''}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Sends only what changed. Chrome takes the user agent and the languages
|
|
235
|
+
// together, so a change to either sends both.
|
|
236
|
+
function deviceMetrics(session, name) {
|
|
237
|
+
const d = devices[name];
|
|
238
|
+
return session.send('Emulation.setDeviceMetricsOverride', {
|
|
239
|
+
width: d.viewport.width, height: d.viewport.height, deviceScaleFactor: d.deviceScaleFactor, mobile: d.isMobile,
|
|
240
|
+
screenWidth: d.screen?.width || d.viewport.width, screenHeight: d.screen?.height || d.viewport.height,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function setEmulation(p, next) {
|
|
245
|
+
const session = await keptSession(p);
|
|
246
|
+
const previous = emulations.get(p) || {};
|
|
247
|
+
const send = (method, params) => session.send(method, params);
|
|
248
|
+
if (next.device !== previous.device) {
|
|
249
|
+
const d = devices[next.device];
|
|
250
|
+
if (d) {
|
|
251
|
+
await deviceMetrics(session, next.device);
|
|
252
|
+
} else {
|
|
253
|
+
await send('Emulation.clearDeviceMetricsOverride');
|
|
254
|
+
}
|
|
255
|
+
await send('Emulation.setTouchEmulationEnabled', d?.hasTouch ? { enabled: true, maxTouchPoints: 5 } : { enabled: false });
|
|
256
|
+
}
|
|
257
|
+
if (next.device !== previous.device || next.locale !== previous.locale) {
|
|
258
|
+
if (next.device || next.locale) {
|
|
259
|
+
const userAgent = next.device ? devices[next.device].userAgent : (await send('Browser.getVersion')).userAgent;
|
|
260
|
+
await send('Emulation.setUserAgentOverride', { userAgent, ...(next.locale ? { acceptLanguage: next.locale } : {}) });
|
|
261
|
+
} else {
|
|
262
|
+
// An empty user agent ends the override.
|
|
263
|
+
await send('Emulation.setUserAgentOverride', { userAgent: '' });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (next.locale !== previous.locale) await send('Emulation.setLocaleOverride', next.locale ? { locale: next.locale } : {});
|
|
267
|
+
if (next.timezone !== previous.timezone) await send('Emulation.setTimezoneOverride', { timezoneId: next.timezone || '' });
|
|
268
|
+
if (next.scheme !== previous.scheme) await send('Emulation.setEmulatedMedia', { features: next.scheme ? [{ name: 'prefers-color-scheme', value: next.scheme }] : [] });
|
|
269
|
+
if (emulatedKinds(next).length) emulations.set(p, next); else emulations.delete(p);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function showEmulation() {
|
|
273
|
+
const e = emulations.get(state.page);
|
|
274
|
+
const forms = [
|
|
275
|
+
['emulate mobile [device]', `a phone's screen, touch and user agent (default ${DEFAULT_DEVICE})`],
|
|
276
|
+
['emulate dark | light', 'the color scheme the page sees'],
|
|
277
|
+
['emulate locale <tag>', 'its language and formats, e.g. fr-FR'],
|
|
278
|
+
['emulate timezone <zone>', 'e.g. Asia/Tokyo'],
|
|
279
|
+
];
|
|
280
|
+
if (!e) {
|
|
281
|
+
out.log('Nothing is emulated in the selected tab.');
|
|
282
|
+
out.log(hints(forms));
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
out.log('Emulated in the selected tab:');
|
|
286
|
+
const rows = [
|
|
287
|
+
e.device && ['mobile', deviceText(e.device)],
|
|
288
|
+
e.scheme && ['color scheme', e.scheme],
|
|
289
|
+
e.locale && ['locale', e.locale],
|
|
290
|
+
e.timezone && ['timezone', e.timezone],
|
|
291
|
+
].filter(Boolean);
|
|
292
|
+
for (const [what, value] of rows) out.log(` ${what.padEnd(12)} ${value}`);
|
|
293
|
+
out.log(hints([...forms, ['emulate <what> off | emulate off', 'stop one, or all']]));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Resets every tab's emulation, which detaching would only partly undo.
|
|
297
|
+
async function resetEmulations() {
|
|
298
|
+
if (!state.browser || state.connectionLost) return;
|
|
299
|
+
const pages = state.browser.contexts().flatMap(c => c.pages()).filter(p => emulations.has(p) && !p.isClosed());
|
|
300
|
+
await Promise.all(pages.map(p => setEmulation(p, {}).catch(() => {})));
|
|
188
301
|
}
|
|
189
302
|
|
|
190
303
|
// page -> Map(glob -> { status, body, handler }). Playwright routes are
|
|
@@ -334,6 +447,8 @@ const consoleLogs = new WeakMap();
|
|
|
334
447
|
// request -> how a route changed it (faked or patched), shown after its status.
|
|
335
448
|
const changedRequests = new WeakMap();
|
|
336
449
|
const requestEntries = new WeakMap();
|
|
450
|
+
// page -> when its page last fired its load event.
|
|
451
|
+
const loadedAt = new WeakMap();
|
|
337
452
|
// Tabs this REPL opened with tab new.
|
|
338
453
|
const openedTabs = new WeakSet();
|
|
339
454
|
|
|
@@ -361,7 +476,9 @@ function ensureRecentLog(p) {
|
|
|
361
476
|
recentLogs.set(p, log);
|
|
362
477
|
consoleLogs.set(p, logs);
|
|
363
478
|
p.on('request', req => {
|
|
364
|
-
|
|
479
|
+
let navigation = false;
|
|
480
|
+
try { navigation = req.isNavigationRequest() && req.frame() === p.mainFrame(); } catch {}
|
|
481
|
+
const entry = { id: nextId++, t: Date.now(), method: req.method(), url: req.url(), type: req.resourceType(), status: 'pending', ms: null, response: null, navigation };
|
|
365
482
|
requestEntries.set(req, entry);
|
|
366
483
|
keep(log, entry);
|
|
367
484
|
});
|
|
@@ -382,6 +499,7 @@ function ensureRecentLog(p) {
|
|
|
382
499
|
finish(req, changedRequests.has(req) ? `${status} ${changedRequests.get(req)}` : status, res);
|
|
383
500
|
});
|
|
384
501
|
p.on('requestfailed', req => finish(req, `failed: ${req.failure()?.errorText || 'unknown'}`));
|
|
502
|
+
p.on('load', () => loadedAt.set(p, Date.now()));
|
|
385
503
|
p.on('console', msg => keep(logs, { t: Date.now(), type: msg.type(), text: clipText(consoleText(msg)) }));
|
|
386
504
|
// Uncaught exceptions never reach the console event.
|
|
387
505
|
p.on('pageerror', error => keep(logs, { t: Date.now(), type: 'pageerror', text: clipText(error.stack || error.message) }));
|
|
@@ -761,7 +879,10 @@ function stopWatching(p, watch) {
|
|
|
761
879
|
function activeModes(p) {
|
|
762
880
|
const modes = [];
|
|
763
881
|
if (watches.get(p)?.on) modes.push('watch');
|
|
764
|
-
|
|
882
|
+
const network = networkChanged.get(p);
|
|
883
|
+
if (network) modes.push(network.offline ? 'network:off' : 'network:slow');
|
|
884
|
+
const emulated = emulatedKinds(emulations.get(p));
|
|
885
|
+
if (emulated.length) modes.push(`emulate:${emulated.join(',')}`);
|
|
765
886
|
const routes = pageRoutes.get(p)?.size;
|
|
766
887
|
if (routes) modes.push(`routes:${routes}`);
|
|
767
888
|
if (cap && cap.page === p) modes.push('capture');
|
|
@@ -779,6 +900,7 @@ function listModes() {
|
|
|
779
900
|
['capture on', 'record requests and console messages together'],
|
|
780
901
|
['route <url-glob> <status> <json-body>', 'fake a response'],
|
|
781
902
|
['network off', 'cut the tab\'s network'],
|
|
903
|
+
['emulate mobile', 'show the tab as a phone would'],
|
|
782
904
|
]));
|
|
783
905
|
return;
|
|
784
906
|
}
|
|
@@ -803,7 +925,8 @@ async function allModesOff() {
|
|
|
803
925
|
if (cap && cap.page === p) { endCapture(); done.push('capture off (capture shows it)'); }
|
|
804
926
|
const globs = [...(pageRoutes.get(p)?.keys() || [])];
|
|
805
927
|
if (globs.length) { await unroute(p, globs); done.push(`${globs.length} route${globs.length === 1 ? '' : 's'} removed`); }
|
|
806
|
-
if (
|
|
928
|
+
if (networkChanged.has(p)) { await setNetwork(p, null); done.push('network on'); }
|
|
929
|
+
if (emulations.has(p)) { await setEmulation(p, {}); done.push('emulate off'); }
|
|
807
930
|
} catch (error) {
|
|
808
931
|
problem = error.message;
|
|
809
932
|
}
|
|
@@ -814,6 +937,19 @@ async function allModesOff() {
|
|
|
814
937
|
if (!any) out.log('No modes were on.');
|
|
815
938
|
}
|
|
816
939
|
|
|
940
|
+
// The type a page sees for a file it is given, from its extension.
|
|
941
|
+
const MIME_TYPES = {
|
|
942
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
|
|
943
|
+
pdf: 'application/pdf', txt: 'text/plain', csv: 'text/csv', json: 'application/json', html: 'text/html',
|
|
944
|
+
xml: 'application/xml', zip: 'application/zip', mp4: 'video/mp4', webm: 'video/webm', mp3: 'audio/mpeg',
|
|
945
|
+
wav: 'audio/wav', doc: 'application/msword', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
946
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
947
|
+
};
|
|
948
|
+
|
|
949
|
+
function mimeType(file) {
|
|
950
|
+
return MIME_TYPES[path.extname(file).slice(1).toLowerCase()] || 'application/octet-stream';
|
|
951
|
+
}
|
|
952
|
+
|
|
817
953
|
// Commands that take only a selector take the whole line, spaces and all.
|
|
818
954
|
function soleSelector(args, usage) {
|
|
819
955
|
const text = unquote((args || '').trim());
|
|
@@ -876,6 +1012,35 @@ async function waitForRequest(pattern, timeout) {
|
|
|
876
1012
|
}
|
|
877
1013
|
}
|
|
878
1014
|
|
|
1015
|
+
// Done once the page has loaded: after the latest navigation of the tab that
|
|
1016
|
+
// began since the previous command did (the click on a link, say), or, with
|
|
1017
|
+
// none, the document there now. Waiting on Playwright's load state alone would
|
|
1018
|
+
// see the page being left, which has already loaded.
|
|
1019
|
+
async function waitForLoad(timeout) {
|
|
1020
|
+
const p = state.page;
|
|
1021
|
+
const since = state.previousCommandAt || 0;
|
|
1022
|
+
const deadline = Date.now() + timeout;
|
|
1023
|
+
for (;;) {
|
|
1024
|
+
const navigation = (recentLogs.get(p) || []).filter(e => e.navigation && e.t >= since).pop();
|
|
1025
|
+
if (navigation && navigation.status.startsWith('failed')) throw new Error(`The page did not load: #${navigation.id} ${navigation.url} ${navigation.status}`);
|
|
1026
|
+
const loaded = navigation ? (loadedAt.get(p) || 0) >= navigation.t : await p.evaluate(() => document.readyState === 'complete').catch(() => false);
|
|
1027
|
+
if (loaded) { out.log(`Loaded: ${p.url()} — ${await p.title().catch(() => '')}`); return; }
|
|
1028
|
+
if (Date.now() >= deadline) throw new Error(`${p.url()} did not finish loading within ${timeout / 1000}s`);
|
|
1029
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// Gone once no match is visible, whether it was removed or hidden.
|
|
1034
|
+
async function waitGone(locator, what, timeout) {
|
|
1035
|
+
try {
|
|
1036
|
+
await locator.filter({ visible: true }).first().waitFor({ state: 'detached', timeout });
|
|
1037
|
+
} catch (error) {
|
|
1038
|
+
if (/Timeout \d+ms exceeded/.test(error.message)) throw new Error(`Still visible after ${timeout / 1000}s: ${what}`);
|
|
1039
|
+
throw error;
|
|
1040
|
+
}
|
|
1041
|
+
out.log(`Gone: ${what}`);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
879
1044
|
const SNAPSHOT_HINT_LINES = 60;
|
|
880
1045
|
|
|
881
1046
|
// Each matching line (any part of it: role, name, flags such as [disabled]),
|
|
@@ -890,7 +1055,11 @@ function grepSnapshot(text, needle) {
|
|
|
890
1055
|
const indent = line.length - line.trimStart().length;
|
|
891
1056
|
while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();
|
|
892
1057
|
if (line.toLowerCase().includes(lower)) {
|
|
893
|
-
const
|
|
1058
|
+
const around = stack.filter(a => !/^generic(?: \[|$)/.test(a.label));
|
|
1059
|
+
// A hit with no ref of its own (text) keeps the ref of what holds it, so
|
|
1060
|
+
// snapshot <ref> can show what sits beside it: the value next to a label.
|
|
1061
|
+
const own = /\[ref=/.test(line);
|
|
1062
|
+
const path = around.map((a, i) => (!own && i === around.length - 1 ? a.label : a.label.replace(/ \[ref=[^\]]+\]/g, '')));
|
|
894
1063
|
hits.push([...path, label(line)].join(' › '));
|
|
895
1064
|
}
|
|
896
1065
|
stack.push({ indent, label: label(line) });
|
|
@@ -1175,6 +1344,32 @@ const commands = {
|
|
|
1175
1344
|
out.log(`Unchecked: ${args.trim()}`);
|
|
1176
1345
|
},
|
|
1177
1346
|
|
|
1347
|
+
// The files are read here and handed to the page as their contents, not
|
|
1348
|
+
// their paths: the browser may run on another machine, or in another
|
|
1349
|
+
// container, where the paths mean nothing.
|
|
1350
|
+
async upload(args) {
|
|
1351
|
+
const parsed = splitSelector(args);
|
|
1352
|
+
if (!parsed || parsed.rest === undefined) throw new Error('Usage: upload <selector> <file>..., e.g. upload "input[type=file]" photo.png; quote a path with spaces');
|
|
1353
|
+
const files = [...parsed.rest.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g)].map(m => path.resolve(m[1] !== undefined ? m[1].replace(/\\(.)/g, '$1') : m[2] ?? m[3]));
|
|
1354
|
+
const payloads = files.map(file => {
|
|
1355
|
+
let buffer;
|
|
1356
|
+
try { buffer = fs.readFileSync(file); } catch (error) { throw new Error(`Cannot read ${file}: ${error.code === 'ENOENT' ? 'no such file' : error.code === 'EISDIR' ? 'it is a folder' : error.message}`); }
|
|
1357
|
+
return { name: path.basename(file), mimeType: mimeType(file), buffer };
|
|
1358
|
+
});
|
|
1359
|
+
const { selector } = parsed;
|
|
1360
|
+
await onElement(selector, async () => {
|
|
1361
|
+
try {
|
|
1362
|
+
await state.page.setInputFiles(selector, payloads, { timeout: 5000 });
|
|
1363
|
+
} catch (error) {
|
|
1364
|
+
if (!/not an HTMLInputElement/.test(error.message)) throw error;
|
|
1365
|
+
// A button that opens the file picker itself: click it, and answer the picker.
|
|
1366
|
+
const [chooser] = await Promise.all([state.page.waitForEvent('filechooser', { timeout: 5000 }), state.page.click(selector, { timeout: 5000 })]);
|
|
1367
|
+
await chooser.setFiles(payloads);
|
|
1368
|
+
}
|
|
1369
|
+
});
|
|
1370
|
+
out.log(`Chose ${files.length === 1 ? 'a file' : `${files.length} files`} in ${parsed.word}: ${files.join(', ')}`);
|
|
1371
|
+
},
|
|
1372
|
+
|
|
1178
1373
|
async text(args, all) {
|
|
1179
1374
|
const selector = soleSelector(args, 'text <selector>');
|
|
1180
1375
|
printOutput(await onElement(selector, () => state.page.innerText(selector, { timeout: 5000 })), all);
|
|
@@ -1295,7 +1490,19 @@ const commands = {
|
|
|
1295
1490
|
// Chrome does not draw a tab that is not in front, and tabs open in the
|
|
1296
1491
|
// background, so the tab is brought to the front for the shot.
|
|
1297
1492
|
await state.page.bringToFront();
|
|
1298
|
-
|
|
1493
|
+
let image;
|
|
1494
|
+
try {
|
|
1495
|
+
image = await state.page.screenshot({ fullPage: full });
|
|
1496
|
+
} finally {
|
|
1497
|
+
// Playwright's screenshot resets the screen size and pixel ratio it
|
|
1498
|
+
// finds; an emulated phone's are set again.
|
|
1499
|
+
const device = emulations.get(state.page)?.device;
|
|
1500
|
+
if (device) {
|
|
1501
|
+
const session = await keptSession(state.page);
|
|
1502
|
+
await session.send('Emulation.clearDeviceMetricsOverride');
|
|
1503
|
+
await deviceMetrics(session, device);
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1299
1506
|
try {
|
|
1300
1507
|
fs.writeFileSync(filepath, image, { flag: 'wx', mode: 0o600 });
|
|
1301
1508
|
} catch (error) {
|
|
@@ -1334,29 +1541,85 @@ const commands = {
|
|
|
1334
1541
|
if (!args) { out.log(await viewportText()); return; }
|
|
1335
1542
|
const [w, h] = args.split('x').map(Number);
|
|
1336
1543
|
if (!w || !h) throw new Error('Usage: viewport <width>x<height>');
|
|
1544
|
+
// Both set the screen size; whichever came last would win without saying so.
|
|
1545
|
+
if (emulations.get(state.page)?.device) throw new Error('emulate mobile sets the size of the selected tab; emulate mobile off first');
|
|
1337
1546
|
await state.page.setViewportSize({ width: w, height: h });
|
|
1338
1547
|
out.log(`Viewport set to ${w}x${h}`);
|
|
1339
1548
|
},
|
|
1340
1549
|
|
|
1550
|
+
// Per tab, like network: the settings live in the tab's kept CDP session.
|
|
1551
|
+
async emulate(args) {
|
|
1552
|
+
const words = (args || '').trim().split(/\s+/).filter(Boolean);
|
|
1553
|
+
if (!words.length) return showEmulation();
|
|
1554
|
+
const usage = 'Usage: emulate [mobile [device] | dark | light | locale <tag> | timezone <zone>], emulate <what> off, emulate off';
|
|
1555
|
+
const [what, ...rest] = words;
|
|
1556
|
+
const value = rest.join(' ');
|
|
1557
|
+
const current = emulations.get(state.page) || {};
|
|
1558
|
+
if (what === 'off' && !rest.length) {
|
|
1559
|
+
const was = emulatedKinds(current);
|
|
1560
|
+
await setEmulation(state.page, {});
|
|
1561
|
+
out.log(was.length ? `Stopped emulating in the selected tab: ${was.join(', ')}` : 'Nothing was emulated in the selected tab.');
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
if (!EMULATE_KINDS.includes(what)) throw new Error(usage);
|
|
1565
|
+
const key = { mobile: 'device', dark: 'scheme', light: 'scheme', locale: 'locale', timezone: 'timezone' }[what];
|
|
1566
|
+
if (value === 'off') {
|
|
1567
|
+
await setEmulation(state.page, { ...current, [key]: undefined });
|
|
1568
|
+
out.log(`Stopped emulating ${key === 'scheme' ? 'the color scheme' : what} in the selected tab`);
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
let next;
|
|
1572
|
+
if (what === 'mobile') {
|
|
1573
|
+
next = deviceNamed(value || DEFAULT_DEVICE);
|
|
1574
|
+
} else if (what === 'dark' || what === 'light') {
|
|
1575
|
+
if (value) throw new Error(usage);
|
|
1576
|
+
next = what;
|
|
1577
|
+
} else if (!value || rest.length > 1) {
|
|
1578
|
+
throw new Error(`Usage: emulate ${what} ${what === 'locale' ? '<tag>, e.g. fr-FR' : '<zone>, e.g. Asia/Tokyo'}`);
|
|
1579
|
+
} else if (what === 'locale') {
|
|
1580
|
+
try { next = Intl.getCanonicalLocales(value)[0]; } catch { throw new Error(`Not a locale: ${value}; e.g. fr-FR, de, pt-BR`); }
|
|
1581
|
+
} else {
|
|
1582
|
+
// Checked, but kept as given: Node's names can be older ones (Asia/Calcutta for Asia/Kolkata).
|
|
1583
|
+
try { new Intl.DateTimeFormat('en-US', { timeZone: value }); } catch { throw new Error(`Not a timezone: ${value}; e.g. Asia/Tokyo, America/New_York, UTC`); }
|
|
1584
|
+
next = value;
|
|
1585
|
+
}
|
|
1586
|
+
await setEmulation(state.page, { ...current, [key]: next });
|
|
1587
|
+
const shown = what === 'mobile' ? `mobile (${deviceText(next)})` : what === 'dark' || what === 'light' ? `${what} mode` : `${what} ${next}`;
|
|
1588
|
+
out.log(`Emulating ${shown} in the selected tab`);
|
|
1589
|
+
// The page reads these when it loads; the rest applies at once.
|
|
1590
|
+
if (what === 'mobile' || what === 'locale') out.log('The page sees its user agent, touch and languages from its next load: reload to see all of it.');
|
|
1591
|
+
},
|
|
1592
|
+
|
|
1341
1593
|
async wait(args) {
|
|
1342
|
-
const usage = 'Usage: wait <selector> | wait text <text> | wait request <url-part|glob
|
|
1594
|
+
const usage = 'Usage: wait <selector> | wait text <text> | wait request <url-part|glob> | wait load, with optional [seconds]; --gone for a selector or text';
|
|
1343
1595
|
let tokens = (args || '').trim().split(/\s+/).filter(Boolean);
|
|
1596
|
+
const gone = tokens.includes('--gone');
|
|
1597
|
+
tokens = tokens.filter(t => t !== '--gone');
|
|
1344
1598
|
let seconds = WAIT_DEFAULT;
|
|
1345
1599
|
if (tokens.length > 1 && /^\d+$/.test(tokens[tokens.length - 1])) seconds = Number(tokens.pop());
|
|
1346
1600
|
if (!tokens.length || seconds < 1 || seconds > WAIT_MAX) throw new Error(`${usage} (1-${WAIT_MAX})`);
|
|
1347
1601
|
const timeout = seconds * 1000;
|
|
1602
|
+
// Said plainly, not as Playwright's call log.
|
|
1603
|
+
const plainly = message => error => { throw /Timeout \d+ms exceeded/.test(error.message) ? new Error(message) : error; };
|
|
1348
1604
|
const kind = tokens[0];
|
|
1605
|
+
if (kind === 'load' && tokens.length === 1) {
|
|
1606
|
+
if (gone) throw new Error(usage);
|
|
1607
|
+
return waitForLoad(timeout);
|
|
1608
|
+
}
|
|
1349
1609
|
if ((kind === 'text' || kind === 'request') && tokens.length > 1) {
|
|
1350
1610
|
const what = tokens.slice(1).join(' ').replace(/^(["'])(.*)\1$/, '$2').replace(/\\"/g, '"');
|
|
1351
|
-
if (kind === '
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
return;
|
|
1611
|
+
if (kind === 'request') {
|
|
1612
|
+
if (gone) throw new Error(usage);
|
|
1613
|
+
return waitForRequest(what, timeout);
|
|
1355
1614
|
}
|
|
1356
|
-
return
|
|
1615
|
+
if (gone) return waitGone(state.page.getByText(what), what, timeout);
|
|
1616
|
+
await state.page.getByText(what).first().waitFor({ state: 'visible', timeout }).catch(plainly(`No visible text matches ${what} within ${seconds}s`));
|
|
1617
|
+
out.log(`Visible: ${what}`);
|
|
1618
|
+
return;
|
|
1357
1619
|
}
|
|
1358
1620
|
const selector = toSelector(unquote(tokens.join(' ')));
|
|
1359
|
-
|
|
1621
|
+
if (gone) return waitGone(state.page.locator(selector), tokens.join(' '), timeout);
|
|
1622
|
+
await state.page.waitForSelector(selector, { state: 'attached', timeout }).catch(plainly(`No element matches ${tokens.join(' ')} within ${seconds}s`));
|
|
1360
1623
|
out.log(`Found: ${tokens.join(' ')}`);
|
|
1361
1624
|
},
|
|
1362
1625
|
|
|
@@ -1369,22 +1632,28 @@ const commands = {
|
|
|
1369
1632
|
// A stopped upstream does not reach the browser as a failure: the dev proxy
|
|
1370
1633
|
// holds the request open instead of refusing it. Cutting the connection at
|
|
1371
1634
|
// the browser is what a visitor's wifi or VPN dropping looks like to the page.
|
|
1372
|
-
// The CDP session is kept alive because emulation resets when it detaches.
|
|
1373
1635
|
async network(args) {
|
|
1374
|
-
const
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1636
|
+
const words = (args || '').trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
1637
|
+
const setting = networkChanged.get(state.page);
|
|
1638
|
+
if (!words.length) {
|
|
1639
|
+
out.log(`The selected tab's network is ${setting ? networkText(setting) : 'on'}.`);
|
|
1640
|
+
if (setting) out.log(hints([['network on', 'restore it']]));
|
|
1641
|
+
else out.log(hints([['network off', 'cut it, like dropped wifi'], ['network slow [<ms> [<kbps>]]', `slow it (default ${networkText(SLOW_DEFAULT).slice(6, -1)})`]]));
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1644
|
+
const [how, ...rest] = words;
|
|
1645
|
+
if ((how === 'on' || how === 'off') && !rest.length) {
|
|
1646
|
+
await setNetwork(state.page, how === 'on' ? null : { offline: true });
|
|
1647
|
+
out.log(`The selected tab's network is ${how}`);
|
|
1383
1648
|
return;
|
|
1384
1649
|
}
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1650
|
+
const usage = `Usage: network [on | off | slow [<latency-ms> [<kbps>]]] (latency up to ${SLOW_LATENCY_MAX}ms)`;
|
|
1651
|
+
if (how !== 'slow' || rest.length > 2 || !rest.every(w => /^\d+$/.test(w))) throw new Error(usage);
|
|
1652
|
+
const [latency, kbps] = rest.map(Number);
|
|
1653
|
+
if (latency > SLOW_LATENCY_MAX || kbps === 0) throw new Error(usage);
|
|
1654
|
+
const slow = rest.length ? { latency, down: kbps || SLOW_DEFAULT.down, up: kbps || SLOW_DEFAULT.up } : SLOW_DEFAULT;
|
|
1655
|
+
await setNetwork(state.page, slow);
|
|
1656
|
+
out.log(`The selected tab's network is ${networkText(slow)}`);
|
|
1388
1657
|
},
|
|
1389
1658
|
|
|
1390
1659
|
// Fulfilled inside the browser, so the page's own code handles the fake
|
|
@@ -1620,6 +1889,7 @@ const commands = {
|
|
|
1620
1889
|
};
|
|
1621
1890
|
|
|
1622
1891
|
onShutdown(discardCapture);
|
|
1892
|
+
onShutdown(resetEmulations);
|
|
1623
1893
|
|
|
1624
1894
|
// Hooks every page needs from the moment the REPL sees it.
|
|
1625
1895
|
function watchPage(p) {
|
|
@@ -1642,7 +1912,9 @@ function complete(line) {
|
|
|
1642
1912
|
if (words.length === 2) {
|
|
1643
1913
|
if (command === 'help') return match(['--all', ...Object.keys(HELP.TOPICS), ...Object.keys(HELP.COMMANDS).sort()]);
|
|
1644
1914
|
if (command === 'tab') return match(['new', 'close']);
|
|
1645
|
-
if (command === 'network') return match(['on', 'off']);
|
|
1915
|
+
if (command === 'network') return match(['on', 'off', 'slow']);
|
|
1916
|
+
if (command === 'emulate') return match([...EMULATE_KINDS, 'off']);
|
|
1917
|
+
if (command === 'wait') return match(['text', 'request', 'load']);
|
|
1646
1918
|
if (command === 'modes') return match(['off']);
|
|
1647
1919
|
if (command === 'dialog') return match(['accept', 'dismiss']);
|
|
1648
1920
|
if (command === 'watch') return match(['on', 'off', 'new', '--all']);
|
|
@@ -1651,6 +1923,7 @@ function complete(line) {
|
|
|
1651
1923
|
}
|
|
1652
1924
|
if (words.length >= 3 && command === 'watch' && words[1] === 'on') return match(['--changes', '--live'].filter(f => !words.slice(2, -1).includes(f)));
|
|
1653
1925
|
if (words.length === 3 && command === 'capture' && words[1] === 'on') return match(['requests', 'console']);
|
|
1926
|
+
if (words.length === 3 && command === 'emulate' && EMULATE_KINDS.includes(words[1])) return match(['off']);
|
|
1654
1927
|
if (words.length === 3 && command === 'route' && words[1] === 'off') return match(['--all', ...(state.page ? routesFor(state.page).keys() : [])]);
|
|
1655
1928
|
return [[], current];
|
|
1656
1929
|
}
|
package/lib/help.js
CHANGED
|
@@ -10,6 +10,9 @@ Common tasks:
|
|
|
10
10
|
where am I? tab, info
|
|
11
11
|
what is on the page? snapshot, screenshot
|
|
12
12
|
do something on it click, fill, press
|
|
13
|
+
wait for a page or element wait load, wait <selector>, wait <selector> --gone
|
|
14
|
+
choose a file in a file input upload <selector> <file>
|
|
15
|
+
see it as a phone, or dark emulate mobile, emulate dark
|
|
13
16
|
what did the page request? requests, then body <#> for what one got back
|
|
14
17
|
console messages and errors console
|
|
15
18
|
show an agent what I do watch on, click around, then watch
|
|
@@ -17,11 +20,12 @@ Common tasks:
|
|
|
17
20
|
requests and console together capture on, then capture off
|
|
18
21
|
break the backend on purpose route <glob> <status> <json>, route <glob> abort, network off
|
|
19
22
|
change or slow an API response route <glob> patch <json>, route <glob> delay <secs>
|
|
23
|
+
slow the whole network network slow
|
|
20
24
|
clean up modes off
|
|
21
25
|
|
|
22
|
-
Modes (watch, capture, route, network off) stay on until turned off; the prompt shows
|
|
23
|
-
(watch routes:1) pw>. tab, watch, capture, route, network and modes on
|
|
24
|
-
what you can run next.
|
|
26
|
+
Modes (watch, capture, route, network off or slow, emulate) stay on until turned off; the prompt shows
|
|
27
|
+
the selected tab's: (watch routes:1) pw>. tab, watch, capture, route, network, emulate and modes on
|
|
28
|
+
their own show their state and what you can run next.
|
|
25
29
|
|
|
26
30
|
Topics (help <topic>):
|
|
27
31
|
tabs open, select, close, navigate network requests, bodies, console, fakes, network off
|
|
@@ -37,18 +41,18 @@ const TOPICS = {
|
|
|
37
41
|
},
|
|
38
42
|
interact: {
|
|
39
43
|
intro: 'Selectors are Playwright selectors (CSS, text=..., role=...) or snapshot refs such as e5.\nCommands use the first match; see help fill for selectors with spaces.',
|
|
40
|
-
commands: ['click', 'dblclick', 'hover', 'fill', 'type', 'press', 'select', 'check', 'uncheck'],
|
|
44
|
+
commands: ['click', 'dblclick', 'hover', 'fill', 'type', 'press', 'select', 'check', 'uncheck', 'upload'],
|
|
41
45
|
},
|
|
42
46
|
inspect: {
|
|
43
47
|
intro: 'Output is capped; put --all right after the command for everything (e.g. text --all body).',
|
|
44
|
-
commands: ['snapshot', 'watch', 'text', 'html', 'attrs', 'listeners', 'count', 'visible', 'links', 'inputs', 'screenshot', 'viewport', 'wait', 'sleep'],
|
|
48
|
+
commands: ['snapshot', 'watch', 'text', 'html', 'attrs', 'listeners', 'count', 'visible', 'links', 'inputs', 'screenshot', 'viewport', 'emulate', 'wait', 'sleep'],
|
|
45
49
|
},
|
|
46
50
|
network: {
|
|
47
51
|
intro: 'requests and console record all the time; a capture records only while it runs.',
|
|
48
52
|
commands: ['requests', 'body', 'console', 'capture', 'route', 'network'],
|
|
49
53
|
},
|
|
50
54
|
devtools: {
|
|
51
|
-
intro: 'Cookie and storage listings omit values. eval, html, screenshots and URLs can still show
|
|
55
|
+
intro: 'Cookie and storage listings omit values. eval, cdp, html, screenshots and URLs can still show\nsensitive data (cdp Network.getCookies shows cookie values).',
|
|
52
56
|
commands: ['eval', 'cdp', 'cookies', 'storage'],
|
|
53
57
|
},
|
|
54
58
|
session: {
|
|
@@ -80,8 +84,12 @@ const COMMANDS = {
|
|
|
80
84
|
summary: 'navigate the selected tab',
|
|
81
85
|
detail: 'Without a scheme, http:// is assumed for localhost, 127.x and [::1], and https:// otherwise, as in\nthe address bar. tab new <url> reads its URL the same way.',
|
|
82
86
|
},
|
|
83
|
-
back: {
|
|
84
|
-
|
|
87
|
+
back: {
|
|
88
|
+
usage: 'back',
|
|
89
|
+
summary: 'go back one history entry',
|
|
90
|
+
detail: 'The browser may bring the page back from its back/forward cache, as it was: nothing loads or runs\nagain, so a loading message or a request the page makes on load does not come back. reload loads it\nafresh.',
|
|
91
|
+
},
|
|
92
|
+
forward: { usage: 'forward', summary: 'go forward one history entry', detail: 'Like back, it may bring the page back from the back/forward cache without loading it again.' },
|
|
85
93
|
reload: { usage: 'reload', summary: 'reload the selected tab' },
|
|
86
94
|
info: { usage: 'info', summary: 'selected tab URL, title and viewport' },
|
|
87
95
|
click: { usage: 'click <selector>', summary: 'click the first match' },
|
|
@@ -97,10 +105,15 @@ const COMMANDS = {
|
|
|
97
105
|
select: { usage: 'select <selector> <value>', summary: 'choose an option in a select (the value is the rest of the line)' },
|
|
98
106
|
check: { usage: 'check <selector>', summary: 'check a checkbox' },
|
|
99
107
|
uncheck: { usage: 'uncheck <selector>', summary: 'uncheck a checkbox' },
|
|
108
|
+
upload: {
|
|
109
|
+
usage: 'upload <selector> <file>...',
|
|
110
|
+
summary: 'choose files in a file input, as the file picker would',
|
|
111
|
+
detail: 'The selector is a file input (even a hidden one), its label, or a button that opens the file\npicker. The page then does what it does with a chosen file; often that is the upload itself.\n\nThe REPL reads the files and hands the page their contents, so they need not be where the browser\nruns; up to 50MB in all. Relative paths are from the folder pw-repl send runs in, or the REPL\'s own\nfor a command typed at its prompt. Quote a path with spaces.',
|
|
112
|
+
},
|
|
100
113
|
snapshot: {
|
|
101
114
|
usage: 'snapshot [--full] [--grep <text> | <eN> | selector]',
|
|
102
115
|
summary: 'outline by role and name, with [ref=eN] labels',
|
|
103
|
-
detail: 'Playwright\'s accessibility snapshot, with unnamed layout wrappers (generic) and cursor hints left\nout; --full shows it unchanged.\n\n--grep <text> prints only the lines containing text (role, name or flag such as [disabled], any\ncase), each with the named elements around it. A hit with no named element around it prints without\na path.\n\nsnapshot e3 outlines one element. A ref (e3, or f1e3 in newer Playwright) works as a selector in any\ncommand: click e3. Refs can change when the page changes (e102 may become f4e98), so take a new\nsnapshot after it does.\n\nOutput over 60 lines ends with a line count.',
|
|
116
|
+
detail: 'Playwright\'s accessibility snapshot, with unnamed layout wrappers (generic) and cursor hints left\nout; --full shows it unchanged.\n\n--grep <text> prints only the lines containing text (role, name or flag such as [disabled], any\ncase), each with the named elements around it. A hit with no named element around it prints without\na path. A hit on text (a label) keeps the ref of the element holding it, so snapshot <that ref>\nshows what sits beside it, such as the value next to the label.\n\nsnapshot e3 outlines one element. A ref (e3, or f1e3 in newer Playwright) works as a selector in any\ncommand: click e3. Refs can change when the page changes (e102 may become f4e98), so take a new\nsnapshot after it does.\n\nOutput over 60 lines ends with a line count.',
|
|
104
117
|
},
|
|
105
118
|
watch: {
|
|
106
119
|
usage: 'watch on [--changes] [--live] | off | [n|new]',
|
|
@@ -122,13 +135,18 @@ const COMMANDS = {
|
|
|
122
135
|
screenshot: {
|
|
123
136
|
usage: 'screenshot [--full] [--delay|-d <seconds>] [name]',
|
|
124
137
|
summary: 'save a PNG of the viewport (or --full page)',
|
|
125
|
-
detail: 'Saved as screenshot-<name or timestamp>.png in $PW_SCREENSHOT_DIR (default /tmp). It brings the tab to\nthe front of its window first: Chrome draws only the tab in front.\n\n--delay counts down out loud first (maximum 60s), so someone can hold a hover or open a menu.',
|
|
138
|
+
detail: 'Saved as screenshot-<name or timestamp>.png in $PW_SCREENSHOT_DIR (default /tmp). It brings the tab to\nthe front of its window first: Chrome draws only the tab in front. The image is at CSS pixel size,\none image pixel per CSS pixel, also while emulate mobile is on.\n\n--delay counts down out loud first (maximum 60s), so someone can hold a hover or open a menu.',
|
|
126
139
|
},
|
|
127
140
|
viewport: { usage: 'viewport [WxH]', summary: 'show or set the viewport size' },
|
|
141
|
+
emulate: {
|
|
142
|
+
usage: 'emulate [<what> [off] | off]',
|
|
143
|
+
summary: 'emulate a phone, dark mode, a locale or a timezone',
|
|
144
|
+
detail: 'emulate mobile [device] a phone\'s screen, touch and user agent: a Pixel 7, or a device named as in\n Playwright\'s list, e.g. emulate mobile iPhone 13; a name that is not\n exact lists the devices it matches (emulate mobile galaxy)\nemulate dark | light the color scheme the page\'s CSS and matchMedia see\nemulate locale <tag> the language (Accept-Language, navigator.language) and date and number\n formats, e.g. fr-FR\nemulate timezone <zone> an IANA timezone, e.g. Asia/Tokyo\nemulate <what> off stop one (dark or light off stops the color scheme); emulate off stops all\n\nPer tab, until turned off or the REPL exits. emulate on its own shows what is on.\n\nThe page sees the user agent, touch and navigator.languages from its next load: reload after\nturning mobile or locale on or off. While mobile is on, viewport refuses: the device sets the size.',
|
|
145
|
+
},
|
|
128
146
|
wait: {
|
|
129
|
-
usage: 'wait [text|request] <what> [secs]',
|
|
130
|
-
summary: 'wait for an element, text, or a
|
|
131
|
-
detail: 'wait <selector> waits for a matching element; wait text <text> for text to be visible; wait request\n<url-part|glob> for a matching response, counting one that finished since the previous command began\n(so click, then wait request, does not miss it).\n\nUp to 120s. A wait that times out is an error; the REPL carries on.',
|
|
147
|
+
usage: 'wait [text|request] <what> [--gone] [secs]',
|
|
148
|
+
summary: 'wait for an element, text, a response or a load (10s)',
|
|
149
|
+
detail: 'wait <selector> waits for a matching element; wait text <text> for text to be visible; wait request\n<url-part|glob> for a matching response, counting one that finished since the previous command began\n(so click, then wait request, does not miss it).\n\nwait load waits for the page to finish loading (its load event). A navigation that began since the\nprevious command began counts, so click a link, then wait load, waits for the new page. A page\nthat changes its own URL without loading (an app\'s own routing) is not a load: wait for something on\nthe new view instead.\n\nwait text matches any part of an element\'s text, in any case.\n\n--gone waits instead until nothing matching is visible (removed or hidden): wait .spinner --gone.\nIt is done at once if nothing matches yet, so wait for it to appear first if it may not have.\n\nUp to 120s. A wait that times out is an error; the REPL carries on.',
|
|
132
150
|
},
|
|
133
151
|
sleep: { usage: 'sleep <ms>', summary: 'wait a fixed time (maximum 3600000)' },
|
|
134
152
|
requests: {
|
|
@@ -144,7 +162,7 @@ const COMMANDS = {
|
|
|
144
162
|
console: {
|
|
145
163
|
usage: 'console [--all] [n] [filter]',
|
|
146
164
|
summary: 'the last n console messages and page errors',
|
|
147
|
-
detail: 'Recording starts when the REPL connects; the last 200 per tab are kept. Each line: time, [type],\ntext. Types are console levels (log, warning, error, ...) and pageerror for uncaught exceptions.\n\nfilter matches the type or the text, e.g. console error.',
|
|
165
|
+
detail: 'Recording starts when the REPL connects; the last 200 per tab are kept. Each line: time, [type],\ntext. Types are console levels (log, warning, error, ...) and pageerror for uncaught exceptions.\n\nfilter matches the type or the text, e.g. console error.\n\nA page brought back from the back/forward cache (back, forward) reports its earlier messages again,\nat the time it comes back, though nothing loaded again.',
|
|
148
166
|
},
|
|
149
167
|
capture: {
|
|
150
168
|
usage: 'capture on [requests|console] [secs] | off',
|
|
@@ -157,9 +175,9 @@ const COMMANDS = {
|
|
|
157
175
|
detail: 'route <glob> <status> <json> answer with this status (200-599) and JSON; it never reaches the network,\n so it still answers while the network is off\nroute <glob> patch <json> let it through, then change its JSON response: a JSON Merge Patch, where\n objects merge, null removes a key and anything else replaces\nroute <glob> delay <secs> hold it for up to 120 seconds, then let it through\nroute <glob> abort fail it as if the connection broke\n\nEach matching request prints a line (Faked:, Patched:, Delayed:, Aborted:) in the REPL window, and in\nthe answer to a command running then, with its number as in requests; requests shows a fake as\n<status> faked and a patch as <status> patched. If a route fails it prints "Route failed" and aborts\nthe request.\n\nRoutes belong to the tab and last until route off <glob> (or route off --all) or the REPL exits.\nRouting the same glob again replaces it. route on its own lists the selected tab\'s routes.\n\nExample: route **/api/cart patch {"total": 0}',
|
|
158
176
|
},
|
|
159
177
|
network: {
|
|
160
|
-
usage: 'network [on|off]',
|
|
161
|
-
summary: 'cut or restore the tab\'s network
|
|
162
|
-
detail: 'Stopping a service is not the same: a dev proxy in front of
|
|
178
|
+
usage: 'network [on|off|slow [<ms> [<kbps>]]]',
|
|
179
|
+
summary: 'cut, slow or restore the tab\'s network',
|
|
180
|
+
detail: 'network off cuts it, like dropped wifi. Stopping a service is not the same: a dev proxy in front of\nit usually holds the request open, so the page spins instead of failing.\n\nnetwork slow makes each request take at least <ms> (Chrome\'s latency is a minimum, not added to a\nslow server\'s own time) and limits its speed: by default as DevTools\' Slow 4G (563ms, 1440 kbps\ndown, 675 up); network slow <ms> [<kbps>] sets them, kbps both ways (left out, it stays Slow\n4G\'s). To slow one API, use route <glob> delay <secs>.\n\nPer tab; lasts until network on or the REPL exits. Routes still answer while it is off or slow.',
|
|
163
181
|
},
|
|
164
182
|
eval: {
|
|
165
183
|
usage: 'eval [--all] <JavaScript>',
|
|
@@ -176,7 +194,7 @@ const COMMANDS = {
|
|
|
176
194
|
modes: {
|
|
177
195
|
usage: 'modes [off]',
|
|
178
196
|
summary: 'the modes on in every tab; modes off turns them all off',
|
|
179
|
-
detail: 'The modes are watch, network off, route and
|
|
197
|
+
detail: 'The modes are watch, network off or slow, route, capture and emulate. Each is turned on and off with\nits own command: watch on|off, network off|slow|on, route <glob> ... | route off <glob>|--all,\ncapture on|off, emulate ... | emulate off.\n\nmodes off turns off every one in every tab; a capture it stops is kept for capture to show.',
|
|
180
198
|
},
|
|
181
199
|
dialog: {
|
|
182
200
|
usage: 'dialog [accept [text] | dismiss]',
|
package/lib/send.js
CHANGED
|
@@ -117,7 +117,8 @@ async function where(options) {
|
|
|
117
117
|
// use, so a REPL that will not start can be told apart from one that is not running.
|
|
118
118
|
const cdp = info?.browser || process.env.PW_CDP_URL || 'http://localhost:9222';
|
|
119
119
|
const version = await fetch(`${cdp}/json/version`, { signal: AbortSignal.timeout(2000) }).then(r => r.json()).catch(() => null);
|
|
120
|
-
|
|
120
|
+
// With no REPL running, it is only where one would connect, not a browser of a REPL that stopped.
|
|
121
|
+
const whose = info?.launched ? ', launched by this REPL' : info ? '' : '; run and serve connect here without --launch';
|
|
121
122
|
console.log(version ? `browser: ${cdp} answers (${version.Browser}${whose})` : `browser: nothing answers at ${cdp}; pw-repl run says how to start one`);
|
|
122
123
|
if (way.kind === 'server') {
|
|
123
124
|
const name = client.describe(way.endpoint);
|
package/lib/state.js
CHANGED
|
@@ -52,7 +52,8 @@ async function shutdown() {
|
|
|
52
52
|
if (shutdownPromise) return shutdownPromise;
|
|
53
53
|
state.stopping = true;
|
|
54
54
|
shutdownPromise = (async () => {
|
|
55
|
-
|
|
55
|
+
// Some undo what the REPL changed in the browser, so they finish first.
|
|
56
|
+
await withTimeout(Promise.all(cleanups.map(async fn => fn())), 'Cleanup').catch(() => {});
|
|
56
57
|
if (state.browser) {
|
|
57
58
|
try { await withTimeout(state.browser.close(), 'Chromium shutdown'); }
|
|
58
59
|
catch (error) { state.shutdownFailed = true; process.exitCode = 1; out.error(`Could not confirm Chromium shutdown: ${error.message}`); }
|
package/lib/syntax.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// Commands whose first word is a selector, quoted if it has spaces, and whose
|
|
4
4
|
// value is the rest of the line. Other commands take the rest of the line as it is.
|
|
5
|
-
const SELECTOR_FIRST = new Set(['fill', 'type', 'select', 'press']);
|
|
5
|
+
const SELECTOR_FIRST = new Set(['fill', 'type', 'select', 'press', 'upload']);
|
|
6
6
|
|
|
7
7
|
// A snapshot ref (e5, or f1e5 in newer Playwright) stands for aria-ref=e5.
|
|
8
8
|
const REF = /^(?:f\d+)?e\d+$/;
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pw-repl
|
|
3
|
-
description: Inspect and drive a Chromium browser, possibly one a person is using, through the pw-repl (playwright-repl) REPL - tabs, page snapshots, clicks and typing, requests and their bodies, console messages,
|
|
3
|
+
description: Inspect and drive a Chromium browser, possibly one a person is using, through the pw-repl (playwright-repl) REPL - tabs, page snapshots, clicks and typing, requests and their bodies, console messages, waiting for pages and elements, choosing files, faking, patching or delaying responses, cutting or slowing the network, emulating a phone, dark mode, a locale or a timezone, and recording what the person does. Use when asked to look at, debug or test something in a web page, in an existing Chromium with remote debugging or in one it starts itself.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# pw-repl
|
|
@@ -47,7 +47,7 @@ listens on TCP 127.0.0.1 instead of a socket, with no access control.
|
|
|
47
47
|
Several REPLs can run at once, each on its own socket, e.g. one per agent. Each has its own selected tab,
|
|
48
48
|
command queue and modes, so they do not wait on or select for each other. They share the browser,
|
|
49
49
|
though: each sees every tab, `modes` lists only its own REPL's modes, and two REPLs acting on the same
|
|
50
|
-
tab can undo each other's routes or
|
|
50
|
+
tab can undo each other's routes, network or emulation settings.
|
|
51
51
|
|
|
52
52
|
A REPL in a terminal stops at its prompt (`quit`, or Ctrl-C); `send quit` is refused.
|
|
53
53
|
|
|
@@ -59,9 +59,10 @@ pw-repl send -t 90 'screenshot -d 60' # wait longer than the 20s default
|
|
|
59
59
|
```
|
|
60
60
|
|
|
61
61
|
Always send commands with `pw-repl send`; don't type into the pane yourself. The words after `send`
|
|
62
|
-
are the command. For `fill`, `type`, `select` and `
|
|
63
|
-
(`pw-repl send fill "text=Your name" Ada`); other commands get the words as they are
|
|
64
|
-
(`pw-repl send eval "document.title + ' x'"`).
|
|
62
|
+
are the command. For `fill`, `type`, `select`, `press` and `upload`, a word quoted in your shell stays
|
|
63
|
+
one word (`pw-repl send fill "text=Your name" Ada`); other commands get the words as they are, joined by
|
|
64
|
+
spaces (`pw-repl send eval "document.title + ' x'"`). A command that takes only a selector takes the whole
|
|
65
|
+
line, spaces and all: `pw-repl send click "text=Your name"`. It works however the REPL was started:
|
|
65
66
|
|
|
66
67
|
- `run` (in tmux): `send` types the command into the tmux pane and reads the result back off the screen.
|
|
67
68
|
It types only when the pane's last line is a bare prompt, so nothing lands in a shell or in the middle
|
|
@@ -114,8 +115,9 @@ help is the command reference; this file does not repeat it.
|
|
|
114
115
|
one works too (`--headless=new`); nobody answers its dialogs but `dialog`.
|
|
115
116
|
- `PW_CHROME` — the Chromium `--launch` starts (default: Playwright's own, then one on the `PATH`).
|
|
116
117
|
- `PW_CDP_URL` — CDP endpoint (default `http://localhost:9222`).
|
|
117
|
-
- `PW_SCREENSHOT_DIR` — where screenshots
|
|
118
|
-
|
|
118
|
+
- `PW_SCREENSHOT_DIR` — where the REPL saves screenshots (default `/tmp`), read when it starts, not by
|
|
119
|
+
`send`. `screenshot` prints each file's path; other REPLs may save theirs there too. Remove only your
|
|
120
|
+
own, and keep those too if they are needed, e.g. as evidence or as something to hand over.
|
|
119
121
|
- `PW_ENDPOINT` / `PW_TMUX_SESSION` — defaults for `send -e` / `-s`. `PW_SOCKET` — the socket `send`
|
|
120
122
|
looks for when neither is given (default `/tmp/playwright-repl.sock`).
|
|
121
123
|
|