pw-repl 0.4.2 → 0.5.0

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
@@ -146,19 +146,41 @@ async function dialogCommand(args, selected = state.page) {
146
146
  // the window's; it is read from the page.
147
147
  async function viewportText() {
148
148
  const device = emulations.get(state.page)?.device;
149
- if (device) return `${devices[device].viewport.width}x${devices[device].viewport.height} (emulate mobile: ${device})`;
149
+ if (device) {
150
+ const { width, height } = devices[device].viewport;
151
+ // A page with no viewport meta tag is laid out as on a desktop, and the phone shows it shrunk.
152
+ const laidOut = await state.page.evaluate(() => innerWidth).catch(() => null);
153
+ const wider = laidOut && laidOut > width ? `; the page lays out ${laidOut} wide, shown shrunk: it has no viewport meta tag` : '';
154
+ return `${width}x${height} (emulate mobile: ${device}${wider})`;
155
+ }
150
156
  const set = state.page.viewportSize();
151
157
  if (set) return `${set.width}x${set.height} (set with viewport)`;
152
158
  const size = await state.page.evaluate(() => `${innerWidth}x${innerHeight}`).catch(() => null);
153
159
  return size ? `${size} (the window's size)` : 'unknown';
154
160
  }
155
161
 
162
+ // A navigation that does not load is said as wait load says it, from its request, rather than as
163
+ // Playwright's net:: error and call log.
164
+ async function navigating(go) {
165
+ const started = Date.now();
166
+ try {
167
+ return await go();
168
+ } catch (error) {
169
+ if (!/net::ERR_/.test(error.message)) throw error;
170
+ const find = () => (recentLogs.get(state.page) || []).filter(e => e.navigation && e.t >= started && e.failed && e.status !== 'pending').pop();
171
+ for (let waited = 0; !find() && waited < 1000; waited += 50) await new Promise(resolve => setTimeout(resolve, 50));
172
+ const navigation = find();
173
+ if (!navigation) throw error;
174
+ throw new Error(notLoaded(navigation));
175
+ }
176
+ }
177
+
156
178
  // A page restored from the back/forward cache never fires its load events
157
179
  // again, so back and forward wait only for the navigation, then briefly for
158
180
  // the document to be parsed, which a restored one already is.
159
181
  async function historyStep(go, direction) {
160
182
  const before = state.page.url();
161
- const response = await go();
183
+ const response = await navigating(go);
162
184
  if (response === null && state.page.url() === before) throw new Error(`No page to go ${direction} to`);
163
185
  await state.page.waitForFunction(() => document.readyState !== 'loading', null, { timeout: 3000 }).catch(() => {});
164
186
  }
@@ -255,6 +277,35 @@ function deviceText(name) {
255
277
  return `${name}, ${d.viewport.width}x${d.viewport.height} at ${d.deviceScaleFactor}x${d.hasTouch ? ', touch' : ''}`;
256
278
  }
257
279
 
280
+ // A screenshot of an emulated phone, taken by Chrome as it draws it. Playwright's own sizes the page
281
+ // again for the shot, and a page with no viewport meta tag, laid out wider than the phone and shown
282
+ // shrunk, comes out drawn small in a corner of a mostly blank image. One image pixel per CSS pixel.
283
+ async function deviceShot(device, { ref, full, type }) {
284
+ const session = await keptSession(state.page);
285
+ let clip = null;
286
+ if (ref) {
287
+ const selector = toSelector(ref);
288
+ const box = await onElement(selector, async () => {
289
+ const locator = state.page.locator(selector);
290
+ await locator.scrollIntoViewIfNeeded({ timeout: 5000 });
291
+ return locator.evaluate(el => { const r = el.getBoundingClientRect(); return { x: r.x + scrollX, y: r.y + scrollY, width: r.width, height: r.height }; });
292
+ });
293
+ clip = box;
294
+ }
295
+ // Measured after scrolling to the element, if any.
296
+ const { cssVisualViewport: view, cssContentSize: content } = await session.send('Page.getLayoutMetrics');
297
+ // At the size the phone shows it: a page laid out wider than the phone is shown shrunk (view.scale).
298
+ const scale = view.scale / devices[device].deviceScaleFactor;
299
+ if (clip) clip = { ...clip, scale };
300
+ if (full) clip = { x: 0, y: 0, width: content.width, height: content.height, scale };
301
+ if (!clip) clip = { x: view.pageX, y: view.pageY, width: view.clientWidth, height: view.clientHeight, scale };
302
+ // Beyond the visible area, Chrome lays the page out again at its whole size for the shot, which
303
+ // someone looking at it would see move: only for --full, and an element that does not fit.
304
+ const inView = clip.x >= view.pageX && clip.y >= view.pageY && clip.x + clip.width <= view.pageX + view.clientWidth && clip.y + clip.height <= view.pageY + view.clientHeight;
305
+ const { data } = await session.send('Page.captureScreenshot', { format: type, clip, captureBeyondViewport: full || (!!ref && !inView) });
306
+ return Buffer.from(data, 'base64');
307
+ }
308
+
258
309
  // Sends only what changed. Chrome takes the user agent and the languages
259
310
  // together, so a change to either sends both.
260
311
  function deviceMetrics(session, name) {
@@ -385,8 +436,8 @@ function routeKind(how, rest, usage) {
385
436
  }
386
437
  if (how === 'patch') {
387
438
  if (!rest) throw new Error(usage);
439
+ // Anything but an object replaces the whole body, as RFC 7386 has it: the way to change an array.
388
440
  const patch = parseJson(rest, 'Patch');
389
- if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new Error('Patch must be a JSON object, e.g. {"total": 0}');
390
441
  return {
391
442
  text: `patch ${rest.length > ROUTE_PREVIEW ? `${rest.slice(0, ROUTE_PREVIEW)}…` : rest}`,
392
443
  async handle(r, req, tag) {
@@ -525,7 +576,14 @@ function consoleLevel(type) {
525
576
 
526
577
  function keep(log, entry) {
527
578
  log.push(entry);
528
- if (log.length > RECENT_MAX) log.shift();
579
+ if (log.length > RECENT_MAX) { log.shift(); log.dropped = true; }
580
+ }
581
+
582
+ // Said when a listing leaves some out, or it looks complete: a busy page makes more than the
583
+ // default in one load, and the log itself keeps only the last RECENT_MAX.
584
+ function cutNote(what, shown, total, dropped, command) {
585
+ if (shown < total) return `(last ${shown} of ${total} kept; ${command(Math.min(total, RECENT_MAX))} shows them all${dropped ? `; older ${what} are no longer kept` : ''})`;
586
+ return dropped ? `(older ${what} are no longer kept: only the last ${RECENT_MAX} of every kind are)` : null;
529
587
  }
530
588
 
531
589
  function ensureRecentLog(p) {
@@ -561,10 +619,22 @@ function ensureRecentLog(p) {
561
619
  finish(req, changedRequests.has(req) ? `${status} ${changedRequests.get(req)}` : status, res);
562
620
  keepBody(p, requestEntries.get(req), res);
563
621
  });
564
- p.on('requestfailed', req => {
565
- finish(req, `failed: ${req.failure()?.errorText || 'unknown'}`);
622
+ p.on('requestfailed', async req => {
566
623
  // A page that never loaded: the app's next route change is not a load either.
567
624
  if (requestEntries.get(req)?.navigation) newDocument = false;
625
+ const reason = req.failure()?.errorText || 'unknown';
626
+ // Whatever its status says: wait load must know at once that this one will never load.
627
+ const entry = requestEntries.get(req);
628
+ if (entry) entry.failed = reason;
629
+ // Chrome reports a response that has no body (204, 205, 304) as aborted once it has arrived: the
630
+ // page got its answer. Any other answered one failed while its body came in, and says so.
631
+ const res = await req.response().catch(() => null);
632
+ if (res) {
633
+ const status = changedRequests.has(req) ? `${res.status()} ${changedRequests.get(req)}` : String(res.status());
634
+ finish(req, [204, 205, 304].includes(res.status()) ? status : `${status}, then failed: ${reason}`, res);
635
+ } else {
636
+ finish(req, `failed: ${reason}`);
637
+ }
568
638
  });
569
639
  p.on('load', () => loadedAt.set(p, Date.now()));
570
640
  // A page load, marked among the messages it comes between; it ages out with them. Only a new
@@ -1194,19 +1264,146 @@ function selectorAndValue(args, usage, example) {
1194
1264
  }
1195
1265
 
1196
1266
  // An element that never appeared is said plainly, not as Playwright's call
1197
- // log. A ref that no longer matches usually means the page changed since the
1198
- // snapshot it came from.
1267
+ // log, and so is one that timed out: its call log (dozens of lines of retries) goes
1268
+ // to the REPL's pane and log only. A ref that no longer matches usually means the page
1269
+ // changed since the snapshot it came from.
1199
1270
  async function onElement(selector, action) {
1200
1271
  try {
1201
1272
  return await action();
1202
1273
  } catch (error) {
1203
- if (/resolved to/.test(error.message)) throw error;
1274
+ const message = String(error.message || '').replace(/\x1b\[[0-9;]*m/g, '');
1275
+ if (/strict mode violation/.test(message)) throw error;
1204
1276
  const ref = selector.startsWith('aria-ref=') ? selector.slice(9) : null;
1205
1277
  const staleRef = ref ? `\n${ref} is a snapshot ref; if the page changed since that snapshot, take a new one.` : '';
1206
- const waited = /Timeout (\d+)ms exceeded[\s\S]*waiting for locator/.exec(error.message);
1207
- if (waited) throw new Error(`No element matches ${ref || selector} (waited ${waited[1] / 1000}s)${staleRef}`);
1208
- error.message += staleRef;
1209
- throw error;
1278
+ const timeout = /Timeout (\d+)ms exceeded/.exec(message);
1279
+ if (timeout && /waiting for locator/.test(message) && !/resolved to/.test(message)) {
1280
+ throw new Error(`No element matches ${ref || selector} (waited ${timeout[1] / 1000}s)${staleRef}`);
1281
+ }
1282
+ if (!timeout) { error.message += staleRef; throw error; }
1283
+ console.error(`Playwright's call log:\n${message}`);
1284
+ // Found, so the ref is not stale: no hint.
1285
+ const summary = new Error(`Timed out after ${timeout[1] / 1000}s on ${ref || selector}: ${lastBlocker(message)} (its call log is in the REPL's pane or log)`);
1286
+ // Worked out from the whole log: the summary no longer says whether the element was found.
1287
+ summary.uncertain = !/waiting for locator/.test(message) || /resolved to/.test(message);
1288
+ throw summary;
1289
+ }
1290
+ }
1291
+
1292
+ // What last stopped the action, from Playwright's call log: the element that took the click,
1293
+ // or the check the element kept failing.
1294
+ function lastBlocker(log) {
1295
+ const lines = log.split('\n').map(line => line.trim().replace(/^-\s*/, ''));
1296
+ const line = lines.reverse().find(l => /intercepts pointer events|element is not |element is outside|not attached|detached/.test(l));
1297
+ if (!line) return 'it never became ready to act on';
1298
+ return line.length > 240 ? `${line.slice(0, 240)}…` : line;
1299
+ }
1300
+
1301
+ // Where a match stands for a click: hidden elements are left out before this. Tested in
1302
+ // its own frame, at its centre, and only when that is in view: scrolling each one to test
1303
+ // it would move a page someone may be looking at.
1304
+ function hitTest(el) {
1305
+ if (window !== window.top) return { kind: 'unchecked' };
1306
+ const r = el.getBoundingClientRect();
1307
+ const x = r.left + r.width / 2;
1308
+ const y = r.top + r.height / 2;
1309
+ if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) return { kind: 'unchecked' };
1310
+ let top = document.elementFromPoint(x, y);
1311
+ while (top?.shadowRoot) {
1312
+ const inner = top.shadowRoot.elementFromPoint(x, y);
1313
+ if (!inner || inner === top) break;
1314
+ top = inner;
1315
+ }
1316
+ for (let n = top; n; n = n.parentNode || n.host) if (n === el) return { kind: 'hit' };
1317
+ const name = e => {
1318
+ const classes = typeof e.className === 'string' ? e.className.trim().split(/\s+/).filter(Boolean).slice(0, 3) : [];
1319
+ return `<${e.tagName.toLowerCase()}${e.id ? `#${e.id}` : ''}${classes.map(c => `.${c}`).join('')}>`;
1320
+ };
1321
+ return { kind: 'covered', by: top ? name(top) : 'nothing (off the page)' };
1322
+ }
1323
+
1324
+ // Playwright acts on the first match and retries it until it times out, even when that one is
1325
+ // hidden or under an overlay and a later one could be used. With several matches, this picks
1326
+ // the first visible one that is not covered (or not checkable here, which Playwright then
1327
+ // checks as it acts), waiting for one as Playwright waits; with none it fails before acting,
1328
+ // saying why. The chosen element's handle is what is acted on, so the list changing
1329
+ // meanwhile cannot shift the choice. Null: a ref or a single match, left to Playwright.
1330
+ async function chooseMatch(selector, timeout) {
1331
+ if (selector.startsWith('aria-ref=')) return null;
1332
+ const locator = state.page.locator(selector);
1333
+ if (await locator.count() <= 1) return null;
1334
+ const deadline = Date.now() + timeout;
1335
+ let seen = { count: 0, handles: [], results: [] };
1336
+ for (;;) {
1337
+ const count = await locator.count();
1338
+ if (count <= 1 && !seen.handles.length) return null;
1339
+ const handles = await locator.filter({ visible: true }).elementHandles();
1340
+ const results = await Promise.all(handles.map(h => h.evaluate(hitTest).catch(() => ({ kind: 'gone' }))));
1341
+ // In page order: a later match in view must not win over an earlier one Playwright can scroll to.
1342
+ const index = results.findIndex(r => r.kind === 'hit' || r.kind === 'unchecked');
1343
+ if (index !== -1) {
1344
+ await Promise.all(seen.handles.concat(handles.filter((_, i) => i !== index)).map(h => h.dispose().catch(() => {})));
1345
+ const all = await locator.elementHandles();
1346
+ const position = (await Promise.all(all.map(h => h.evaluate((a, b) => a === b, handles[index])))).indexOf(true);
1347
+ await Promise.all(all.map(h => h.dispose().catch(() => {})));
1348
+ return { handle: handles[index], position: position + 1, count };
1349
+ }
1350
+ await Promise.all(seen.handles.map(h => h.dispose().catch(() => {})));
1351
+ seen = { count, handles, results };
1352
+ if (Date.now() >= deadline) break;
1353
+ await new Promise(resolve => setTimeout(resolve, 100));
1354
+ }
1355
+ const listed = await Promise.all(seen.handles.slice(0, 5).map(async (h, i) => {
1356
+ const text = await h.evaluate(el => `<${el.tagName.toLowerCase()}> ${(el.innerText || el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60)}`).catch(() => '(gone)');
1357
+ const r = seen.results[i];
1358
+ return ` ${text}: ${r.kind === 'covered' ? `covered by ${r.by}` : 'gone from the page'}`;
1359
+ }));
1360
+ await Promise.all(seen.handles.map(h => h.dispose().catch(() => {})));
1361
+ const hidden = seen.count - seen.handles.length;
1362
+ const error = new Error([
1363
+ `None of the ${seen.count} matches for ${selector} could be acted on after ${timeout / 1000}s, so nothing was done:`,
1364
+ ...listed,
1365
+ ...(seen.handles.length > 5 ? [` and ${seen.handles.length - 5} more like these`] : []),
1366
+ ...(hidden ? [` ${hidden} hidden`] : []),
1367
+ 'A more specific selector, or a ref from snapshot, picks one; covered ones may be under a dialog or overlay.',
1368
+ ].join('\n'));
1369
+ error.uncertain = false;
1370
+ throw error;
1371
+ }
1372
+
1373
+ function numbers(args, usage) {
1374
+ const words = (args || '').trim().split(/\s+/).filter(Boolean);
1375
+ if (words.some(w => !/^-?\d+(?:\.\d+)?$/.test(w))) throw new Error(`Usage: ${usage}, in numbers of CSS pixels`);
1376
+ return words.map(Number);
1377
+ }
1378
+
1379
+ function mouseButton(args, name) {
1380
+ const button = (args || '').trim() || 'left';
1381
+ if (!/^(?:left|right|middle)$/.test(button)) throw new Error(`Usage: ${name} [left|right|middle]`);
1382
+ return button;
1383
+ }
1384
+
1385
+ // click, dblclick and hover: on the match chooseMatch picks, or as Playwright does.
1386
+ // A page that renders the chosen element again (as React lists do) replaces it before it is
1387
+ // acted on; it is chosen again then, as Playwright finds a locator's element again.
1388
+ async function pointerAction(selector, act, options = {}) {
1389
+ const TIMEOUT = 5000;
1390
+ const deadline = Date.now() + TIMEOUT;
1391
+ const left = () => Math.max(1000, deadline - Date.now());
1392
+ for (;;) {
1393
+ const chosen = await onElement(selector, () => chooseMatch(selector, left()));
1394
+ if (!chosen) {
1395
+ // Only counted so far, so the whole time is Playwright's, as it was before.
1396
+ await onElement(selector, () => act(state.page, selector, { ...options, timeout: TIMEOUT }));
1397
+ return '';
1398
+ }
1399
+ try {
1400
+ await onElement(selector, () => act(chosen.handle, null, { ...options, timeout: left() }));
1401
+ return chosen.position === 1 ? '' : ` (match ${chosen.position} of ${chosen.count}; the ones before it are hidden or covered)`;
1402
+ } catch (error) {
1403
+ if (!/not attached/i.test(error.message) || Date.now() >= deadline) throw error;
1404
+ } finally {
1405
+ await chosen.handle.dispose().catch(() => {});
1406
+ }
1210
1407
  }
1211
1408
  }
1212
1409
 
@@ -1233,13 +1430,29 @@ async function waitForRequest(pattern, timeout) {
1233
1430
  const since = state.previousCommandAt || 0;
1234
1431
  const deadline = Date.now() + timeout;
1235
1432
  for (;;) {
1236
- const entry = (recentLogs.get(state.page) || []).find(e => e.t >= since && matches(e.url));
1237
- if (entry && entry.status !== 'pending') return printRequest(entry);
1433
+ // Begun since the previous command, or finished since, or still going: one begun earlier (a
1434
+ // reload two commands ago) can still be the one waited for. A finished one first, so a request
1435
+ // that never ends (a long poll) does not hide one that has.
1436
+ const found = (recentLogs.get(state.page) || []).filter(e => matches(e.url) && (e.t >= since || e.ms === null || e.t + e.ms >= since));
1437
+ const done = found.find(e => e.status !== 'pending');
1438
+ if (done) return printRequest(done);
1439
+ const entry = found[0];
1238
1440
  if (Date.now() >= deadline) throw new Error(`No response matching ${pattern} within ${timeout / 1000}s${entry ? ` (#${entry.id} is still pending)` : ''}`);
1239
1441
  await new Promise(resolve => setTimeout(resolve, 50));
1240
1442
  }
1241
1443
  }
1242
1444
 
1445
+ // Why a navigation that failed did not load, from its request.
1446
+ function notLoaded(navigation) {
1447
+ const why = stayedPut(navigation) ? `${navigation.status} (no page to show, so the tab stays where it was)` : navigation.status.startsWith('failed') ? navigation.status : `${navigation.status}, then failed: ${navigation.failed}`;
1448
+ return `The page did not load: #${navigation.id} ${navigation.url} ${why}`;
1449
+ }
1450
+
1451
+ // Answered with no page (204, 205): Chrome leaves the tab on the page it was on.
1452
+ function stayedPut(entry) {
1453
+ return !!entry.failed && /^(?:204|205)\b/.test(entry.status);
1454
+ }
1455
+
1243
1456
  // Done once the page has loaded: after the latest navigation of the tab that
1244
1457
  // began since the previous command did (the click on a link, say), or, with
1245
1458
  // none, the document there now. Waiting on Playwright's load state alone would
@@ -1250,7 +1463,7 @@ async function waitForLoad(timeout) {
1250
1463
  const deadline = Date.now() + timeout;
1251
1464
  for (;;) {
1252
1465
  const navigation = (recentLogs.get(p) || []).filter(e => e.navigation && e.t >= since).pop();
1253
- if (navigation && navigation.status.startsWith('failed')) throw new Error(`The page did not load: #${navigation.id} ${navigation.url} ${navigation.status}`);
1466
+ if (navigation?.failed && navigation.status !== 'pending') throw new Error(notLoaded(navigation));
1254
1467
  const loaded = navigation ? (loadedAt.get(p) || 0) >= navigation.t : await p.evaluate(() => document.readyState === 'complete').catch(() => false);
1255
1468
  if (loaded && !navigation && (loadedAt.get(p) || 0) < since) {
1256
1469
  // Nothing loaded since the previous command: a page that changes its URL itself (pushState) does not
@@ -1431,10 +1644,23 @@ async function listTabs() {
1431
1644
 
1432
1645
  // A function written out (el => ..., function (el) {...}), as one expression.
1433
1646
  function isFunctionText(text) {
1647
+ // Wrapped in parentheses, as (el => el.id) is often written: only when the first one closes at the end.
1648
+ const wrapped = /^\s*\(([\s\S]*)\)\s*$/.exec(text);
1649
+ if (wrapped && closesAtEnd(text.trim()) && isFunctionText(wrapped[1])) return true;
1434
1650
  if (!/^\s*(?:async\s+)?(?:function\b|\([^)]*\)\s*=>|[\w$]+\s*=>)/.test(text)) return false;
1435
1651
  try { new Function(`return (${text}\n);`); return true; } catch { return false; }
1436
1652
  }
1437
1653
 
1654
+ // Whether the opening parenthesis is matched by the last character, not earlier: (a)(b) is a call.
1655
+ function closesAtEnd(text) {
1656
+ let depth = 0;
1657
+ for (let i = 0; i < text.length; i += 1) {
1658
+ if (text[i] === '(') depth += 1;
1659
+ else if (text[i] === ')') { depth -= 1; if (depth === 0) return i === text.length - 1; }
1660
+ }
1661
+ return false;
1662
+ }
1663
+
1438
1664
  const commands = {
1439
1665
  async tab(args) {
1440
1666
  const trimmed = (args || '').trim();
@@ -1517,7 +1743,7 @@ const commands = {
1517
1743
  // As a browser's address bar does: a local dev server is almost always plain http.
1518
1744
  const local = /^(?:localhost|127(?:\.\d+){3}|\[::1\]|0\.0\.0\.0)(?:[:/?#]|$)/i.test(url);
1519
1745
  if (!/^[a-z][a-z\d+.-]*:\/\//i.test(url) && !/^(about|data|file|javascript):/i.test(url)) url = `${local ? 'http' : 'https'}://${url}`;
1520
- await state.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
1746
+ await navigating(() => state.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 }));
1521
1747
  out.log(`${state.page.url()} — ${await state.page.title()}`);
1522
1748
  },
1523
1749
 
@@ -1532,32 +1758,65 @@ const commands = {
1532
1758
  },
1533
1759
 
1534
1760
  async reload() {
1535
- await state.page.reload({ waitUntil: 'domcontentloaded', timeout: 15000 });
1761
+ await navigating(() => state.page.reload({ waitUntil: 'domcontentloaded', timeout: 15000 }));
1536
1762
  out.log(`Reloaded: ${state.page.url()}`);
1537
1763
  },
1538
1764
 
1539
1765
  async info() {
1540
1766
  out.log(` URL: ${state.page.url()}`);
1767
+ // Chrome's error page has a URL of its own; the one that failed is in the request log.
1768
+ if (state.page.url().startsWith('chrome-error://')) {
1769
+ const failed = (recentLogs.get(state.page) || []).filter(e => e.navigation && e.failed && !stayedPut(e)).pop();
1770
+ if (failed) out.log(` Error: Chrome's error page, for #${failed.id} ${failed.url} ${failed.status}`);
1771
+ }
1541
1772
  out.log(` Title: ${await state.page.title()}`);
1542
1773
  out.log(` Viewport: ${await viewportText()}`);
1543
1774
  },
1544
1775
 
1545
1776
  async click(args) {
1546
1777
  const { selector, shown, options } = clickArgs(args, 'click <selector> [left|right|middle] [--modifiers=<key>[,<key>]]');
1547
- await onElement(selector, () => state.page.click(selector, { ...options, timeout: 5000 }));
1548
- out.log(`Clicked: ${shown}`);
1778
+ const which = await pointerAction(selector, (on, sel, o) => (sel ? on.click(sel, o) : on.click(o)), options);
1779
+ out.log(`Clicked: ${shown}${which}`);
1549
1780
  },
1550
1781
 
1551
1782
  async dblclick(args) {
1552
1783
  const { selector, shown, options } = clickArgs(args, 'dblclick <selector> [left|right|middle] [--modifiers=<key>[,<key>]]');
1553
- await onElement(selector, () => state.page.dblclick(selector, { ...options, timeout: 5000 }));
1554
- out.log(`Double-clicked: ${shown}`);
1784
+ const which = await pointerAction(selector, (on, sel, o) => (sel ? on.dblclick(sel, o) : on.dblclick(o)), options);
1785
+ out.log(`Double-clicked: ${shown}${which}`);
1555
1786
  },
1556
1787
 
1557
1788
  async hover(args) {
1558
1789
  const selector = soleSelector(args, 'hover <selector>');
1559
- await onElement(selector, () => state.page.hover(selector, { timeout: 5000 }));
1560
- out.log(`Hovered: ${args.trim()}`);
1790
+ const which = await pointerAction(selector, (on, sel, o) => (sel ? on.hover(sel, o) : on.hover(o)));
1791
+ out.log(`Hovered: ${args.trim()}${which}`);
1792
+ },
1793
+
1794
+ // At a point, in CSS pixels from the top left of the tab's viewport, as playwright-cli's are.
1795
+ async mousemove(args) {
1796
+ const [x, y, extra] = numbers(args, 'mousemove <x> <y>');
1797
+ if (y === undefined || extra !== undefined) throw new Error('Usage: mousemove <x> <y>, in CSS pixels from the top left of the viewport');
1798
+ await state.page.mouse.move(x, y);
1799
+ out.log(`Moved the mouse to ${x}, ${y}`);
1800
+ },
1801
+
1802
+ async mousedown(args) {
1803
+ const button = mouseButton(args, 'mousedown');
1804
+ await state.page.mouse.down({ button });
1805
+ out.log(`Pressed the ${button} button`);
1806
+ },
1807
+
1808
+ async mouseup(args) {
1809
+ const button = mouseButton(args, 'mouseup');
1810
+ await state.page.mouse.up({ button });
1811
+ out.log(`Released the ${button} button`);
1812
+ },
1813
+
1814
+ // Wheel events where the mouse is (mousemove puts it there): a map zooms around that point.
1815
+ async mousewheel(args) {
1816
+ const [dx, dy, extra] = numbers(args, 'mousewheel <dx> <dy>');
1817
+ if (dy === undefined || extra !== undefined) throw new Error('Usage: mousewheel <dx> <dy>, e.g. mousewheel 0 300 scrolls down (negative dy up)');
1818
+ await state.page.mouse.wheel(dx, dy);
1819
+ out.log(`Turned the wheel by ${dx}, ${dy}`);
1561
1820
  },
1562
1821
 
1563
1822
  async fill(line) {
@@ -1706,14 +1965,26 @@ const commands = {
1706
1965
  // Chrome includes each handler's source only for an object in a named group.
1707
1966
  const { result } = await cdp.send('Runtime.evaluate', { expression: which || 'window.__pwReplListenersOf', objectGroup: 'pw-repl-listeners' });
1708
1967
  if (!result.objectId) throw new Error(`No element matches ${text}`);
1709
- const { listeners } = await cdp.send('DOMDebugger.getEventListeners', { objectId: result.objectId });
1710
- if (!listeners.length) { out.log(`No event listeners on ${text}`); return; }
1968
+ const { listeners: found } = await cdp.send('DOMDebugger.getEventListeners', { objectId: result.objectId });
1969
+ // Playwright adds its own to window once it has acted on the page; they are not the page's.
1970
+ // Told by the script they come from, the one its hit-target check is in.
1971
+ const own = new Set(found.filter(l => /^__playwright_/.test(l.type) || /_hitTargetInterceptor/.test(l.handler?.description || '')).map(l => l.scriptId));
1972
+ const listeners = found.filter(l => !own.has(l.scriptId));
1973
+ const note = found.length > listeners.length ? ` (${found.length - listeners.length} of Playwright's own left out)` : '';
1974
+ if (!listeners.length) { out.log(`No event listeners on ${text}${note}`); return; }
1711
1975
  const lines = listeners.map(l => {
1712
1976
  const how = [l.useCapture && 'capture', l.once && 'once', l.passive && 'passive'].filter(Boolean).join(', ');
1713
- const handler = (l.handler?.description || '').replace(/\s+/g, ' ').slice(0, 100);
1977
+ const source = l.handler?.description || '';
1978
+ // Whole, as written, with its lines indented; or its start on one line, where ↵ marks each
1979
+ // line break, so a // comment is seen to end there.
1980
+ const text = all
1981
+ ? source.split('\n').map((line, i) => (i ? ` ${line}` : line)).join('\n')
1982
+ : source.replace(/[ \t]*\r?\n\s*/g, ' ↵ ').replace(/[ \t]+/g, ' ');
1983
+ const handler = all || text.length <= 100 ? text : `${text.slice(0, 100)}…`;
1714
1984
  return `${l.type}${how ? ` (${how})` : ''}: ${handler} (line ${l.lineNumber + 1})`;
1715
1985
  });
1716
1986
  printOutput(lines.join('\n'), all);
1987
+ if (note) out.log(note.trim());
1717
1988
  } finally {
1718
1989
  await cdp.send('Runtime.evaluate', { expression: 'delete window.__pwReplListenersOf' }).catch(() => {});
1719
1990
  await cdp.send('Runtime.releaseObjectGroup', { objectGroup: 'pw-repl-listeners' }).catch(() => {});
@@ -1804,8 +2075,11 @@ const commands = {
1804
2075
  // background, so the tab is brought to the front for the shot.
1805
2076
  await state.page.bringToFront();
1806
2077
  let image;
2078
+ const device = emulations.get(state.page)?.device;
1807
2079
  try {
1808
- if (ref) {
2080
+ if (device) {
2081
+ image = await deviceShot(device, { ref, full, type });
2082
+ } else if (ref) {
1809
2083
  const selector = toSelector(ref);
1810
2084
  image = await onElement(selector, () => state.page.locator(selector).screenshot({ type, timeout: 5000 }));
1811
2085
  } else {
@@ -1814,7 +2088,6 @@ const commands = {
1814
2088
  } finally {
1815
2089
  // Playwright's screenshot resets the screen size and pixel ratio it
1816
2090
  // finds; an emulated phone's are set again.
1817
- const device = emulations.get(state.page)?.device;
1818
2091
  if (device) {
1819
2092
  const session = await keptSession(state.page);
1820
2093
  await session.send('Emulation.clearDeviceMetricsOverride');
@@ -1844,8 +2117,21 @@ const commands = {
1844
2117
  }
1845
2118
  // Playwright's labels are e5, or frame-prefixed like f1e5 in newer versions.
1846
2119
  const selector = toSelector(rest);
1847
- const target = selector ? state.page.locator(selector).first() : state.page;
1848
- const raw = selector ? await onElement(selector, () => target.ariaSnapshot({ mode: 'ai', timeout: 5000 })) : await target.ariaSnapshot({ mode: 'ai', timeout: 5000 });
2120
+ let raw;
2121
+ // A snapshot of one element gives the page new refs, and those of the last whole-page snapshot
2122
+ // stop working; so a ref's part is cut from a whole-page snapshot, which keeps them.
2123
+ if (REF.test(rest)) {
2124
+ const lines = (await state.page.ariaSnapshot({ mode: 'ai', timeout: 5000 })).split('\n');
2125
+ const at = lines.findIndex(line => line.includes(`[ref=${rest}]`));
2126
+ if (at === -1) throw new Error(`No element matches ${rest}\n${rest} is a snapshot ref; if the page changed since that snapshot, take a new one.`);
2127
+ const indent = line => line.length - line.trimStart().length;
2128
+ const end = lines.findIndex((line, i) => i > at && indent(line) <= indent(lines[at]));
2129
+ raw = lines.slice(at, end === -1 ? undefined : end).map(line => line.slice(indent(lines[at]))).join('\n');
2130
+ } else if (selector) {
2131
+ raw = await onElement(selector, () => state.page.locator(selector).first().ariaSnapshot({ mode: 'ai', timeout: 5000 }));
2132
+ } else {
2133
+ raw = await state.page.ariaSnapshot({ mode: 'ai', timeout: 5000 });
2134
+ }
1849
2135
  const text = full ? raw : compactSnapshot(raw);
1850
2136
  if (needle !== null) {
1851
2137
  const hits = grepSnapshot(text, needle);
@@ -1918,7 +2204,9 @@ const commands = {
1918
2204
  const gone = tokens.includes('--gone');
1919
2205
  tokens = tokens.filter(t => t !== '--gone');
1920
2206
  let seconds = WAIT_DEFAULT;
1921
- if (tokens.length > 1 && /^\d+$/.test(tokens[tokens.length - 1])) seconds = Number(tokens.pop());
2207
+ // A number last is the seconds, unless it is all there is to wait for: wait text 990.
2208
+ const onlyWhat = (tokens[0] === 'text' || tokens[0] === 'request') && tokens.length === 2;
2209
+ if (tokens.length > 1 && !onlyWhat && /^\d+$/.test(tokens[tokens.length - 1])) seconds = Number(tokens.pop());
1922
2210
  if (!tokens.length || seconds < 1 || seconds > WAIT_MAX) throw new Error(`${usage} (1-${WAIT_MAX})`);
1923
2211
  const timeout = seconds * 1000;
1924
2212
  // Said plainly, not as Playwright's call log.
@@ -2016,9 +2304,11 @@ const commands = {
2016
2304
  async requests(args) {
2017
2305
  // --regex <pattern> takes the rest of the line.
2018
2306
  let regex = null;
2307
+ let regexArg = null;
2019
2308
  const text = (args || '').replace(/(?:^|\s)--regex(?:\s+([\s\S]*))?$/, (_, pattern) => {
2020
2309
  if (!pattern?.trim()) throw new Error('Usage: requests [--all] [n] --regex <pattern>');
2021
- regex = regexOf(patternText(pattern.trim()));
2310
+ regexArg = pattern.trim();
2311
+ regex = regexOf(patternText(regexArg));
2022
2312
  return '';
2023
2313
  });
2024
2314
  const tokens = text.trim().split(/\s+/).filter(Boolean);
@@ -2031,12 +2321,25 @@ const commands = {
2031
2321
  const wanted = regex ? url => regex.test(url) : url => !filter || url.includes(filter);
2032
2322
  const log = recentLogs.get(state.page) || [];
2033
2323
  const shown = everything ? log : log.filter(e => !RECENT_HIDDEN_TYPES.has(e.type) && !e.url.startsWith('chrome-extension://'));
2034
- const matches = shown.filter(e => wanted(e.url)).slice(-count);
2324
+ const kept = shown.filter(e => wanted(e.url));
2325
+ const matches = kept.slice(-count);
2326
+ const cut = cutNote('requests', matches.length, kept.length, log.dropped, n => `requests${everything ? ' --all' : ''} ${n}${regex ? ` --regex ${regexArg}` : filter ? ` ${filter}` : ''}`);
2327
+ // First, so the list is not read as complete, and its last line is still the latest request.
2328
+ if (cut) out.log(cut);
2035
2329
  if (!matches.length) { out.log(filter ? `No requests matching ${filter}` : 'No requests on the selected tab'); return; }
2330
+ // Where each page load starts, as console marks it, so the requests of one load can be told
2331
+ // apart; from the load the first one shown came from, and whether or not the filter matches it.
2332
+ // Not one answered with no page: the tab stayed on the page it was on.
2333
+ const loads = log.filter(e => e.navigation && !stayedPut(e));
2334
+ let load = loads.filter(e => e.id <= matches[0].id).pop();
2036
2335
  for (const e of matches) {
2336
+ for (const next of loads.filter(n => (!load || n.id > load.id) && n.id <= e.id)) load = next;
2337
+ if (load && !load.marked) out.log(`${clock(load.t)} --- the page loads ${load.url}`);
2338
+ if (load) load.marked = true;
2037
2339
  const ms = e.ms === null ? '-' : `${e.ms}ms`;
2038
- out.log(`#${e.id} ${clock(e.t)} ${e.method} ${e.status} ${ms} ${e.url}`);
2340
+ out.log(`#${e.id} ${clock(e.t)} ${e.method} ${e.status} ${ms} ${e.type} ${e.url}`);
2039
2341
  }
2342
+ for (const e of loads) delete e.marked;
2040
2343
  // The numbers skip what is hidden; say so, or they look like requests went missing.
2041
2344
  const first = matches[0].id;
2042
2345
  const last = matches[matches.length - 1].id;
@@ -2091,7 +2394,10 @@ const commands = {
2091
2394
  ? e => !filter || e.type.includes(filter) || e.text.includes(filter)
2092
2395
  : e => CONSOLE_LEVELS.indexOf(consoleLevel(e.type)) <= level;
2093
2396
  const logs = consoleLogs.get(state.page) || [];
2094
- const matches = logs.filter(e => e.type !== 'load' && shown(e)).slice(-count);
2397
+ const kept = logs.filter(e => e.type !== 'load' && shown(e));
2398
+ const matches = kept.slice(-count);
2399
+ const cut = cutNote('messages', matches.length, kept.length, logs.dropped, n => `console ${n}${filter ? ` ${filter}` : ''}`);
2400
+ if (cut) out.log(cut);
2095
2401
  if (!matches.length) { out.log(filter ? `No console messages matching ${filter}` : 'No console messages on the selected tab'); return; }
2096
2402
  // Where the page loaded again, so a message from an earlier load does not read as the current one's.
2097
2403
  // From the load the first message shown came from, if it is still kept.