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 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; `lib/server.js` is the opt-in server; `lib/send.js` and `lib/client.js`
15
- are `send` and `where`; `lib/output.js` routes all output so the server can return it.
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 40s): a private headless Chromium, a local test site, and the real
18
- REPL with its server. Nothing is mocked, and the shared browser is never touched. It finds Chromium
19
- through `PW_TEST_CHROME` or Playwright's installed browsers, and skips the browser tests if there is
20
- none. Add a test with each new command or behaviour.
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` and `network off` stay on until you turn them off: `watch on|off`,
88
- `capture on|off`, `route ...|route off`, `network off|on`. While any are on in the selected tab, the prompt
89
- shows them: `(watch network:off routes:2) pw>`. `modes` lists them for every tab, and `modes off` turns them
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 you can
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 (via `PW_TEST_CHROME`, or Playwright's installed
160
- browsers: `npx playwright-core install chromium`) and a local test site; the browser tests are skipped when no
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
- const quoted = requote ? words.map(w => (w === '' || /[\s"']/.test(w) ? JSON.stringify(w) : w)) : words;
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 its CDP session is attached to.
170
- const offlineSessions = new WeakMap();
171
- // Pages whose network is cut.
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
- async function setNetwork(p, on) {
175
- let session = offlineSessions.get(p);
176
- if (!session) {
177
- session = await p.context().newCDPSession(p);
178
- await session.send('Network.enable');
179
- offlineSessions.set(p, session);
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: !on,
183
- latency: 0,
184
- downloadThroughput: -1,
185
- uploadThroughput: -1,
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 (on) networkCut.delete(p); else networkCut.add(p);
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
- const entry = { id: nextId++, t: Date.now(), method: req.method(), url: req.url(), type: req.resourceType(), status: 'pending', ms: null, response: null };
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
- if (networkCut.has(p)) modes.push('network:off');
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 (networkCut.has(p)) { await setNetwork(p, true); done.push('network on'); }
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>, each with optional [seconds]';
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 === 'text') {
1352
- await state.page.getByText(what).first().waitFor({ state: 'visible', timeout });
1353
- out.log(`Visible: ${what}`);
1354
- return;
1580
+ if (kind === 'request') {
1581
+ if (gone) throw new Error(usage);
1582
+ return waitForRequest(what, timeout);
1355
1583
  }
1356
- return waitForRequest(what, timeout);
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 arg = (args || '').trim().toLowerCase();
1375
- if (!arg) {
1376
- if (networkCut.has(state.page)) {
1377
- out.log('The selected tab\'s network is off (offline).');
1378
- out.log(hints([['network on', 'restore it']]));
1379
- } else {
1380
- out.log('The selected tab\'s network is on.');
1381
- out.log(hints([['network off', 'cut it, like dropped wifi']]));
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
- if (arg !== 'on' && arg !== 'off') throw new Error('Usage: network [on|off]');
1386
- await setNetwork(state.page, arg === 'on');
1387
- out.log(`The selected tab's network is ${arg}`);
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 the selected tab's:
23
- (watch routes:1) pw>. tab, watch, capture, route, network and modes on their own show their state and
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 response (default 10s)',
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, like dropped wifi',
162
- detail: 'Stopping a service is not the same: a dev proxy in front of it usually holds the request open, so\nthe page spins instead of failing.\n\nPer tab; lasts until network on or the REPL exits. Routes still answer while it is off.',
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 capture. Each is turned on and off with its own command:\nwatch on|off, network off|on, route <glob> ... | route off <glob>|--all, capture on|off.\n\nmodes off turns off every one in every tab; a capture it stops is kept for capture to show.',
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
- cleanups.forEach(fn => fn());
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pw-repl",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Text REPL for driving an existing Chromium through Playwright over CDP",
5
5
  "keywords": [
6
6
  "playwright",
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, fake responses, cutting the network, and recording what the person does. Use when asked to look at, debug or test something in a browser that runs with remote debugging.
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 network setting.
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