pw-repl 0.4.1 → 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) {
@@ -477,6 +528,13 @@ const BODY_TIMEOUT = 15000;
477
528
  const RECENT_HIDDEN_TYPES = new Set(['image', 'font', 'stylesheet', 'media']);
478
529
  // The bodies body prints; any other is binary.
479
530
  const TEXT_BODY = /json|^text\/|javascript|xml|html|x-www-form-urlencoded/;
531
+
532
+ // UTF-8 with no control characters but tabs and line breaks.
533
+ function looksLikeText(buffer) {
534
+ let text;
535
+ try { text = new TextDecoder('utf-8', { fatal: true }).decode(buffer); } catch { return false; }
536
+ return !/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(text);
537
+ }
480
538
  // A watched tab keeps each body watch shows as it arrives, since the browser
481
539
  // drops them once the tab navigates, often before anyone has read them.
482
540
  const KEPT_BODY_MAX = 100 * 1024;
@@ -491,6 +549,8 @@ const loadedAt = new WeakMap();
491
549
  const openedTabs = new WeakSet();
492
550
  // page -> the client that opened it (null: the prompt, or a sender without a name).
493
551
  const tabOwners = new WeakMap();
552
+ // The tab the REPL opened for its start URL, which tab marks: in a kept profile it sits among restored tabs.
553
+ const startTabs = new WeakSet();
494
554
 
495
555
  function clipText(text) {
496
556
  return text.length > MAX_EVENT_TEXT ? `${text.slice(0, MAX_EVENT_TEXT)}…` : text;
@@ -516,7 +576,14 @@ function consoleLevel(type) {
516
576
 
517
577
  function keep(log, entry) {
518
578
  log.push(entry);
519
- 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;
520
587
  }
521
588
 
522
589
  function ensureRecentLog(p) {
@@ -552,10 +619,22 @@ function ensureRecentLog(p) {
552
619
  finish(req, changedRequests.has(req) ? `${status} ${changedRequests.get(req)}` : status, res);
553
620
  keepBody(p, requestEntries.get(req), res);
554
621
  });
555
- p.on('requestfailed', req => {
556
- finish(req, `failed: ${req.failure()?.errorText || 'unknown'}`);
622
+ p.on('requestfailed', async req => {
557
623
  // A page that never loaded: the app's next route change is not a load either.
558
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
+ }
559
638
  });
560
639
  p.on('load', () => loadedAt.set(p, Date.now()));
561
640
  // A page load, marked among the messages it comes between; it ages out with them. Only a new
@@ -1185,19 +1264,146 @@ function selectorAndValue(args, usage, example) {
1185
1264
  }
1186
1265
 
1187
1266
  // An element that never appeared is said plainly, not as Playwright's call
1188
- // log. A ref that no longer matches usually means the page changed since the
1189
- // 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.
1190
1270
  async function onElement(selector, action) {
1191
1271
  try {
1192
1272
  return await action();
1193
1273
  } catch (error) {
1194
- 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;
1195
1276
  const ref = selector.startsWith('aria-ref=') ? selector.slice(9) : null;
1196
1277
  const staleRef = ref ? `\n${ref} is a snapshot ref; if the page changed since that snapshot, take a new one.` : '';
1197
- const waited = /Timeout (\d+)ms exceeded[\s\S]*waiting for locator/.exec(error.message);
1198
- if (waited) throw new Error(`No element matches ${ref || selector} (waited ${waited[1] / 1000}s)${staleRef}`);
1199
- error.message += staleRef;
1200
- 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
+ }
1201
1407
  }
1202
1408
  }
1203
1409
 
@@ -1224,13 +1430,29 @@ async function waitForRequest(pattern, timeout) {
1224
1430
  const since = state.previousCommandAt || 0;
1225
1431
  const deadline = Date.now() + timeout;
1226
1432
  for (;;) {
1227
- const entry = (recentLogs.get(state.page) || []).find(e => e.t >= since && matches(e.url));
1228
- 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];
1229
1440
  if (Date.now() >= deadline) throw new Error(`No response matching ${pattern} within ${timeout / 1000}s${entry ? ` (#${entry.id} is still pending)` : ''}`);
1230
1441
  await new Promise(resolve => setTimeout(resolve, 50));
1231
1442
  }
1232
1443
  }
1233
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
+
1234
1456
  // Done once the page has loaded: after the latest navigation of the tab that
1235
1457
  // began since the previous command did (the click on a link, say), or, with
1236
1458
  // none, the document there now. Waiting on Playwright's load state alone would
@@ -1241,8 +1463,14 @@ async function waitForLoad(timeout) {
1241
1463
  const deadline = Date.now() + timeout;
1242
1464
  for (;;) {
1243
1465
  const navigation = (recentLogs.get(p) || []).filter(e => e.navigation && e.t >= since).pop();
1244
- 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));
1245
1467
  const loaded = navigation ? (loadedAt.get(p) || 0) >= navigation.t : await p.evaluate(() => document.readyState === 'complete').catch(() => false);
1468
+ if (loaded && !navigation && (loadedAt.get(p) || 0) < since) {
1469
+ // Nothing loaded since the previous command: a page that changes its URL itself (pushState) does not
1470
+ // load, and may not have changed it yet.
1471
+ out.log(`Loaded before the previous command, nothing since: ${p.url()} — ${await p.title().catch(() => '')}\n(A page that changes its own URL does not load: wait <selector> or wait text waits for its new view.)`);
1472
+ return;
1473
+ }
1246
1474
  if (loaded) { out.log(`Loaded: ${p.url()} — ${await p.title().catch(() => '')}`); return; }
1247
1475
  if (Date.now() >= deadline) throw new Error(`${p.url()} did not finish loading within ${timeout / 1000}s`);
1248
1476
  await new Promise(resolve => setTimeout(resolve, 50));
@@ -1408,17 +1636,31 @@ async function listTabs() {
1408
1636
  // URL first on its own line: it is what distinguishes otherwise
1409
1637
  // identically titled tabs, and titles wrap less badly when indented.
1410
1638
  if (i) out.log('');
1411
- out.log(`${marker} [${i}] ${p.url()}${tabOwners.get(p) ? ` (opened by ${tabOwners.get(p)})` : ''}`);
1639
+ const notes = [startTabs.has(p) && 'the start URL', tabOwners.get(p) && `opened by ${tabOwners.get(p)}`].filter(Boolean);
1640
+ out.log(`${marker} [${i}] ${p.url()}${notes.length ? ` (${notes.join(', ')})` : ''}`);
1412
1641
  out.log(` ${JSON.stringify(title)}`);
1413
1642
  }
1414
1643
  }
1415
1644
 
1416
1645
  // A function written out (el => ..., function (el) {...}), as one expression.
1417
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;
1418
1650
  if (!/^\s*(?:async\s+)?(?:function\b|\([^)]*\)\s*=>|[\w$]+\s*=>)/.test(text)) return false;
1419
1651
  try { new Function(`return (${text}\n);`); return true; } catch { return false; }
1420
1652
  }
1421
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
+
1422
1664
  const commands = {
1423
1665
  async tab(args) {
1424
1666
  const trimmed = (args || '').trim();
@@ -1455,8 +1697,15 @@ const commands = {
1455
1697
  if (parts.length) throw new Error(usage);
1456
1698
  const target = state.tabListing[Number(subcommand)];
1457
1699
  if (target && all.includes(target)) { select(target); return commands.info(); }
1700
+ // Not read as part of a URL instead: that could land in someone else's tab without a word.
1701
+ if (target) throw new Error(`Tab [${subcommand}] has closed since the latest listing. Run tab to list them again.`);
1458
1702
  // A number that is not a tab in the listing may be part of a URL (a port).
1459
- if (!all.some(p => p.url().includes(subcommand))) throw new Error(`No tab [${subcommand}] in the latest listing, and no tab URL contains ${subcommand}. Run tab to list them.`);
1703
+ const containing = all.filter(p => p.url().includes(subcommand)).length;
1704
+ if (!containing) throw new Error(`No tab [${subcommand}] in the latest listing, and no tab URL contains ${subcommand}. Run tab to list them.`);
1705
+ if (containing > 1) {
1706
+ const listing = state.tabListing.length ? 'the latest listing' : 'a listing yet: each client has its own, from tab';
1707
+ throw new Error(`No tab [${subcommand}] in ${listing}. As part of a URL, ${subcommand} is in ${containing} tabs. Run tab, then tab <index>.`);
1708
+ }
1460
1709
  }
1461
1710
  if (subcommand === 'new') {
1462
1711
  select(await openTab());
@@ -1494,7 +1743,7 @@ const commands = {
1494
1743
  // As a browser's address bar does: a local dev server is almost always plain http.
1495
1744
  const local = /^(?:localhost|127(?:\.\d+){3}|\[::1\]|0\.0\.0\.0)(?:[:/?#]|$)/i.test(url);
1496
1745
  if (!/^[a-z][a-z\d+.-]*:\/\//i.test(url) && !/^(about|data|file|javascript):/i.test(url)) url = `${local ? 'http' : 'https'}://${url}`;
1497
- await state.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
1746
+ await navigating(() => state.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 }));
1498
1747
  out.log(`${state.page.url()} — ${await state.page.title()}`);
1499
1748
  },
1500
1749
 
@@ -1509,32 +1758,65 @@ const commands = {
1509
1758
  },
1510
1759
 
1511
1760
  async reload() {
1512
- await state.page.reload({ waitUntil: 'domcontentloaded', timeout: 15000 });
1761
+ await navigating(() => state.page.reload({ waitUntil: 'domcontentloaded', timeout: 15000 }));
1513
1762
  out.log(`Reloaded: ${state.page.url()}`);
1514
1763
  },
1515
1764
 
1516
1765
  async info() {
1517
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
+ }
1518
1772
  out.log(` Title: ${await state.page.title()}`);
1519
1773
  out.log(` Viewport: ${await viewportText()}`);
1520
1774
  },
1521
1775
 
1522
1776
  async click(args) {
1523
1777
  const { selector, shown, options } = clickArgs(args, 'click <selector> [left|right|middle] [--modifiers=<key>[,<key>]]');
1524
- await onElement(selector, () => state.page.click(selector, { ...options, timeout: 5000 }));
1525
- 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}`);
1526
1780
  },
1527
1781
 
1528
1782
  async dblclick(args) {
1529
1783
  const { selector, shown, options } = clickArgs(args, 'dblclick <selector> [left|right|middle] [--modifiers=<key>[,<key>]]');
1530
- await onElement(selector, () => state.page.dblclick(selector, { ...options, timeout: 5000 }));
1531
- 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}`);
1532
1786
  },
1533
1787
 
1534
1788
  async hover(args) {
1535
1789
  const selector = soleSelector(args, 'hover <selector>');
1536
- await onElement(selector, () => state.page.hover(selector, { timeout: 5000 }));
1537
- 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}`);
1538
1820
  },
1539
1821
 
1540
1822
  async fill(line) {
@@ -1683,14 +1965,26 @@ const commands = {
1683
1965
  // Chrome includes each handler's source only for an object in a named group.
1684
1966
  const { result } = await cdp.send('Runtime.evaluate', { expression: which || 'window.__pwReplListenersOf', objectGroup: 'pw-repl-listeners' });
1685
1967
  if (!result.objectId) throw new Error(`No element matches ${text}`);
1686
- const { listeners } = await cdp.send('DOMDebugger.getEventListeners', { objectId: result.objectId });
1687
- 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; }
1688
1975
  const lines = listeners.map(l => {
1689
1976
  const how = [l.useCapture && 'capture', l.once && 'once', l.passive && 'passive'].filter(Boolean).join(', ');
1690
- 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)}…`;
1691
1984
  return `${l.type}${how ? ` (${how})` : ''}: ${handler} (line ${l.lineNumber + 1})`;
1692
1985
  });
1693
1986
  printOutput(lines.join('\n'), all);
1987
+ if (note) out.log(note.trim());
1694
1988
  } finally {
1695
1989
  await cdp.send('Runtime.evaluate', { expression: 'delete window.__pwReplListenersOf' }).catch(() => {});
1696
1990
  await cdp.send('Runtime.releaseObjectGroup', { objectGroup: 'pw-repl-listeners' }).catch(() => {});
@@ -1781,8 +2075,11 @@ const commands = {
1781
2075
  // background, so the tab is brought to the front for the shot.
1782
2076
  await state.page.bringToFront();
1783
2077
  let image;
2078
+ const device = emulations.get(state.page)?.device;
1784
2079
  try {
1785
- if (ref) {
2080
+ if (device) {
2081
+ image = await deviceShot(device, { ref, full, type });
2082
+ } else if (ref) {
1786
2083
  const selector = toSelector(ref);
1787
2084
  image = await onElement(selector, () => state.page.locator(selector).screenshot({ type, timeout: 5000 }));
1788
2085
  } else {
@@ -1791,7 +2088,6 @@ const commands = {
1791
2088
  } finally {
1792
2089
  // Playwright's screenshot resets the screen size and pixel ratio it
1793
2090
  // finds; an emulated phone's are set again.
1794
- const device = emulations.get(state.page)?.device;
1795
2091
  if (device) {
1796
2092
  const session = await keptSession(state.page);
1797
2093
  await session.send('Emulation.clearDeviceMetricsOverride');
@@ -1821,8 +2117,21 @@ const commands = {
1821
2117
  }
1822
2118
  // Playwright's labels are e5, or frame-prefixed like f1e5 in newer versions.
1823
2119
  const selector = toSelector(rest);
1824
- const target = selector ? state.page.locator(selector).first() : state.page;
1825
- 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
+ }
1826
2135
  const text = full ? raw : compactSnapshot(raw);
1827
2136
  if (needle !== null) {
1828
2137
  const hits = grepSnapshot(text, needle);
@@ -1895,7 +2204,9 @@ const commands = {
1895
2204
  const gone = tokens.includes('--gone');
1896
2205
  tokens = tokens.filter(t => t !== '--gone');
1897
2206
  let seconds = WAIT_DEFAULT;
1898
- 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());
1899
2210
  if (!tokens.length || seconds < 1 || seconds > WAIT_MAX) throw new Error(`${usage} (1-${WAIT_MAX})`);
1900
2211
  const timeout = seconds * 1000;
1901
2212
  // Said plainly, not as Playwright's call log.
@@ -1925,7 +2236,8 @@ const commands = {
1925
2236
  async sleep(args) {
1926
2237
  if (!args || !/^\d+$/.test(args) || Number(args) > 3600000) throw new Error('Usage: sleep <ms> (maximum 3600000)');
1927
2238
  await state.page.waitForTimeout(Number(args));
1928
- out.log(`Slept ${args}ms`);
2239
+ // sleep 3 is easily meant as seconds.
2240
+ out.log(Number(args) < 100 ? `Slept ${args}ms (sleep takes milliseconds: sleep ${args}000 is ${args}s)` : `Slept ${args}ms`);
1929
2241
  },
1930
2242
 
1931
2243
  // A stopped upstream does not reach the browser as a failure: the dev proxy
@@ -1992,9 +2304,11 @@ const commands = {
1992
2304
  async requests(args) {
1993
2305
  // --regex <pattern> takes the rest of the line.
1994
2306
  let regex = null;
2307
+ let regexArg = null;
1995
2308
  const text = (args || '').replace(/(?:^|\s)--regex(?:\s+([\s\S]*))?$/, (_, pattern) => {
1996
2309
  if (!pattern?.trim()) throw new Error('Usage: requests [--all] [n] --regex <pattern>');
1997
- regex = regexOf(patternText(pattern.trim()));
2310
+ regexArg = pattern.trim();
2311
+ regex = regexOf(patternText(regexArg));
1998
2312
  return '';
1999
2313
  });
2000
2314
  const tokens = text.trim().split(/\s+/).filter(Boolean);
@@ -2007,12 +2321,25 @@ const commands = {
2007
2321
  const wanted = regex ? url => regex.test(url) : url => !filter || url.includes(filter);
2008
2322
  const log = recentLogs.get(state.page) || [];
2009
2323
  const shown = everything ? log : log.filter(e => !RECENT_HIDDEN_TYPES.has(e.type) && !e.url.startsWith('chrome-extension://'));
2010
- 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);
2011
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();
2012
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;
2013
2339
  const ms = e.ms === null ? '-' : `${e.ms}ms`;
2014
- 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}`);
2015
2341
  }
2342
+ for (const e of loads) delete e.marked;
2016
2343
  // The numbers skip what is hidden; say so, or they look like requests went missing.
2017
2344
  const first = matches[0].id;
2018
2345
  const last = matches[matches.length - 1].id;
@@ -2049,7 +2376,8 @@ const commands = {
2049
2376
  const type = entry.response.headers()['content-type'] || '';
2050
2377
  out.log(`#${id} ${entry.method} ${entry.status} ${entry.url} (${type || 'no content type'}, ${buffer.length} bytes)`);
2051
2378
  if (!buffer.length) return;
2052
- if (!TEXT_BODY.test(type)) { out.log('[binary body not shown]'); return; }
2379
+ // With no content type, text is told by its bytes: an error page's "not found" often has none.
2380
+ if (!TEXT_BODY.test(type) && (type || !looksLikeText(buffer))) { out.log('[binary body not shown]'); return; }
2053
2381
  let text = buffer.toString('utf8');
2054
2382
  if (/json/.test(type)) { try { text = JSON.stringify(JSON.parse(text), null, 2); } catch {} }
2055
2383
  printOutput(text, all);
@@ -2066,7 +2394,10 @@ const commands = {
2066
2394
  ? e => !filter || e.type.includes(filter) || e.text.includes(filter)
2067
2395
  : e => CONSOLE_LEVELS.indexOf(consoleLevel(e.type)) <= level;
2068
2396
  const logs = consoleLogs.get(state.page) || [];
2069
- 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);
2070
2401
  if (!matches.length) { out.log(filter ? `No console messages matching ${filter}` : 'No console messages on the selected tab'); return; }
2071
2402
  // Where the page loaded again, so a message from an earlier load does not read as the current one's.
2072
2403
  // From the load the first message shown came from, if it is still kept.
@@ -2345,4 +2676,4 @@ function complete(line) {
2345
2676
  return [[], current];
2346
2677
  }
2347
2678
 
2348
- module.exports = { commands, cliCommands, needsTab, dialogCommand, listTabs, openTab, activeModes, watchPage, complete, compactSnapshot, grepSnapshot, summarizeChanges, scrubEditable, clock };
2679
+ module.exports = { commands, cliCommands, needsTab, dialogCommand, listTabs, openTab, startTabs, activeModes, watchPage, complete, compactSnapshot, grepSnapshot, summarizeChanges, scrubEditable, clock };