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/AGENTS.md +7 -3
- package/README.md +90 -101
- package/bin/pw-repl.js +6 -5
- package/lib/background.js +41 -8
- package/lib/cli-names.js +1 -3
- package/lib/client.js +6 -1
- package/lib/commands.js +372 -41
- package/lib/help.js +30 -18
- package/lib/launch.js +97 -27
- package/lib/output.js +1 -1
- package/lib/runner.js +6 -3
- package/lib/send.js +9 -0
- package/lib/server.js +9 -2
- package/lib/skill.js +29 -0
- package/lib/start.js +15 -5
- package/lib/state.js +4 -1
- package/package.json +1 -1
- package/skill/SKILL.md +54 -20
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)
|
|
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
|
|
1189
|
-
//
|
|
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
|
-
|
|
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
|
|
1198
|
-
if (
|
|
1199
|
-
|
|
1200
|
-
|
|
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
|
-
|
|
1228
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
1825
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 };
|