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/AGENTS.md +7 -3
- package/README.md +90 -101
- package/bin/pw-repl.js +2 -2
- package/lib/background.js +4 -3
- package/lib/cli-names.js +1 -3
- package/lib/client.js +6 -1
- package/lib/commands.js +342 -36
- package/lib/help.js +28 -16
- package/lib/runner.js +1 -0
- package/lib/send.js +9 -0
- package/lib/server.js +9 -2
- package/lib/skill.js +29 -0
- package/lib/start.js +5 -1
- package/lib/state.js +3 -0
- package/package.json +1 -1
- package/skill/SKILL.md +35 -12
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) {
|
|
@@ -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
|
|
1198
|
-
//
|
|
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
|
-
|
|
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
|
|
1207
|
-
if (
|
|
1208
|
-
|
|
1209
|
-
|
|
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
|
-
|
|
1237
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
1848
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|