pw-repl 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +10 -6
- package/README.md +13 -9
- package/bin/pw-repl.js +3 -1
- package/lib/commands.js +281 -39
- package/lib/help.js +26 -12
- package/lib/state.js +2 -1
- package/lib/syntax.js +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +2 -2
package/AGENTS.md
CHANGED
|
@@ -11,13 +11,17 @@ 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
|
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);
|
|
@@ -166,25 +169,122 @@ 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
|
+
function deviceNamed(name) {
|
|
216
|
+
const found = Object.keys(devices).find(d => d.toLowerCase() === name.toLowerCase());
|
|
217
|
+
if (!found) throw new Error(`No device ${JSON.stringify(name)}; names are Playwright's, e.g. Pixel 7, iPhone 13, iPad Mini, Galaxy S9+`);
|
|
218
|
+
return found;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function deviceText(name) {
|
|
222
|
+
const d = devices[name];
|
|
223
|
+
return `${name}, ${d.viewport.width}x${d.viewport.height} at ${d.deviceScaleFactor}x${d.hasTouch ? ', touch' : ''}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Sends only what changed. Chrome takes the user agent and the languages
|
|
227
|
+
// together, so a change to either sends both.
|
|
228
|
+
async function setEmulation(p, next) {
|
|
229
|
+
const session = await keptSession(p);
|
|
230
|
+
const previous = emulations.get(p) || {};
|
|
231
|
+
const send = (method, params) => session.send(method, params);
|
|
232
|
+
if (next.device !== previous.device) {
|
|
233
|
+
const d = devices[next.device];
|
|
234
|
+
if (d) {
|
|
235
|
+
await send('Emulation.setDeviceMetricsOverride', {
|
|
236
|
+
width: d.viewport.width, height: d.viewport.height, deviceScaleFactor: d.deviceScaleFactor, mobile: d.isMobile,
|
|
237
|
+
screenWidth: d.screen?.width || d.viewport.width, screenHeight: d.screen?.height || d.viewport.height,
|
|
238
|
+
});
|
|
239
|
+
} else {
|
|
240
|
+
await send('Emulation.clearDeviceMetricsOverride');
|
|
241
|
+
}
|
|
242
|
+
await send('Emulation.setTouchEmulationEnabled', d?.hasTouch ? { enabled: true, maxTouchPoints: 5 } : { enabled: false });
|
|
243
|
+
}
|
|
244
|
+
if (next.device !== previous.device || next.locale !== previous.locale) {
|
|
245
|
+
if (next.device || next.locale) {
|
|
246
|
+
const userAgent = next.device ? devices[next.device].userAgent : (await send('Browser.getVersion')).userAgent;
|
|
247
|
+
await send('Emulation.setUserAgentOverride', { userAgent, ...(next.locale ? { acceptLanguage: next.locale } : {}) });
|
|
248
|
+
} else {
|
|
249
|
+
// An empty user agent ends the override.
|
|
250
|
+
await send('Emulation.setUserAgentOverride', { userAgent: '' });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (next.locale !== previous.locale) await send('Emulation.setLocaleOverride', next.locale ? { locale: next.locale } : {});
|
|
254
|
+
if (next.timezone !== previous.timezone) await send('Emulation.setTimezoneOverride', { timezoneId: next.timezone || '' });
|
|
255
|
+
if (next.scheme !== previous.scheme) await send('Emulation.setEmulatedMedia', { features: next.scheme ? [{ name: 'prefers-color-scheme', value: next.scheme }] : [] });
|
|
256
|
+
if (emulatedKinds(next).length) emulations.set(p, next); else emulations.delete(p);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function showEmulation() {
|
|
260
|
+
const e = emulations.get(state.page);
|
|
261
|
+
const forms = [
|
|
262
|
+
['emulate mobile [device]', `a phone's screen, touch and user agent (default ${DEFAULT_DEVICE})`],
|
|
263
|
+
['emulate dark | light', 'the color scheme the page sees'],
|
|
264
|
+
['emulate locale <tag>', 'its language and formats, e.g. fr-FR'],
|
|
265
|
+
['emulate timezone <zone>', 'e.g. Asia/Tokyo'],
|
|
266
|
+
];
|
|
267
|
+
if (!e) {
|
|
268
|
+
out.log('Nothing is emulated in the selected tab.');
|
|
269
|
+
out.log(hints(forms));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
out.log('Emulated in the selected tab:');
|
|
273
|
+
const rows = [
|
|
274
|
+
e.device && ['mobile', deviceText(e.device)],
|
|
275
|
+
e.scheme && ['color scheme', e.scheme],
|
|
276
|
+
e.locale && ['locale', e.locale],
|
|
277
|
+
e.timezone && ['timezone', e.timezone],
|
|
278
|
+
].filter(Boolean);
|
|
279
|
+
for (const [what, value] of rows) out.log(` ${what.padEnd(12)} ${value}`);
|
|
280
|
+
out.log(hints([...forms, ['emulate <what> off | emulate off', 'stop one, or all']]));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Resets every tab's emulation, which detaching would only partly undo.
|
|
284
|
+
async function resetEmulations() {
|
|
285
|
+
if (!state.browser || state.connectionLost) return;
|
|
286
|
+
const pages = state.browser.contexts().flatMap(c => c.pages()).filter(p => emulations.has(p) && !p.isClosed());
|
|
287
|
+
await Promise.all(pages.map(p => setEmulation(p, {}).catch(() => {})));
|
|
188
288
|
}
|
|
189
289
|
|
|
190
290
|
// page -> Map(glob -> { status, body, handler }). Playwright routes are
|
|
@@ -334,6 +434,8 @@ const consoleLogs = new WeakMap();
|
|
|
334
434
|
// request -> how a route changed it (faked or patched), shown after its status.
|
|
335
435
|
const changedRequests = new WeakMap();
|
|
336
436
|
const requestEntries = new WeakMap();
|
|
437
|
+
// page -> when its page last fired its load event.
|
|
438
|
+
const loadedAt = new WeakMap();
|
|
337
439
|
// Tabs this REPL opened with tab new.
|
|
338
440
|
const openedTabs = new WeakSet();
|
|
339
441
|
|
|
@@ -361,7 +463,9 @@ function ensureRecentLog(p) {
|
|
|
361
463
|
recentLogs.set(p, log);
|
|
362
464
|
consoleLogs.set(p, logs);
|
|
363
465
|
p.on('request', req => {
|
|
364
|
-
|
|
466
|
+
let navigation = false;
|
|
467
|
+
try { navigation = req.isNavigationRequest() && req.frame() === p.mainFrame(); } catch {}
|
|
468
|
+
const entry = { id: nextId++, t: Date.now(), method: req.method(), url: req.url(), type: req.resourceType(), status: 'pending', ms: null, response: null, navigation };
|
|
365
469
|
requestEntries.set(req, entry);
|
|
366
470
|
keep(log, entry);
|
|
367
471
|
});
|
|
@@ -382,6 +486,7 @@ function ensureRecentLog(p) {
|
|
|
382
486
|
finish(req, changedRequests.has(req) ? `${status} ${changedRequests.get(req)}` : status, res);
|
|
383
487
|
});
|
|
384
488
|
p.on('requestfailed', req => finish(req, `failed: ${req.failure()?.errorText || 'unknown'}`));
|
|
489
|
+
p.on('load', () => loadedAt.set(p, Date.now()));
|
|
385
490
|
p.on('console', msg => keep(logs, { t: Date.now(), type: msg.type(), text: clipText(consoleText(msg)) }));
|
|
386
491
|
// Uncaught exceptions never reach the console event.
|
|
387
492
|
p.on('pageerror', error => keep(logs, { t: Date.now(), type: 'pageerror', text: clipText(error.stack || error.message) }));
|
|
@@ -761,7 +866,10 @@ function stopWatching(p, watch) {
|
|
|
761
866
|
function activeModes(p) {
|
|
762
867
|
const modes = [];
|
|
763
868
|
if (watches.get(p)?.on) modes.push('watch');
|
|
764
|
-
|
|
869
|
+
const network = networkChanged.get(p);
|
|
870
|
+
if (network) modes.push(network.offline ? 'network:off' : 'network:slow');
|
|
871
|
+
const emulated = emulatedKinds(emulations.get(p));
|
|
872
|
+
if (emulated.length) modes.push(`emulate:${emulated.join(',')}`);
|
|
765
873
|
const routes = pageRoutes.get(p)?.size;
|
|
766
874
|
if (routes) modes.push(`routes:${routes}`);
|
|
767
875
|
if (cap && cap.page === p) modes.push('capture');
|
|
@@ -779,6 +887,7 @@ function listModes() {
|
|
|
779
887
|
['capture on', 'record requests and console messages together'],
|
|
780
888
|
['route <url-glob> <status> <json-body>', 'fake a response'],
|
|
781
889
|
['network off', 'cut the tab\'s network'],
|
|
890
|
+
['emulate mobile', 'show the tab as a phone would'],
|
|
782
891
|
]));
|
|
783
892
|
return;
|
|
784
893
|
}
|
|
@@ -803,7 +912,8 @@ async function allModesOff() {
|
|
|
803
912
|
if (cap && cap.page === p) { endCapture(); done.push('capture off (capture shows it)'); }
|
|
804
913
|
const globs = [...(pageRoutes.get(p)?.keys() || [])];
|
|
805
914
|
if (globs.length) { await unroute(p, globs); done.push(`${globs.length} route${globs.length === 1 ? '' : 's'} removed`); }
|
|
806
|
-
if (
|
|
915
|
+
if (networkChanged.has(p)) { await setNetwork(p, null); done.push('network on'); }
|
|
916
|
+
if (emulations.has(p)) { await setEmulation(p, {}); done.push('emulate off'); }
|
|
807
917
|
} catch (error) {
|
|
808
918
|
problem = error.message;
|
|
809
919
|
}
|
|
@@ -814,6 +924,19 @@ async function allModesOff() {
|
|
|
814
924
|
if (!any) out.log('No modes were on.');
|
|
815
925
|
}
|
|
816
926
|
|
|
927
|
+
// The type a page sees for a file it is given, from its extension.
|
|
928
|
+
const MIME_TYPES = {
|
|
929
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
|
|
930
|
+
pdf: 'application/pdf', txt: 'text/plain', csv: 'text/csv', json: 'application/json', html: 'text/html',
|
|
931
|
+
xml: 'application/xml', zip: 'application/zip', mp4: 'video/mp4', webm: 'video/webm', mp3: 'audio/mpeg',
|
|
932
|
+
wav: 'audio/wav', doc: 'application/msword', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
933
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
function mimeType(file) {
|
|
937
|
+
return MIME_TYPES[path.extname(file).slice(1).toLowerCase()] || 'application/octet-stream';
|
|
938
|
+
}
|
|
939
|
+
|
|
817
940
|
// Commands that take only a selector take the whole line, spaces and all.
|
|
818
941
|
function soleSelector(args, usage) {
|
|
819
942
|
const text = unquote((args || '').trim());
|
|
@@ -876,6 +999,35 @@ async function waitForRequest(pattern, timeout) {
|
|
|
876
999
|
}
|
|
877
1000
|
}
|
|
878
1001
|
|
|
1002
|
+
// Done once the page has loaded: after the latest navigation of the tab that
|
|
1003
|
+
// began since the previous command did (the click on a link, say), or, with
|
|
1004
|
+
// none, the document there now. Waiting on Playwright's load state alone would
|
|
1005
|
+
// see the page being left, which has already loaded.
|
|
1006
|
+
async function waitForLoad(timeout) {
|
|
1007
|
+
const p = state.page;
|
|
1008
|
+
const since = state.previousCommandAt || 0;
|
|
1009
|
+
const deadline = Date.now() + timeout;
|
|
1010
|
+
for (;;) {
|
|
1011
|
+
const navigation = (recentLogs.get(p) || []).filter(e => e.navigation && e.t >= since).pop();
|
|
1012
|
+
if (navigation && navigation.status.startsWith('failed')) throw new Error(`The page did not load: #${navigation.id} ${navigation.url} ${navigation.status}`);
|
|
1013
|
+
const loaded = navigation ? (loadedAt.get(p) || 0) >= navigation.t : await p.evaluate(() => document.readyState === 'complete').catch(() => false);
|
|
1014
|
+
if (loaded) { out.log(`Loaded: ${p.url()} — ${await p.title().catch(() => '')}`); return; }
|
|
1015
|
+
if (Date.now() >= deadline) throw new Error(`${p.url()} did not finish loading within ${timeout / 1000}s`);
|
|
1016
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Gone once no match is visible, whether it was removed or hidden.
|
|
1021
|
+
async function waitGone(locator, what, timeout) {
|
|
1022
|
+
try {
|
|
1023
|
+
await locator.filter({ visible: true }).first().waitFor({ state: 'detached', timeout });
|
|
1024
|
+
} catch (error) {
|
|
1025
|
+
if (/Timeout \d+ms exceeded/.test(error.message)) throw new Error(`Still visible after ${timeout / 1000}s: ${what}`);
|
|
1026
|
+
throw error;
|
|
1027
|
+
}
|
|
1028
|
+
out.log(`Gone: ${what}`);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
879
1031
|
const SNAPSHOT_HINT_LINES = 60;
|
|
880
1032
|
|
|
881
1033
|
// Each matching line (any part of it: role, name, flags such as [disabled]),
|
|
@@ -1175,6 +1327,32 @@ const commands = {
|
|
|
1175
1327
|
out.log(`Unchecked: ${args.trim()}`);
|
|
1176
1328
|
},
|
|
1177
1329
|
|
|
1330
|
+
// The files are read here and handed to the page as their contents, not
|
|
1331
|
+
// their paths: the browser may run on another machine, or in another
|
|
1332
|
+
// container, where the paths mean nothing.
|
|
1333
|
+
async upload(args) {
|
|
1334
|
+
const parsed = splitSelector(args);
|
|
1335
|
+
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');
|
|
1336
|
+
const files = [...parsed.rest.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g)].map(m => path.resolve(m[1] !== undefined ? m[1].replace(/\\(.)/g, '$1') : m[2] ?? m[3]));
|
|
1337
|
+
const payloads = files.map(file => {
|
|
1338
|
+
let buffer;
|
|
1339
|
+
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}`); }
|
|
1340
|
+
return { name: path.basename(file), mimeType: mimeType(file), buffer };
|
|
1341
|
+
});
|
|
1342
|
+
const { selector } = parsed;
|
|
1343
|
+
await onElement(selector, async () => {
|
|
1344
|
+
try {
|
|
1345
|
+
await state.page.setInputFiles(selector, payloads, { timeout: 5000 });
|
|
1346
|
+
} catch (error) {
|
|
1347
|
+
if (!/not an HTMLInputElement/.test(error.message)) throw error;
|
|
1348
|
+
// A button that opens the file picker itself: click it, and answer the picker.
|
|
1349
|
+
const [chooser] = await Promise.all([state.page.waitForEvent('filechooser', { timeout: 5000 }), state.page.click(selector, { timeout: 5000 })]);
|
|
1350
|
+
await chooser.setFiles(payloads);
|
|
1351
|
+
}
|
|
1352
|
+
});
|
|
1353
|
+
out.log(`Chose ${files.length === 1 ? 'a file' : `${files.length} files`} in ${parsed.word}: ${files.join(', ')}`);
|
|
1354
|
+
},
|
|
1355
|
+
|
|
1178
1356
|
async text(args, all) {
|
|
1179
1357
|
const selector = soleSelector(args, 'text <selector>');
|
|
1180
1358
|
printOutput(await onElement(selector, () => state.page.innerText(selector, { timeout: 5000 })), all);
|
|
@@ -1334,28 +1512,82 @@ const commands = {
|
|
|
1334
1512
|
if (!args) { out.log(await viewportText()); return; }
|
|
1335
1513
|
const [w, h] = args.split('x').map(Number);
|
|
1336
1514
|
if (!w || !h) throw new Error('Usage: viewport <width>x<height>');
|
|
1515
|
+
// Both set the screen size; whichever came last would win without saying so.
|
|
1516
|
+
if (emulations.get(state.page)?.device) throw new Error('emulate mobile sets the size of the selected tab; emulate mobile off first');
|
|
1337
1517
|
await state.page.setViewportSize({ width: w, height: h });
|
|
1338
1518
|
out.log(`Viewport set to ${w}x${h}`);
|
|
1339
1519
|
},
|
|
1340
1520
|
|
|
1521
|
+
// Per tab, like network: the settings live in the tab's kept CDP session.
|
|
1522
|
+
async emulate(args) {
|
|
1523
|
+
const words = (args || '').trim().split(/\s+/).filter(Boolean);
|
|
1524
|
+
if (!words.length) return showEmulation();
|
|
1525
|
+
const usage = 'Usage: emulate [mobile [device] | dark | light | locale <tag> | timezone <zone>], emulate <what> off, emulate off';
|
|
1526
|
+
const [what, ...rest] = words;
|
|
1527
|
+
const value = rest.join(' ');
|
|
1528
|
+
const current = emulations.get(state.page) || {};
|
|
1529
|
+
if (what === 'off' && !rest.length) {
|
|
1530
|
+
const was = emulatedKinds(current);
|
|
1531
|
+
await setEmulation(state.page, {});
|
|
1532
|
+
out.log(was.length ? `Stopped emulating in the selected tab: ${was.join(', ')}` : 'Nothing was emulated in the selected tab.');
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
if (!EMULATE_KINDS.includes(what)) throw new Error(usage);
|
|
1536
|
+
const key = { mobile: 'device', dark: 'scheme', light: 'scheme', locale: 'locale', timezone: 'timezone' }[what];
|
|
1537
|
+
if (value === 'off') {
|
|
1538
|
+
await setEmulation(state.page, { ...current, [key]: undefined });
|
|
1539
|
+
out.log(`Stopped emulating ${key === 'scheme' ? 'the color scheme' : what} in the selected tab`);
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
let next;
|
|
1543
|
+
if (what === 'mobile') {
|
|
1544
|
+
next = deviceNamed(value || DEFAULT_DEVICE);
|
|
1545
|
+
} else if (what === 'dark' || what === 'light') {
|
|
1546
|
+
if (value) throw new Error(usage);
|
|
1547
|
+
next = what;
|
|
1548
|
+
} else if (!value || rest.length > 1) {
|
|
1549
|
+
throw new Error(`Usage: emulate ${what} ${what === 'locale' ? '<tag>, e.g. fr-FR' : '<zone>, e.g. Asia/Tokyo'}`);
|
|
1550
|
+
} else if (what === 'locale') {
|
|
1551
|
+
try { next = Intl.getCanonicalLocales(value)[0]; } catch { throw new Error(`Not a locale: ${value}; e.g. fr-FR, de, pt-BR`); }
|
|
1552
|
+
} else {
|
|
1553
|
+
// Checked, but kept as given: Node's names can be older ones (Asia/Calcutta for Asia/Kolkata).
|
|
1554
|
+
try { new Intl.DateTimeFormat('en-US', { timeZone: value }); } catch { throw new Error(`Not a timezone: ${value}; e.g. Asia/Tokyo, America/New_York, UTC`); }
|
|
1555
|
+
next = value;
|
|
1556
|
+
}
|
|
1557
|
+
await setEmulation(state.page, { ...current, [key]: next });
|
|
1558
|
+
const shown = what === 'mobile' ? `mobile (${deviceText(next)})` : what === 'dark' || what === 'light' ? `${what} mode` : `${what} ${next}`;
|
|
1559
|
+
out.log(`Emulating ${shown} in the selected tab`);
|
|
1560
|
+
// The page reads these when it loads; the rest applies at once.
|
|
1561
|
+
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.');
|
|
1562
|
+
},
|
|
1563
|
+
|
|
1341
1564
|
async wait(args) {
|
|
1342
|
-
const usage = 'Usage: wait <selector> | wait text <text> | wait request <url-part|glob
|
|
1565
|
+
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
1566
|
let tokens = (args || '').trim().split(/\s+/).filter(Boolean);
|
|
1567
|
+
const gone = tokens.includes('--gone');
|
|
1568
|
+
tokens = tokens.filter(t => t !== '--gone');
|
|
1344
1569
|
let seconds = WAIT_DEFAULT;
|
|
1345
1570
|
if (tokens.length > 1 && /^\d+$/.test(tokens[tokens.length - 1])) seconds = Number(tokens.pop());
|
|
1346
1571
|
if (!tokens.length || seconds < 1 || seconds > WAIT_MAX) throw new Error(`${usage} (1-${WAIT_MAX})`);
|
|
1347
1572
|
const timeout = seconds * 1000;
|
|
1348
1573
|
const kind = tokens[0];
|
|
1574
|
+
if (kind === 'load' && tokens.length === 1) {
|
|
1575
|
+
if (gone) throw new Error(usage);
|
|
1576
|
+
return waitForLoad(timeout);
|
|
1577
|
+
}
|
|
1349
1578
|
if ((kind === 'text' || kind === 'request') && tokens.length > 1) {
|
|
1350
1579
|
const what = tokens.slice(1).join(' ').replace(/^(["'])(.*)\1$/, '$2').replace(/\\"/g, '"');
|
|
1351
|
-
if (kind === '
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
return;
|
|
1580
|
+
if (kind === 'request') {
|
|
1581
|
+
if (gone) throw new Error(usage);
|
|
1582
|
+
return waitForRequest(what, timeout);
|
|
1355
1583
|
}
|
|
1356
|
-
return
|
|
1584
|
+
if (gone) return waitGone(state.page.getByText(what), what, timeout);
|
|
1585
|
+
await state.page.getByText(what).first().waitFor({ state: 'visible', timeout });
|
|
1586
|
+
out.log(`Visible: ${what}`);
|
|
1587
|
+
return;
|
|
1357
1588
|
}
|
|
1358
1589
|
const selector = toSelector(unquote(tokens.join(' ')));
|
|
1590
|
+
if (gone) return waitGone(state.page.locator(selector), tokens.join(' '), timeout);
|
|
1359
1591
|
await state.page.waitForSelector(selector, { state: 'attached', timeout });
|
|
1360
1592
|
out.log(`Found: ${tokens.join(' ')}`);
|
|
1361
1593
|
},
|
|
@@ -1369,22 +1601,28 @@ const commands = {
|
|
|
1369
1601
|
// A stopped upstream does not reach the browser as a failure: the dev proxy
|
|
1370
1602
|
// holds the request open instead of refusing it. Cutting the connection at
|
|
1371
1603
|
// 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
1604
|
async network(args) {
|
|
1374
|
-
const
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1605
|
+
const words = (args || '').trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
1606
|
+
const setting = networkChanged.get(state.page);
|
|
1607
|
+
if (!words.length) {
|
|
1608
|
+
out.log(`The selected tab's network is ${setting ? networkText(setting) : 'on'}.`);
|
|
1609
|
+
if (setting) out.log(hints([['network on', 'restore it']]));
|
|
1610
|
+
else out.log(hints([['network off', 'cut it, like dropped wifi'], ['network slow [<ms> [<kbps>]]', `slow it (default ${networkText(SLOW_DEFAULT).slice(6, -1)})`]]));
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
const [how, ...rest] = words;
|
|
1614
|
+
if ((how === 'on' || how === 'off') && !rest.length) {
|
|
1615
|
+
await setNetwork(state.page, how === 'on' ? null : { offline: true });
|
|
1616
|
+
out.log(`The selected tab's network is ${how}`);
|
|
1383
1617
|
return;
|
|
1384
1618
|
}
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1619
|
+
const usage = `Usage: network [on | off | slow [<latency-ms> [<kbps>]]] (latency up to ${SLOW_LATENCY_MAX}ms)`;
|
|
1620
|
+
if (how !== 'slow' || rest.length > 2 || !rest.every(w => /^\d+$/.test(w))) throw new Error(usage);
|
|
1621
|
+
const [latency, kbps] = rest.map(Number);
|
|
1622
|
+
if (latency > SLOW_LATENCY_MAX || kbps === 0) throw new Error(usage);
|
|
1623
|
+
const slow = rest.length ? { latency, down: kbps || SLOW_DEFAULT.down, up: kbps || SLOW_DEFAULT.up } : SLOW_DEFAULT;
|
|
1624
|
+
await setNetwork(state.page, slow);
|
|
1625
|
+
out.log(`The selected tab's network is ${networkText(slow)}`);
|
|
1388
1626
|
},
|
|
1389
1627
|
|
|
1390
1628
|
// Fulfilled inside the browser, so the page's own code handles the fake
|
|
@@ -1620,6 +1858,7 @@ const commands = {
|
|
|
1620
1858
|
};
|
|
1621
1859
|
|
|
1622
1860
|
onShutdown(discardCapture);
|
|
1861
|
+
onShutdown(resetEmulations);
|
|
1623
1862
|
|
|
1624
1863
|
// Hooks every page needs from the moment the REPL sees it.
|
|
1625
1864
|
function watchPage(p) {
|
|
@@ -1642,7 +1881,9 @@ function complete(line) {
|
|
|
1642
1881
|
if (words.length === 2) {
|
|
1643
1882
|
if (command === 'help') return match(['--all', ...Object.keys(HELP.TOPICS), ...Object.keys(HELP.COMMANDS).sort()]);
|
|
1644
1883
|
if (command === 'tab') return match(['new', 'close']);
|
|
1645
|
-
if (command === 'network') return match(['on', 'off']);
|
|
1884
|
+
if (command === 'network') return match(['on', 'off', 'slow']);
|
|
1885
|
+
if (command === 'emulate') return match([...EMULATE_KINDS, 'off']);
|
|
1886
|
+
if (command === 'wait') return match(['text', 'request', 'load']);
|
|
1646
1887
|
if (command === 'modes') return match(['off']);
|
|
1647
1888
|
if (command === 'dialog') return match(['accept', 'dismiss']);
|
|
1648
1889
|
if (command === 'watch') return match(['on', 'off', 'new', '--all']);
|
|
@@ -1651,6 +1892,7 @@ function complete(line) {
|
|
|
1651
1892
|
}
|
|
1652
1893
|
if (words.length >= 3 && command === 'watch' && words[1] === 'on') return match(['--changes', '--live'].filter(f => !words.slice(2, -1).includes(f)));
|
|
1653
1894
|
if (words.length === 3 && command === 'capture' && words[1] === 'on') return match(['requests', 'console']);
|
|
1895
|
+
if (words.length === 3 && command === 'emulate' && EMULATE_KINDS.includes(words[1])) return match(['off']);
|
|
1654
1896
|
if (words.length === 3 && command === 'route' && words[1] === 'off') return match(['--all', ...(state.page ? routesFor(state.page).keys() : [])]);
|
|
1655
1897
|
return [[], current];
|
|
1656
1898
|
}
|
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,11 +41,11 @@ 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.',
|
|
@@ -97,6 +101,11 @@ const COMMANDS = {
|
|
|
97
101
|
select: { usage: 'select <selector> <value>', summary: 'choose an option in a select (the value is the rest of the line)' },
|
|
98
102
|
check: { usage: 'check <selector>', summary: 'check a checkbox' },
|
|
99
103
|
uncheck: { usage: 'uncheck <selector>', summary: 'uncheck a checkbox' },
|
|
104
|
+
upload: {
|
|
105
|
+
usage: 'upload <selector> <file>...',
|
|
106
|
+
summary: 'choose files in a file input, as the file picker would',
|
|
107
|
+
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.',
|
|
108
|
+
},
|
|
100
109
|
snapshot: {
|
|
101
110
|
usage: 'snapshot [--full] [--grep <text> | <eN> | selector]',
|
|
102
111
|
summary: 'outline by role and name, with [ref=eN] labels',
|
|
@@ -125,10 +134,15 @@ const COMMANDS = {
|
|
|
125
134
|
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.',
|
|
126
135
|
},
|
|
127
136
|
viewport: { usage: 'viewport [WxH]', summary: 'show or set the viewport size' },
|
|
137
|
+
emulate: {
|
|
138
|
+
usage: 'emulate [<what> [off] | off]',
|
|
139
|
+
summary: 'emulate a phone, dark mode, a locale or a timezone',
|
|
140
|
+
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\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\nemulate mobile or emulate locale. While mobile is on, viewport refuses: the device sets the size.',
|
|
141
|
+
},
|
|
128
142
|
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.',
|
|
143
|
+
usage: 'wait [text|request] <what> [--gone] [secs]',
|
|
144
|
+
summary: 'wait for an element, text, a response or a load (10s)',
|
|
145
|
+
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.\n\n--gone waits instead until nothing matching is visible (removed or hidden): wait .spinner --gone.\n\nUp to 120s. A wait that times out is an error; the REPL carries on.',
|
|
132
146
|
},
|
|
133
147
|
sleep: { usage: 'sleep <ms>', summary: 'wait a fixed time (maximum 3600000)' },
|
|
134
148
|
requests: {
|
|
@@ -157,9 +171,9 @@ const COMMANDS = {
|
|
|
157
171
|
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
172
|
},
|
|
159
173
|
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
|
|
174
|
+
usage: 'network [on|off|slow [<ms> [<kbps>]]]',
|
|
175
|
+
summary: 'cut, slow or restore the tab\'s network',
|
|
176
|
+
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 adds latency to each request and limits its speed: by default as DevTools\' Slow 4G\n(563ms, 1440 kbps down, 675 up); network slow <ms> [<kbps>] sets them. To slow one API, use route\n<glob> delay <secs>.\n\nPer tab; lasts until network on or the REPL exits. Routes still answer while it is off or slow.',
|
|
163
177
|
},
|
|
164
178
|
eval: {
|
|
165
179
|
usage: 'eval [--all] <JavaScript>',
|
|
@@ -176,7 +190,7 @@ const COMMANDS = {
|
|
|
176
190
|
modes: {
|
|
177
191
|
usage: 'modes [off]',
|
|
178
192
|
summary: 'the modes on in every tab; modes off turns them all off',
|
|
179
|
-
detail: 'The modes are watch, network off, route and
|
|
193
|
+
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
194
|
},
|
|
181
195
|
dialog: {
|
|
182
196
|
usage: 'dialog [accept [text] | dismiss]',
|
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
|
|