pw-repl 0.2.2 → 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/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
@@ -197,23 +297,108 @@ function routesFor(p) {
197
297
  return pageRoutes.get(p);
198
298
  }
199
299
 
300
+ const ROUTE_DELAY_MAX = 120;
301
+
302
+ // Marks a request a route answered, now: the browser reports it finished only later.
303
+ function markChanged(req, status, how) {
304
+ changedRequests.set(req, how);
305
+ const entry = requestEntries.get(req);
306
+ if (entry) { entry.status = `${status} ${how}`; entry.ms = Date.now() - entry.t; }
307
+ }
308
+
309
+ // A JSON Merge Patch (RFC 7386): objects merge, null removes a key, anything else replaces.
310
+ function mergePatch(target, patch) {
311
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return patch;
312
+ const result = target && typeof target === 'object' && !Array.isArray(target) ? { ...target } : {};
313
+ for (const [key, value] of Object.entries(patch)) {
314
+ if (value === null) delete result[key];
315
+ else result[key] = mergePatch(result[key], value);
316
+ }
317
+ return result;
318
+ }
319
+
320
+ function parseJson(text, what) {
321
+ try { return JSON.parse(text); } catch (error) { throw new Error(`${what} is not valid JSON: ${error.message}`); }
322
+ }
323
+
324
+ // How a route treats the requests it matches: its text for listing, and what it does to each one.
325
+ function routeKind(how, rest, usage) {
326
+ if (/^\d{3}$/.test(how)) {
327
+ const status = Number(how);
328
+ if (status < 200 || status > 599) throw new Error('Status must be from 200 to 599');
329
+ if (!rest) throw new Error(usage);
330
+ parseJson(rest, 'Body');
331
+ const preview = rest.length > ROUTE_PREVIEW ? `${rest.slice(0, ROUTE_PREVIEW)}…` : rest;
332
+ return {
333
+ text: `${status} ${preview}`,
334
+ async handle(r, req, tag) {
335
+ markChanged(req, status, 'faked');
336
+ await r.fulfill({ status, contentType: 'application/json', body: rest });
337
+ out.notice(`Faked: ${tag()} -> ${status}`);
338
+ },
339
+ };
340
+ }
341
+ if (how === 'patch') {
342
+ if (!rest) throw new Error(usage);
343
+ const patch = parseJson(rest, 'Patch');
344
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new Error('Patch must be a JSON object, e.g. {"total": 0}');
345
+ return {
346
+ text: `patch ${rest.length > ROUTE_PREVIEW ? `${rest.slice(0, ROUTE_PREVIEW)}…` : rest}`,
347
+ async handle(r, req, tag) {
348
+ const response = await r.fetch();
349
+ let body;
350
+ try { body = await response.json(); } catch {
351
+ await r.fulfill({ response });
352
+ out.notice(`Not patched: ${tag()} — its response is not JSON, so it went through unchanged`);
353
+ return;
354
+ }
355
+ markChanged(req, response.status(), 'patched');
356
+ await r.fulfill({ response, json: mergePatch(body, patch) });
357
+ out.notice(`Patched: ${tag()} -> ${response.status()}`);
358
+ },
359
+ };
360
+ }
361
+ if (how === 'delay') {
362
+ const seconds = Number(rest);
363
+ if (!/^\d+(?:\.\d+)?$/.test(rest || '') || seconds <= 0 || seconds > ROUTE_DELAY_MAX) throw new Error(`Usage: route <url-glob> delay <seconds> (up to ${ROUTE_DELAY_MAX})`);
364
+ return {
365
+ text: `delay ${seconds}s`,
366
+ async handle(r, req, tag) {
367
+ await new Promise(resolve => setTimeout(resolve, seconds * 1000));
368
+ await r.continue();
369
+ out.notice(`Delayed ${seconds}s: ${tag()}`);
370
+ },
371
+ };
372
+ }
373
+ if (how === 'abort' && !rest) {
374
+ return {
375
+ text: 'abort',
376
+ async handle(r, req, tag) {
377
+ await r.abort('failed');
378
+ out.notice(`Aborted: ${tag()}`);
379
+ },
380
+ };
381
+ }
382
+ throw new Error(usage);
383
+ }
384
+
200
385
  function listRoutes() {
201
386
  const routes = routesFor(state.page);
387
+ const forms = [
388
+ ['route <url-glob> <status> <json-body>', 'answer it with this status and JSON'],
389
+ ['route <url-glob> patch <json>', 'let it through, then change its JSON'],
390
+ ['route <url-glob> delay <seconds>', 'hold it, then let it through'],
391
+ ['route <url-glob> abort', 'fail it as if the connection broke'],
392
+ ];
202
393
  if (!routes.size) {
203
- out.log('No fake responses on the selected tab.');
204
- out.log(hints([['route <url-glob> <status> <json-body>', 'add one']]));
394
+ out.log('No routes on the selected tab.');
395
+ out.log(hints(forms));
205
396
  return;
206
397
  }
207
- out.log(`Fake responses on the selected tab (${routes.size}):`);
398
+ out.log(`Routes on the selected tab (${routes.size}):`);
208
399
  const width = Math.max(...[...routes.keys()].map(glob => glob.length));
209
- for (const [glob, { status, body }] of routes) {
210
- const preview = body.length > ROUTE_PREVIEW ? `${body.slice(0, ROUTE_PREVIEW)}…` : body;
211
- out.log(` ${glob.padEnd(width)} ${status} ${preview}`);
212
- }
213
- out.log(hints([
214
- ['route <url-glob> <status> <json-body>', 'add one, or replace the one for that glob'],
215
- ['route off <url-glob> | route off --all', 'remove'],
216
- ]));
400
+ for (const [glob, { text }] of routes) out.log(` ${glob.padEnd(width)} ${text}`);
401
+ out.log(hints([...forms, ['route off <url-glob> | route off --all', 'remove']]));
217
402
  }
218
403
 
219
404
  async function removeRoutes(glob) {
@@ -222,7 +407,7 @@ async function removeRoutes(glob) {
222
407
  if (glob !== '--all' && !routes.has(glob)) throw new Error(`No route for ${glob} on the selected tab`);
223
408
  const removed = await unroute(state.page, glob === '--all' ? [...routes.keys()] : [glob]);
224
409
  for (const g of removed) out.log(`Removed: ${g}`);
225
- if (!removed.length) out.log('No fake responses on the selected tab');
410
+ if (!removed.length) out.log('No routes on the selected tab');
226
411
  }
227
412
 
228
413
  async function unroute(p, globs) {
@@ -246,8 +431,11 @@ const BODY_TIMEOUT = 15000;
246
431
  const RECENT_HIDDEN_TYPES = new Set(['image', 'font', 'stylesheet', 'media']);
247
432
  const recentLogs = new WeakMap();
248
433
  const consoleLogs = new WeakMap();
249
- const fakedRequests = new WeakSet();
434
+ // request -> how a route changed it (faked or patched), shown after its status.
435
+ const changedRequests = new WeakMap();
250
436
  const requestEntries = new WeakMap();
437
+ // page -> when its page last fired its load event.
438
+ const loadedAt = new WeakMap();
251
439
  // Tabs this REPL opened with tab new.
252
440
  const openedTabs = new WeakSet();
253
441
 
@@ -275,7 +463,9 @@ function ensureRecentLog(p) {
275
463
  recentLogs.set(p, log);
276
464
  consoleLogs.set(p, logs);
277
465
  p.on('request', req => {
278
- 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 };
279
469
  requestEntries.set(req, entry);
280
470
  keep(log, entry);
281
471
  });
@@ -293,9 +483,10 @@ function ensureRecentLog(p) {
293
483
  res = await req.response();
294
484
  if (res) status = String(res.status());
295
485
  } catch {}
296
- finish(req, fakedRequests.has(req) ? `${status} faked` : status, res);
486
+ finish(req, changedRequests.has(req) ? `${status} ${changedRequests.get(req)}` : status, res);
297
487
  });
298
488
  p.on('requestfailed', req => finish(req, `failed: ${req.failure()?.errorText || 'unknown'}`));
489
+ p.on('load', () => loadedAt.set(p, Date.now()));
299
490
  p.on('console', msg => keep(logs, { t: Date.now(), type: msg.type(), text: clipText(consoleText(msg)) }));
300
491
  // Uncaught exceptions never reach the console event.
301
492
  p.on('pageerror', error => keep(logs, { t: Date.now(), type: 'pageerror', text: clipText(error.stack || error.message) }));
@@ -675,7 +866,10 @@ function stopWatching(p, watch) {
675
866
  function activeModes(p) {
676
867
  const modes = [];
677
868
  if (watches.get(p)?.on) modes.push('watch');
678
- 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(',')}`);
679
873
  const routes = pageRoutes.get(p)?.size;
680
874
  if (routes) modes.push(`routes:${routes}`);
681
875
  if (cap && cap.page === p) modes.push('capture');
@@ -693,6 +887,7 @@ function listModes() {
693
887
  ['capture on', 'record requests and console messages together'],
694
888
  ['route <url-glob> <status> <json-body>', 'fake a response'],
695
889
  ['network off', 'cut the tab\'s network'],
890
+ ['emulate mobile', 'show the tab as a phone would'],
696
891
  ]));
697
892
  return;
698
893
  }
@@ -717,7 +912,8 @@ async function allModesOff() {
717
912
  if (cap && cap.page === p) { endCapture(); done.push('capture off (capture shows it)'); }
718
913
  const globs = [...(pageRoutes.get(p)?.keys() || [])];
719
914
  if (globs.length) { await unroute(p, globs); done.push(`${globs.length} route${globs.length === 1 ? '' : 's'} removed`); }
720
- 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'); }
721
917
  } catch (error) {
722
918
  problem = error.message;
723
919
  }
@@ -728,6 +924,19 @@ async function allModesOff() {
728
924
  if (!any) out.log('No modes were on.');
729
925
  }
730
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
+
731
940
  // Commands that take only a selector take the whole line, spaces and all.
732
941
  function soleSelector(args, usage) {
733
942
  const text = unquote((args || '').trim());
@@ -790,6 +999,35 @@ async function waitForRequest(pattern, timeout) {
790
999
  }
791
1000
  }
792
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
+
793
1031
  const SNAPSHOT_HINT_LINES = 60;
794
1032
 
795
1033
  // Each matching line (any part of it: role, name, flags such as [disabled]),
@@ -1089,6 +1327,32 @@ const commands = {
1089
1327
  out.log(`Unchecked: ${args.trim()}`);
1090
1328
  },
1091
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
+
1092
1356
  async text(args, all) {
1093
1357
  const selector = soleSelector(args, 'text <selector>');
1094
1358
  printOutput(await onElement(selector, () => state.page.innerText(selector, { timeout: 5000 })), all);
@@ -1109,6 +1373,36 @@ const commands = {
1109
1373
  printOutput(result, all);
1110
1374
  },
1111
1375
 
1376
+ // The page's own event listeners on an element, which only DevTools can
1377
+ // list: the element is handed to a CDP session through the page, so any
1378
+ // Playwright selector works, and the session asks for its listeners.
1379
+ async listeners(args, all) {
1380
+ const text = (args || '').trim();
1381
+ const usage = 'listeners <selector> | listeners document | listeners window';
1382
+ if (!text) throw new Error(`Usage: ${usage}`);
1383
+ const which = text === 'document' || text === 'window' ? text : null;
1384
+ const selector = which ? null : soleSelector(args, usage);
1385
+ if (selector) await onElement(selector, () => state.page.locator(selector).first().evaluate(el => { window.__pwReplListenersOf = el; }, null, { timeout: 5000 }));
1386
+ const cdp = await state.page.context().newCDPSession(state.page);
1387
+ try {
1388
+ // Chrome includes each handler's source only for an object in a named group.
1389
+ const { result } = await cdp.send('Runtime.evaluate', { expression: which || 'window.__pwReplListenersOf', objectGroup: 'pw-repl-listeners' });
1390
+ if (!result.objectId) throw new Error(`No element matches ${text}`);
1391
+ const { listeners } = await cdp.send('DOMDebugger.getEventListeners', { objectId: result.objectId });
1392
+ if (!listeners.length) { out.log(`No event listeners on ${text}`); return; }
1393
+ const lines = listeners.map(l => {
1394
+ const how = [l.useCapture && 'capture', l.once && 'once', l.passive && 'passive'].filter(Boolean).join(', ');
1395
+ const handler = (l.handler?.description || '').replace(/\s+/g, ' ').slice(0, 100);
1396
+ return `${l.type}${how ? ` (${how})` : ''}: ${handler} (line ${l.lineNumber + 1})`;
1397
+ });
1398
+ printOutput(lines.join('\n'), all);
1399
+ } finally {
1400
+ await cdp.send('Runtime.evaluate', { expression: 'delete window.__pwReplListenersOf' }).catch(() => {});
1401
+ await cdp.send('Runtime.releaseObjectGroup', { objectGroup: 'pw-repl-listeners' }).catch(() => {});
1402
+ await cdp.detach().catch(() => {});
1403
+ }
1404
+ },
1405
+
1112
1406
  async count(args) {
1113
1407
  const selector = soleSelector(args, 'count <selector>');
1114
1408
  const els = await state.page.$$(selector);
@@ -1202,7 +1496,7 @@ const commands = {
1202
1496
  // Playwright's labels are e5, or frame-prefixed like f1e5 in newer versions.
1203
1497
  const selector = toSelector(rest);
1204
1498
  const target = selector ? state.page.locator(selector).first() : state.page;
1205
- const raw = await target.ariaSnapshot({ mode: 'ai', timeout: 5000 });
1499
+ const raw = selector ? await onElement(selector, () => target.ariaSnapshot({ mode: 'ai', timeout: 5000 })) : await target.ariaSnapshot({ mode: 'ai', timeout: 5000 });
1206
1500
  const text = full ? raw : compactSnapshot(raw);
1207
1501
  if (needle !== null) {
1208
1502
  const hits = grepSnapshot(text, needle);
@@ -1218,28 +1512,82 @@ const commands = {
1218
1512
  if (!args) { out.log(await viewportText()); return; }
1219
1513
  const [w, h] = args.split('x').map(Number);
1220
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');
1221
1517
  await state.page.setViewportSize({ width: w, height: h });
1222
1518
  out.log(`Viewport set to ${w}x${h}`);
1223
1519
  },
1224
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
+
1225
1564
  async wait(args) {
1226
- 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';
1227
1566
  let tokens = (args || '').trim().split(/\s+/).filter(Boolean);
1567
+ const gone = tokens.includes('--gone');
1568
+ tokens = tokens.filter(t => t !== '--gone');
1228
1569
  let seconds = WAIT_DEFAULT;
1229
1570
  if (tokens.length > 1 && /^\d+$/.test(tokens[tokens.length - 1])) seconds = Number(tokens.pop());
1230
1571
  if (!tokens.length || seconds < 1 || seconds > WAIT_MAX) throw new Error(`${usage} (1-${WAIT_MAX})`);
1231
1572
  const timeout = seconds * 1000;
1232
1573
  const kind = tokens[0];
1574
+ if (kind === 'load' && tokens.length === 1) {
1575
+ if (gone) throw new Error(usage);
1576
+ return waitForLoad(timeout);
1577
+ }
1233
1578
  if ((kind === 'text' || kind === 'request') && tokens.length > 1) {
1234
1579
  const what = tokens.slice(1).join(' ').replace(/^(["'])(.*)\1$/, '$2').replace(/\\"/g, '"');
1235
- if (kind === 'text') {
1236
- await state.page.getByText(what).first().waitFor({ state: 'visible', timeout });
1237
- out.log(`Visible: ${what}`);
1238
- return;
1580
+ if (kind === 'request') {
1581
+ if (gone) throw new Error(usage);
1582
+ return waitForRequest(what, timeout);
1239
1583
  }
1240
- 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;
1241
1588
  }
1242
1589
  const selector = toSelector(unquote(tokens.join(' ')));
1590
+ if (gone) return waitGone(state.page.locator(selector), tokens.join(' '), timeout);
1243
1591
  await state.page.waitForSelector(selector, { state: 'attached', timeout });
1244
1592
  out.log(`Found: ${tokens.join(' ')}`);
1245
1593
  },
@@ -1253,22 +1601,28 @@ const commands = {
1253
1601
  // A stopped upstream does not reach the browser as a failure: the dev proxy
1254
1602
  // holds the request open instead of refusing it. Cutting the connection at
1255
1603
  // the browser is what a visitor's wifi or VPN dropping looks like to the page.
1256
- // The CDP session is kept alive because emulation resets when it detaches.
1257
1604
  async network(args) {
1258
- const arg = (args || '').trim().toLowerCase();
1259
- if (!arg) {
1260
- if (networkCut.has(state.page)) {
1261
- out.log('The selected tab\'s network is off (offline).');
1262
- out.log(hints([['network on', 'restore it']]));
1263
- } else {
1264
- out.log('The selected tab\'s network is on.');
1265
- out.log(hints([['network off', 'cut it, like dropped wifi']]));
1266
- }
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)})`]]));
1267
1611
  return;
1268
1612
  }
1269
- if (arg !== 'on' && arg !== 'off') throw new Error('Usage: network [on|off]');
1270
- await setNetwork(state.page, arg === 'on');
1271
- out.log(`The selected tab's network is ${arg}`);
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}`);
1617
+ return;
1618
+ }
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)}`);
1272
1626
  },
1273
1627
 
1274
1628
  // Fulfilled inside the browser, so the page's own code handles the fake
@@ -1278,35 +1632,28 @@ const commands = {
1278
1632
  if (!trimmed) return listRoutes();
1279
1633
  const off = /^off(?:\s+(\S+))?$/.exec(trimmed);
1280
1634
  if (off) return removeRoutes(off[1]);
1281
- const match = /^(\S+)\s+(\d{3})\s+([\s\S]+)$/.exec(trimmed);
1282
- if (!match) throw new Error('Usage: route <url-glob> <status> <json-body> | route off <url-glob>|--all');
1283
- const [, glob, statusText, body] = match;
1284
- const status = Number(statusText);
1285
- if (status < 200 || status > 599) throw new Error('Status must be from 200 to 599');
1286
- try { JSON.parse(body); } catch (error) { throw new Error(`Body is not valid JSON: ${error.message}`); }
1635
+ const usage = 'Usage: route <url-glob> <status> <json-body> | route <url-glob> patch <json> | route <url-glob> delay <seconds> | route <url-glob> abort | route off <url-glob>|--all';
1636
+ const match = /^(\S+)\s+(\S+)(?:\s+([\s\S]+))?$/.exec(trimmed);
1637
+ if (!match) throw new Error(usage);
1638
+ const [, glob, how, rest] = match;
1639
+ const route = routeKind(how, rest, usage);
1287
1640
  const routes = routesFor(state.page);
1288
1641
  const previous = routes.get(glob);
1289
1642
  if (previous) await state.page.unroute(glob, previous.handler);
1290
1643
  const handler = async r => {
1291
1644
  const req = r.request();
1292
- fakedRequests.add(req);
1645
+ const tag = () => { const id = requestEntries.get(req)?.id; return `${id ? `#${id} ` : ''}${req.method()} ${req.url()}`; };
1293
1646
  try {
1294
- await r.fulfill({ status, contentType: 'application/json', body });
1295
- // Recorded now: the browser reports the request finished only later.
1296
- const entry = requestEntries.get(req);
1297
- if (entry) { entry.status = `${status} faked`; entry.ms = Date.now() - entry.t; }
1298
- const id = entry?.id;
1299
- out.notice(`Faked: ${id ? `#${id} ` : ''}${req.method()} ${req.url()} -> ${status}`);
1647
+ await route.handle(r, req, tag);
1300
1648
  } catch (error) {
1301
- // Aborted, never continued: a request meant to be faked must not reach the network.
1649
+ // Aborted, never continued: a request meant to be changed must not reach the page as it was.
1302
1650
  await r.abort().catch(() => {});
1303
- const id = requestEntries.get(req)?.id;
1304
- out.notice(`Fake failed: ${id ? `#${id} ` : ''}${req.method()} ${req.url()} — ${error.message}; the request was aborted`);
1651
+ out.notice(`Route failed: ${tag()} — ${error.message}; the request was aborted`);
1305
1652
  }
1306
1653
  };
1307
1654
  await state.page.route(glob, handler);
1308
- routes.set(glob, { status, body, handler });
1309
- out.log(`${previous ? 'Replaced' : 'Routed'}: ${glob} -> ${status}`);
1655
+ routes.set(glob, { text: route.text, handler });
1656
+ out.log(`${previous ? 'Replaced' : 'Routed'}: ${glob} -> ${route.text}`);
1310
1657
  },
1311
1658
 
1312
1659
  async requests(args) {
@@ -1340,7 +1687,7 @@ const commands = {
1340
1687
  const entry = (recentLogs.get(state.page) || []).find(e => e.id === id);
1341
1688
  if (!entry) throw new Error(`No request #${id} on the selected tab; requests lists them`);
1342
1689
  // A fake is marked answered before the browser hands over its response.
1343
- for (let waited = 0; !entry.response && /faked$/.test(entry.status) && waited < 2000; waited += 50) {
1690
+ for (let waited = 0; !entry.response && /(?:faked|patched)$/.test(entry.status) && waited < 2000; waited += 50) {
1344
1691
  await new Promise(resolve => setTimeout(resolve, 50));
1345
1692
  }
1346
1693
  if (!entry.response) throw new Error(`#${id} has no response${entry.status === 'pending' ? ' yet' : ` (${entry.status})`}`);
@@ -1511,6 +1858,7 @@ const commands = {
1511
1858
  };
1512
1859
 
1513
1860
  onShutdown(discardCapture);
1861
+ onShutdown(resetEmulations);
1514
1862
 
1515
1863
  // Hooks every page needs from the moment the REPL sees it.
1516
1864
  function watchPage(p) {
@@ -1533,7 +1881,9 @@ function complete(line) {
1533
1881
  if (words.length === 2) {
1534
1882
  if (command === 'help') return match(['--all', ...Object.keys(HELP.TOPICS), ...Object.keys(HELP.COMMANDS).sort()]);
1535
1883
  if (command === 'tab') return match(['new', 'close']);
1536
- 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']);
1537
1887
  if (command === 'modes') return match(['off']);
1538
1888
  if (command === 'dialog') return match(['accept', 'dismiss']);
1539
1889
  if (command === 'watch') return match(['on', 'off', 'new', '--all']);
@@ -1542,6 +1892,7 @@ function complete(line) {
1542
1892
  }
1543
1893
  if (words.length >= 3 && command === 'watch' && words[1] === 'on') return match(['--changes', '--live'].filter(f => !words.slice(2, -1).includes(f)));
1544
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']);
1545
1896
  if (words.length === 3 && command === 'route' && words[1] === 'off') return match(['--all', ...(state.page ? routesFor(state.page).keys() : [])]);
1546
1897
  return [[], current];
1547
1898
  }