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 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; `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
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` 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);
@@ -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 page, rather than time out on a page that is already there.
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.waitForLoadState('domcontentloaded', { timeout: 3000 }).catch(() => {});
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 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
+ 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
- const entry = { id: nextId++, t: Date.now(), method: req.method(), url: req.url(), type: req.resourceType(), status: 'pending', ms: null, response: null };
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
- if (networkCut.has(p)) modes.push('network:off');
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 (networkCut.has(p)) { await setNetwork(p, true); done.push('network on'); }
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 path = stack.filter(a => !/^generic(?: \[|$)/.test(a.label)).map(a => a.label.replace(/ \[ref=[^\]]+\]/g, ''));
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
- const image = await state.page.screenshot({ fullPage: full });
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>, each with optional [seconds]';
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 === 'text') {
1352
- await state.page.getByText(what).first().waitFor({ state: 'visible', timeout });
1353
- out.log(`Visible: ${what}`);
1354
- return;
1611
+ if (kind === 'request') {
1612
+ if (gone) throw new Error(usage);
1613
+ return waitForRequest(what, timeout);
1355
1614
  }
1356
- return waitForRequest(what, timeout);
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
- await state.page.waitForSelector(selector, { state: 'attached', timeout });
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 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
- }
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
- 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}`);
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 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,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 sensitive data.',
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: { usage: 'back', summary: 'go back one history entry' },
84
- forward: { usage: 'forward', summary: 'go forward one history entry' },
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 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.',
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, 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.',
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 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.',
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
- const whose = info?.launched ? ', launched by this REPL' : '';
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
- 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.2",
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
 
@@ -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 `press`, a word quoted in your shell stays one word
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'"`). It works however the REPL was started:
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 go (default `/tmp`). They are all named `screenshot-*.png`,
118
- so `rm /tmp/screenshot-*.png` cleans up.
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