pw-repl 0.5.0 → 0.6.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
@@ -1,2588 +1,34 @@
1
- const path = require('path');
2
- const fs = require('fs');
3
- const { state, clientRecord, withTimeout, onShutdown, shutdown } = require('./state');
1
+ // The REPL's commands, gathered from each facility's module, and what the prompt and the runner
2
+ // need besides: playwright-cli's own commands, the hooks every page gets, and tab completion.
3
+ const { state, shutdown } = require('./state');
4
4
  const out = require('./output');
5
5
  const HELP = require('./help');
6
- const { takeOptions } = require('./cli-names');
7
- const { devices } = require('playwright-core');
8
- const { toSelector, unquote, splitSelector, REF, keyName } = require('./syntax');
9
-
10
- const { printOutput, OUTPUT_LIMIT } = out;
11
-
12
- const COMMAND_TIMEOUT = 15000;
13
- const MAX_CAPTURE_EVENTS = 10000;
14
- const MAX_CAPTURE_TEXT = 400000;
15
- const MAX_EVENT_TEXT = 4000;
16
-
17
- let cap = null;
18
- let lastCapture = null;
19
-
20
- // Records the selected tab's requests and console messages together, in time
21
- // order, until capture off, or for a set number of seconds.
22
- async function startCapture(tokens) {
23
- if (cap) { out.log('Already capturing; capture off stops it.'); return; }
24
- if (!state.page || state.page.isClosed()) throw new Error('No tab is selected; select one with tab <index|url-part>, or open one with tab new');
25
- const usage = 'Usage: capture on [requests|console] [seconds] (seconds from 1 to 3600)';
26
- const kinds = tokens.filter(t => t === 'requests' || t === 'console');
27
- const numbers = tokens.filter(t => /^\d+$/.test(t));
28
- if (kinds.length > 1 || numbers.length > 1 || kinds.length + numbers.length !== tokens.length) throw new Error(usage);
29
- const seconds = numbers.length ? Number(numbers[0]) : null;
30
- if (seconds !== null && (seconds < 1 || seconds > 3600)) throw new Error(usage);
31
- const label = kinds[0] || 'requests and console';
32
- const startedAt = Date.now();
33
- const events = [];
34
- const handlers = [];
35
- let dropped = 0;
36
- let shortened = 0;
37
- let bytes = 0;
38
- const record = event => {
39
- let text = String(event.text);
40
- if (text.length > MAX_EVENT_TEXT) { text = `${text.slice(0, MAX_EVENT_TEXT)}…`; shortened += 1; }
41
- const size = text.length;
42
- if (events.length >= MAX_CAPTURE_EVENTS || bytes + size > MAX_CAPTURE_TEXT) { dropped += 1; return; }
43
- bytes += size;
44
- events.push({ ...event, text });
45
- };
46
- const page = state.page;
47
- const listen = (event, handler) => { page.on(event, handler); handlers.push([event, handler]); };
48
- if (label !== 'console') listen('request', req => record({ t: Date.now(), tag: 'request', req, text: `${req.method()} ${req.url()}` }));
49
- if (label !== 'requests') {
50
- listen('console', msg => record({ t: Date.now(), tag: msg.type(), text: consoleText(msg) }));
51
- listen('pageerror', error => record({ t: Date.now(), tag: 'pageerror', text: error.stack || error.message }));
52
- }
53
- // A capture must not outlive its tab: nothing could see or stop it.
54
- listen('close', () => {
55
- if (cap?.page !== page) return;
56
- endCapture();
57
- out.notice('The captured tab closed, which ended the capture; capture shows what it recorded.');
58
- });
59
- const capture = {
60
- page, label, startedAt, events, handlers, wake: null, owner: state.client,
61
- get dropped() { return dropped; },
62
- get shortened() { return shortened; },
63
- };
64
- cap = capture;
65
- if (seconds) {
66
- out.log(`Capturing ${label} for ${seconds}s...`);
67
- // Also ends early if the capture does, e.g. because its tab closed.
68
- await new Promise(resolve => {
69
- const timer = setTimeout(resolve, seconds * 1000);
70
- capture.wake = () => { clearTimeout(timer); resolve(); };
71
- });
72
- if (cap === capture) stopCapture();
73
- else printCapture(lastCapture);
74
- } else {
75
- out.log(`Capturing ${label}; capture off stops it and prints what it recorded.`);
76
- }
77
- }
78
-
79
- function endCapture() {
80
- cap.handlers.forEach(([event, h]) => cap.page.off(event, h));
81
- const { label, startedAt, events, dropped, shortened, wake } = cap;
82
- cap = null;
83
- lastCapture = { label, startedAt, events, dropped, shortened };
84
- if (wake) wake();
85
- }
86
-
87
- function stopCapture(all = false) {
88
- if (!cap) { out.log('Not capturing.'); return; }
89
- endCapture();
90
- printCapture(lastCapture, all);
91
- }
92
-
93
- // What a command run on its own prints after its state: the commands that
94
- // come next, set apart from the state by a blank line.
95
- function hints(pairs) {
96
- const width = Math.max(...pairs.map(([command]) => command.length));
97
- return `\n${pairs.map(([command, what]) => ` ${command.padEnd(width)} ${what}`).join('\n')}`;
98
- }
99
-
100
- const dialogPages = new WeakSet();
101
-
102
- // page -> its open dialog. The page, and every command that reads it, waits
103
- // until the dialog is answered, in the browser or with the dialog command.
104
- const openDialogs = new Map();
105
-
106
- function ensureDialogHandler(p) {
107
- if (dialogPages.has(p)) return;
108
- dialogPages.add(p);
109
- p.on('dialog', dialog => {
110
- openDialogs.set(p, dialog);
111
- out.notice(`Dialog [${dialog.type()}]: ${String(dialog.message()).slice(0, OUTPUT_LIMIT)}`);
112
- out.notice('It waits to be answered in the browser, or with dialog accept [text] | dialog dismiss; until then the page, and commands that read it, wait too.');
113
- });
114
- p.on('close', () => openDialogs.delete(p));
115
- }
116
-
117
- // Runs ahead of the command queue (see runner.js), so it still works while
118
- // commands wait on the dialog; it returns its output rather than printing it.
119
- async function dialogCommand(args, selected = state.page) {
120
- const [action, ...rest] = (args || '').trim().split(/\s+/).filter(Boolean);
121
- const pages = state.browser.contexts().flatMap(c => c.pages());
122
- for (const p of openDialogs.keys()) if (p.isClosed() || !pages.includes(p)) openDialogs.delete(p);
123
- const describe = (p, d) => `[${pages.indexOf(p)}] ${p.url()}: ${d.type()} ${JSON.stringify(String(d.message()).slice(0, 200))}`;
124
- if (!action) {
125
- if (!openDialogs.size) return 'No dialog is open.';
126
- return [...[...openDialogs].map(([p, d]) => describe(p, d)), hints([['dialog accept [text]', 'accept it (text answers a prompt)'], ['dialog dismiss', 'dismiss it']])].join('\n');
127
- }
128
- if (action !== 'accept' && action !== 'dismiss') throw new Error('Usage: dialog [accept [text] | dismiss]');
129
- // The selected tab's dialog, or the only one open.
130
- const page = openDialogs.has(selected) ? selected : openDialogs.size === 1 ? [...openDialogs.keys()][0] : null;
131
- if (!page) throw new Error(openDialogs.size ? 'Several tabs have a dialog open; select one with tab <index|url-part> first' : 'No dialog is open.');
132
- const dialog = openDialogs.get(page);
133
- openDialogs.delete(page);
134
- const line = describe(page, dialog);
135
- try {
136
- if (action === 'accept') await dialog.accept(rest.length ? unquote(rest.join(' ')) : undefined);
137
- else await dialog.dismiss();
138
- } catch (error) {
139
- // Answered in the browser meanwhile.
140
- return `No dialog is open (${error.message.split('\n')[0]})`;
141
- }
142
- return `${action === 'accept' ? 'Accepted' : 'Dismissed'}: ${line}`;
143
- }
144
-
145
- // A tab in a browser the REPL connected to has no viewport set, so its size is
146
- // the window's; it is read from the page.
147
- async function viewportText() {
148
- const device = emulations.get(state.page)?.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
- }
156
- const set = state.page.viewportSize();
157
- if (set) return `${set.width}x${set.height} (set with viewport)`;
158
- const size = await state.page.evaluate(() => `${innerWidth}x${innerHeight}`).catch(() => null);
159
- return size ? `${size} (the window's size)` : 'unknown';
160
- }
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
-
178
- // A page restored from the back/forward cache never fires its load events
179
- // again, so back and forward wait only for the navigation, then briefly for
180
- // the document to be parsed, which a restored one already is.
181
- async function historyStep(go, direction) {
182
- const before = state.page.url();
183
- const response = await navigating(go);
184
- if (response === null && state.page.url() === before) throw new Error(`No page to go ${direction} to`);
185
- await state.page.waitForFunction(() => document.readyState !== 'loading', null, { timeout: 3000 }).catch(() => {});
186
- }
187
-
188
- const SCREENSHOT_DELAY_MAX = 60;
189
-
190
- function nextScreenshotPath(name) {
191
- const filename = name ? `screenshot-${name}` : `screenshot-${Date.now()}`;
192
- // A REPL on a socket of its own keeps its screenshots next to it, as its log is.
193
- return path.join(process.env.PW_SCREENSHOT_DIR || state.ownDir || '/tmp', `${filename}.png`);
194
- }
195
-
196
- // Per page: a CDP session's emulation applies to the page it is attached to
197
- // and resets, in part, when it detaches, so one is kept for each tab.
198
- const keptSessions = new WeakMap();
199
-
200
- function keptSession(p) {
201
- if (!keptSessions.has(p)) keptSessions.set(p, p.context().newCDPSession(p).catch(error => { keptSessions.delete(p); throw error; }));
202
- return keptSessions.get(p);
203
- }
204
-
205
- // Chrome all but stops drawing a tab that is not in front once it has had
206
- // input (about a frame a second), and Playwright waits for frames to see an
207
- // element hold still before acting on it, so each click in a tab opened in the
208
- // background took 1-2s. A screencast keeps the tab drawing without bringing it
209
- // to the front, for as long as the REPL runs (the throttle is back within
210
- // seconds of stopping it). Its frames are tiny and rare; the cost is that the
211
- // tab renders at full rate, as if in front, an animation or a video included.
212
- const drawingPages = new WeakSet();
213
-
214
- async function keepDrawing(p) {
215
- if (drawingPages.has(p)) return;
216
- drawingPages.add(p);
217
- try {
218
- const session = await keptSession(p);
219
- session.on('Page.screencastFrame', frame => session.send('Page.screencastFrameAck', { sessionId: frame.sessionId }).catch(() => {}));
220
- await session.send('Page.startScreencast', { format: 'jpeg', quality: 1, maxWidth: 8, maxHeight: 8, everyNthFrame: 60 });
221
- } catch {
222
- // Only slower without it.
223
- drawingPages.delete(p);
224
- }
225
- }
226
-
227
- const networkEnabled = new WeakSet();
228
- // page -> how its network is changed: { offline: true }, or slowed { latency, down, up } (ms, kbps).
229
- const networkChanged = new WeakMap();
230
- // DevTools' "Slow 4G".
231
- const SLOW_DEFAULT = { latency: 563, down: 1440, up: 675 };
232
- const SLOW_LATENCY_MAX = 10000;
233
-
234
- // setting null restores the network.
235
- async function setNetwork(p, setting) {
236
- const session = await keptSession(p);
237
- if (!networkEnabled.has(p)) { await session.send('Network.enable'); networkEnabled.add(p); }
238
- await session.send('Network.emulateNetworkConditions', {
239
- offline: !!setting?.offline,
240
- latency: setting?.latency || 0,
241
- // kbps to bytes per second; -1 is no limit.
242
- downloadThroughput: setting?.down ? setting.down * 125 : -1,
243
- uploadThroughput: setting?.up ? setting.up * 125 : -1,
244
- });
245
- if (setting) networkChanged.set(p, { ...setting, owner: state.client }); else networkChanged.delete(p);
246
- }
247
-
248
- function networkText(setting) {
249
- return setting.offline ? 'off (offline)' : `slow (${setting.latency}ms latency, ${setting.down} kbps down, ${setting.up} kbps up)`;
250
- }
251
-
252
- // page -> what emulate changed: { device, scheme, locale, timezone }, each unset when off.
253
- const emulations = new WeakMap();
254
- const DEFAULT_DEVICE = 'Pixel 7';
255
- const EMULATE_KINDS = ['mobile', 'dark', 'light', 'locale', 'timezone'];
256
-
257
- function emulatedKinds(e) {
258
- return [e?.device && 'mobile', e?.scheme, e?.locale && 'locale', e?.timezone && 'timezone'].filter(Boolean);
259
- }
260
-
261
- const DEVICES_SHOWN = 20;
262
-
263
- function deviceNamed(name) {
264
- const names = Object.keys(devices);
265
- const found = names.find(d => d.toLowerCase() === name.toLowerCase());
266
- if (found) return found;
267
- // The names that have every word typed, so a near miss finds the exact name.
268
- const words = name.toLowerCase().split(/\s+/);
269
- const close = names.filter(d => !/ landscape$/.test(d) && words.every(w => d.toLowerCase().includes(w)));
270
- if (!close.length) throw new Error(`No device ${JSON.stringify(name)}; names are Playwright's, e.g. Pixel 7, iPhone 13, iPad Mini, Galaxy S9+`);
271
- const more = close.length > DEVICES_SHOWN ? `, and ${close.length - DEVICES_SHOWN} more` : '';
272
- throw new Error(`No device ${JSON.stringify(name)}; matching: ${close.slice(0, DEVICES_SHOWN).join(', ')}${more} (each also as "<name> landscape")`);
273
- }
274
-
275
- function deviceText(name) {
276
- const d = devices[name];
277
- return `${name}, ${d.viewport.width}x${d.viewport.height} at ${d.deviceScaleFactor}x${d.hasTouch ? ', touch' : ''}`;
278
- }
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
-
309
- // Sends only what changed. Chrome takes the user agent and the languages
310
- // together, so a change to either sends both.
311
- function deviceMetrics(session, name) {
312
- const d = devices[name];
313
- return session.send('Emulation.setDeviceMetricsOverride', {
314
- width: d.viewport.width, height: d.viewport.height, deviceScaleFactor: d.deviceScaleFactor, mobile: d.isMobile,
315
- screenWidth: d.screen?.width || d.viewport.width, screenHeight: d.screen?.height || d.viewport.height,
316
- });
317
- }
318
-
319
- async function setEmulation(p, next) {
320
- const session = await keptSession(p);
321
- const previous = emulations.get(p) || {};
322
- const send = (method, params) => session.send(method, params);
323
- if (next.device !== previous.device) {
324
- const d = devices[next.device];
325
- if (d) {
326
- await deviceMetrics(session, next.device);
327
- } else {
328
- await send('Emulation.clearDeviceMetricsOverride');
329
- }
330
- await send('Emulation.setTouchEmulationEnabled', d?.hasTouch ? { enabled: true, maxTouchPoints: 5 } : { enabled: false });
331
- }
332
- if (next.device !== previous.device || next.locale !== previous.locale) {
333
- if (next.device || next.locale) {
334
- const userAgent = next.device ? devices[next.device].userAgent : (await send('Browser.getVersion')).userAgent;
335
- await send('Emulation.setUserAgentOverride', { userAgent, ...(next.locale ? { acceptLanguage: next.locale } : {}) });
336
- } else {
337
- // An empty user agent ends the override.
338
- await send('Emulation.setUserAgentOverride', { userAgent: '' });
339
- }
340
- }
341
- if (next.locale !== previous.locale) await send('Emulation.setLocaleOverride', next.locale ? { locale: next.locale } : {});
342
- if (next.timezone !== previous.timezone) await send('Emulation.setTimezoneOverride', { timezoneId: next.timezone || '' });
343
- if (next.scheme !== previous.scheme) await send('Emulation.setEmulatedMedia', { features: next.scheme ? [{ name: 'prefers-color-scheme', value: next.scheme }] : [] });
344
- if (emulatedKinds(next).length) emulations.set(p, { ...next, owner: state.client }); else emulations.delete(p);
345
- }
346
-
347
- function showEmulation() {
348
- const e = emulations.get(state.page);
349
- const forms = [
350
- ['emulate mobile [device]', `a phone's screen, touch and user agent (default ${DEFAULT_DEVICE})`],
351
- ['emulate dark | light', 'the color scheme the page sees'],
352
- ['emulate locale <tag>', 'its language and formats, e.g. fr-FR'],
353
- ['emulate timezone <zone>', 'e.g. Asia/Tokyo'],
354
- ];
355
- if (!e) {
356
- out.log('Nothing is emulated in the selected tab.');
357
- out.log(hints(forms));
358
- return;
359
- }
360
- out.log('Emulated in the selected tab:');
361
- const rows = [
362
- e.device && ['mobile', deviceText(e.device)],
363
- e.scheme && ['color scheme', e.scheme],
364
- e.locale && ['locale', e.locale],
365
- e.timezone && ['timezone', e.timezone],
366
- ].filter(Boolean);
367
- for (const [what, value] of rows) out.log(` ${what.padEnd(12)} ${value}`);
368
- out.log(hints([...forms, ['emulate <what> off | emulate off', 'stop one, or all']]));
369
- }
370
-
371
- // Resets every tab's emulation, which detaching would only partly undo.
372
- async function resetEmulations() {
373
- if (!state.browser || state.connectionLost) return;
374
- const pages = state.browser.contexts().flatMap(c => c.pages()).filter(p => emulations.has(p) && !p.isClosed());
375
- await Promise.all(pages.map(p => setEmulation(p, {}).catch(() => {})));
376
- }
377
-
378
- // page -> Map(glob -> { status, body, handler }). Playwright routes are
379
- // registered per page, so the listing is too.
380
- const pageRoutes = new WeakMap();
381
- const ROUTE_PREVIEW = 80;
382
-
383
- function routesFor(p) {
384
- if (!pageRoutes.has(p)) pageRoutes.set(p, new Map());
385
- return pageRoutes.get(p);
386
- }
387
-
388
- const ROUTE_DELAY_MAX = 120;
389
-
390
- // Marks a request a route answered, now: the browser reports it finished only later.
391
- function markChanged(req, status, how) {
392
- changedRequests.set(req, how);
393
- const entry = requestEntries.get(req);
394
- if (entry) { entry.status = `${status} ${how}`; entry.ms = Date.now() - entry.t; }
395
- }
396
-
397
- // A JSON Merge Patch (RFC 7386): objects merge, null removes a key, anything else replaces.
398
- function mergePatch(target, patch) {
399
- if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return patch;
400
- const result = target && typeof target === 'object' && !Array.isArray(target) ? { ...target } : {};
401
- for (const [key, value] of Object.entries(patch)) {
402
- if (value === null) delete result[key];
403
- else result[key] = mergePatch(result[key], value);
404
- }
405
- return result;
406
- }
407
-
408
- function parseJson(text, what) {
409
- try { return JSON.parse(text); } catch (error) { throw new Error(`${what} is not valid JSON: ${error.message}`); }
410
- }
411
-
412
- // How a route treats the requests it matches: its text for listing, and what it does to each one.
413
- function routeKind(how, rest, usage) {
414
- if (/^\d{3}$/.test(how)) {
415
- const status = Number(how);
416
- if (status < 200 || status > 599) throw new Error('Status must be from 200 to 599');
417
- // JSON unless its type is given, so a typo in the JSON is not served as text.
418
- const typed = /^--content-type=("(?:[^"\\]|\\.)*"|'[^']*'|\S+)(?:\s+([\s\S]*))?$/.exec(rest || '');
419
- const body = typed ? typed[2] || '' : rest || '';
420
- if (!typed && body) {
421
- try { JSON.parse(body); } catch (error) {
422
- throw new Error(`Body is not valid JSON: ${error.message}; for another kind, give its type: route <url-glob> <status> --content-type=text/plain <body>`);
423
- }
424
- }
425
- // Quoted when it has a parameter: --content-type="text/html; charset=utf-8".
426
- const contentType = typed ? unquote(typed[1]) : body ? 'application/json' : undefined;
427
- const preview = body.length > ROUTE_PREVIEW ? `${body.slice(0, ROUTE_PREVIEW)}…` : body;
428
- return {
429
- text: `${status}${typed ? ` (${contentType})` : ''}${preview ? ` ${preview}` : ''}`,
430
- async handle(r, req, tag) {
431
- markChanged(req, status, 'faked');
432
- await r.fulfill({ status, contentType, body });
433
- out.notice(`Faked: ${tag()} -> ${status}`);
434
- },
435
- };
436
- }
437
- if (how === 'patch') {
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.
440
- const patch = parseJson(rest, 'Patch');
441
- return {
442
- text: `patch ${rest.length > ROUTE_PREVIEW ? `${rest.slice(0, ROUTE_PREVIEW)}…` : rest}`,
443
- async handle(r, req, tag) {
444
- const response = await r.fetch();
445
- let body;
446
- try { body = await response.json(); } catch {
447
- await r.fulfill({ response });
448
- out.notice(`Not patched: ${tag()} — its response is not JSON, so it went through unchanged`);
449
- return;
450
- }
451
- markChanged(req, response.status(), 'patched');
452
- await r.fulfill({ response, json: mergePatch(body, patch) });
453
- out.notice(`Patched: ${tag()} -> ${response.status()}`);
454
- },
455
- };
456
- }
457
- if (how === 'delay') {
458
- const seconds = Number(rest);
459
- if (!/^\d+(?:\.\d+)?$/.test(rest || '') || seconds <= 0 || seconds > ROUTE_DELAY_MAX) throw new Error(`Usage: route <url-glob> delay <seconds> (up to ${ROUTE_DELAY_MAX})`);
460
- return {
461
- text: `delay ${seconds}s`,
462
- async handle(r, req, tag) {
463
- await new Promise(resolve => setTimeout(resolve, seconds * 1000));
464
- await r.continue();
465
- out.notice(`Delayed ${seconds}s: ${tag()}`);
466
- },
467
- };
468
- }
469
- if (how === 'abort' && !rest) {
470
- return {
471
- text: 'abort',
472
- async handle(r, req, tag) {
473
- await r.abort('failed');
474
- out.notice(`Aborted: ${tag()}`);
475
- },
476
- };
477
- }
478
- throw new Error(usage);
479
- }
480
-
481
- function listRoutes() {
482
- const routes = routesFor(state.page);
483
- const forms = [
484
- ['route <url-glob> <status> [json-body]', 'answer it with this status and JSON'],
485
- ['route <url-glob> <status> --content-type=<type> <body>', 'or with a body of another type'],
486
- ['route <url-glob> patch <json>', 'let it through, then change its JSON'],
487
- ['route <url-glob> delay <seconds>', 'hold it, then let it through'],
488
- ['route <url-glob> abort', 'fail it as if the connection broke'],
489
- ];
490
- if (!routes.size) {
491
- out.log('No routes on the selected tab.');
492
- out.log(hints(forms));
493
- return;
494
- }
495
- out.log(`Routes on the selected tab (${routes.size}):`);
496
- const width = Math.max(...[...routes.keys()].map(glob => glob.length));
497
- for (const [glob, { text }] of routes) out.log(` ${glob.padEnd(width)} ${text}`);
498
- out.log(hints([...forms, ['route off <url-glob> | route off --all', 'remove']]));
499
- }
500
-
501
- async function removeRoutes(glob) {
502
- if (!glob) throw new Error('Usage: route off <url-glob> | route off --all');
503
- const routes = routesFor(state.page);
504
- if (glob !== '--all' && !routes.has(glob)) throw new Error(`No route for ${glob} on the selected tab`);
505
- const removed = await unroute(state.page, glob === '--all' ? [...routes.keys()] : [glob]);
506
- for (const g of removed) out.log(`Removed: ${g}`);
507
- if (!removed.length) out.log('No routes on the selected tab');
508
- }
509
-
510
- async function unroute(p, globs) {
511
- const routes = routesFor(p);
512
- for (const g of globs) {
513
- await p.unroute(g, routes.get(g).handler);
514
- routes.delete(g);
515
- }
516
- return globs;
517
- }
518
-
519
- // Always-on request and console logs, so what happened can be checked after
520
- // someone has already clicked through without a capture running. Requests
521
- // keep metadata plus a handle to the response; a body is read from the
522
- // browser only when asked for, and only while the browser still has it.
523
- const RECENT_MAX = 200;
524
- const RECENT_DEFAULT = 20;
525
- const BODY_TIMEOUT = 15000;
526
- // Hidden unless asked for: they are rarely what is being checked and crowd out
527
- // the API calls that are.
528
- const RECENT_HIDDEN_TYPES = new Set(['image', 'font', 'stylesheet', 'media']);
529
- // The bodies body prints; any other is binary.
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
- }
538
- // A watched tab keeps each body watch shows as it arrives, since the browser
539
- // drops them once the tab navigates, often before anyone has read them.
540
- const KEPT_BODY_MAX = 100 * 1024;
541
- const recentLogs = new WeakMap();
542
- const consoleLogs = new WeakMap();
543
- // request -> how a route changed it (faked or patched), shown after its status.
544
- const changedRequests = new WeakMap();
545
- const requestEntries = new WeakMap();
546
- // page -> when its page last fired its load event.
547
- const loadedAt = new WeakMap();
548
- // Tabs this REPL opened with tab new.
549
- const openedTabs = new WeakSet();
550
- // page -> the client that opened it (null: the prompt, or a sender without a name).
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();
554
-
555
- function clipText(text) {
556
- return text.length > MAX_EVENT_TEXT ? `${text.slice(0, MAX_EVENT_TEXT)}…` : text;
557
- }
558
-
559
- // The browser's own "Failed to load resource" message does not say which one.
560
- function consoleText(msg) {
561
- const text = msg.text();
562
- const url = msg.location()?.url;
563
- return /^Failed to load resource/.test(text) && url ? `${text}: ${url}` : text;
564
- }
565
-
566
- // A level includes the ones above it, as playwright-cli's console [min-level]
567
- // does, which sorts the types the same way; a page error is an error.
568
- const CONSOLE_LEVELS = ['error', 'warning', 'info', 'debug'];
569
- const DEBUG_TYPES = new Set(['clear', 'debug', 'endGroup', 'profile', 'profileEnd', 'startGroup', 'startGroupCollapsed', 'trace']);
570
-
571
- function consoleLevel(type) {
572
- if (type === 'error' || type === 'assert' || type === 'pageerror') return 'error';
573
- if (type === 'warning') return 'warning';
574
- return DEBUG_TYPES.has(type) ? 'debug' : 'info';
575
- }
576
-
577
- function keep(log, entry) {
578
- log.push(entry);
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;
587
- }
588
-
589
- function ensureRecentLog(p) {
590
- if (recentLogs.has(p)) return;
591
- const log = [];
592
- const logs = [];
593
- let nextId = 1;
594
- let newDocument = false;
595
- recentLogs.set(p, log);
596
- consoleLogs.set(p, logs);
597
- p.on('request', req => {
598
- let navigation = false;
599
- try { navigation = req.isNavigationRequest() && req.frame() === p.mainFrame(); } catch {}
600
- const entry = { id: nextId++, t: Date.now(), method: req.method(), url: req.url(), type: req.resourceType(), status: 'pending', ms: null, response: null, navigation };
601
- if (navigation) newDocument = true;
602
- requestEntries.set(req, entry);
603
- keep(log, entry);
604
- });
605
- const finish = (req, status, response = null) => {
606
- const entry = requestEntries.get(req);
607
- if (!entry) return;
608
- entry.status = status;
609
- entry.ms = Date.now() - entry.t;
610
- entry.response = response;
611
- };
612
- p.on('requestfinished', async req => {
613
- let status = 'no response';
614
- let res = null;
615
- try {
616
- res = await req.response();
617
- if (res) status = String(res.status());
618
- } catch {}
619
- finish(req, changedRequests.has(req) ? `${status} ${changedRequests.get(req)}` : status, res);
620
- keepBody(p, requestEntries.get(req), res);
621
- });
622
- p.on('requestfailed', async req => {
623
- // A page that never loaded: the app's next route change is not a load either.
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
- }
638
- });
639
- p.on('load', () => loadedAt.set(p, Date.now()));
640
- // A page load, marked among the messages it comes between; it ages out with them. Only a new
641
- // document counts: an app's own route change (pushState, a hash) navigates without loading.
642
- p.on('framenavigated', frame => {
643
- if (frame !== p.mainFrame() || !newDocument) return;
644
- newDocument = false;
645
- keep(logs, { t: Date.now(), type: 'load', text: frame.url() });
646
- });
647
- p.on('console', msg => keep(logs, { t: Date.now(), type: msg.type(), text: clipText(consoleText(msg)) }));
648
- // Uncaught exceptions never reach the console event.
649
- p.on('pageerror', error => keep(logs, { t: Date.now(), type: 'pageerror', text: clipText(error.stack || error.message) }));
650
- }
651
-
652
- // Unnamed generic nodes are layout wrappers (mostly divs): they add depth and
653
- // nothing to read. Drop them, lift their children, and drop cursor hints.
654
- function compactSnapshot(text) {
655
- const dropped = [];
656
- const lines = [];
657
- for (const line of text.split('\n')) {
658
- const indent = line.length - line.trimStart().length;
659
- while (dropped.length && dropped[dropped.length - 1] >= indent) dropped.pop();
660
- if (/^- generic(?: \[[^\]]+\])*:?$/.test(line.trim())) { dropped.push(indent); continue; }
661
- lines.push(' '.repeat(Math.max(0, indent - 2 * dropped.length)) + line.trimStart().replace(/ \[cursor=pointer\]/g, ''));
662
- }
663
- return lines.join('\n');
664
- }
665
-
666
- // Opt-in record of what the person at the browser does, so an agent can see
667
- // the steps and the requests each one caused. Values typed into fields are
668
- // never recorded, and password fields not at all.
669
- const watches = new WeakMap();
670
- const WATCH_MAX = 200;
671
- const WATCH_REQUESTS_SHOWN = 5;
672
- // Scripts too: a dev server loads dozens per navigation, crowding out the API calls.
673
- const WATCH_HIDDEN_TYPES = new Set([...RECENT_HIDDEN_TYPES, 'script']);
674
-
675
- // Tabs whose watch on --next-tab is still starting: the page can load before it is ready.
676
- const startingWatches = new WeakSet();
677
-
678
- // A page that navigates as soon as a request answers (a login that redirects)
679
- // takes the body with it before the request is even reported finished; told to
680
- // in time, the browser keeps them outside the page instead. Once per tab, from
681
- // its first watch, and never switched off: up to 50 MB of the browser's memory
682
- // per watched tab, for as long as the REPL runs.
683
- const DURABLE_TOTAL = 50 * 1024 * 1024;
684
- const durablePages = new WeakSet();
685
-
686
- async function keepBodiesDurable(p) {
687
- if (durablePages.has(p)) return;
688
- durablePages.add(p);
689
- try {
690
- const session = await keptSession(p);
691
- await session.send('Network.configureDurableMessages', { maxTotalBufferSize: DURABLE_TOTAL, maxResourceBufferSize: KEPT_BODY_MAX });
692
- } catch {
693
- // An older Chrome: bodies are still kept once their requests finish.
694
- }
695
- }
696
-
697
- async function keepBody(p, entry, res) {
698
- if (!entry || !res || !(watches.get(p)?.on || startingWatches.has(p)) || WATCH_HIDDEN_TYPES.has(entry.type)) return;
699
- const headers = res.headers();
700
- if (!TEXT_BODY.test(headers['content-type'] || '') || Number(headers['content-length']) > KEPT_BODY_MAX) return;
701
- try {
702
- const buffer = await res.body();
703
- if (buffer.length <= KEPT_BODY_MAX) entry.kept = buffer;
704
- } catch {}
705
- }
706
- const TYPING_BACKDATE_MAX = 60000;
707
- const PAGE_ACTIONS = new Set(['click', 'check', 'uncheck', 'select', 'fill', 'type', 'press', 'submit']);
708
- // page -> its binding's record function; the binding is added once per tab.
709
- const watchBindings = new WeakMap();
710
- const WATCH_BINDING = '__pwReplWatch';
711
-
712
- // Runs in the page. Describes elements the way snapshot does: role and name.
713
- const WATCH_SCRIPT = `(() => {
714
- if (window.__pwReplWatching) return;
715
- window.__pwReplWatching = true;
716
- const INTERACTIVE = 'a,button,input,select,textarea,summary,label,[role],[onclick],[tabindex]';
717
- const roleOf = el => {
718
- const explicit = el.getAttribute('role');
719
- if (explicit) return explicit;
720
- const tag = el.tagName.toLowerCase();
721
- const type = (el.getAttribute('type') || '').toLowerCase();
722
- if (tag === 'a' && el.hasAttribute('href')) return 'link';
723
- if (tag === 'button' || (tag === 'input' && ['button', 'submit', 'reset', 'image'].includes(type))) return 'button';
724
- if (tag === 'input' && type === 'checkbox') return 'checkbox';
725
- if (tag === 'input' && type === 'radio') return 'radio';
726
- if (tag === 'input' || tag === 'textarea') return 'textbox';
727
- if (tag === 'select') return 'combobox';
728
- if (/^h[1-6]$/.test(tag)) return 'heading';
729
- return tag;
730
- };
731
- // Text inside these may be what the person typed, so it is never used as a name.
732
- const typedInto = el => el.matches('input,select,textarea,form') || el.isContentEditable || !!el.closest('[contenteditable]:not([contenteditable=false])');
733
- const isPassword = el => el.matches('input[type=password]') || (el.control && el.control.matches('input[type=password]'));
734
- // An element holding an editable area would be named partly by what was typed there.
735
- const hasEditable = el => !!el.querySelector('[contenteditable]:not([contenteditable=false])');
736
- const clip = text => (text || '').replace(/\\s+/g, ' ').trim().slice(0, 60);
737
- // A label's own words, without the options or values of the controls inside it.
738
- const labelText = label => {
739
- const copy = label.cloneNode(true);
740
- copy.querySelectorAll('select,textarea,input,button').forEach(n => n.remove());
741
- return copy.textContent;
742
- };
743
- const nameOf = el => {
744
- const labelledBy = el.getAttribute('aria-labelledby');
745
- const byId = labelledBy && labelledBy.split(/\\s+/).map(id => document.getElementById(id)?.innerText).join(' ');
746
- const label = (el.id && document.querySelector('label[for="' + CSS.escape(el.id) + '"]')) || el.closest('label');
747
- return clip(el.getAttribute('aria-label') || byId || (label && label !== el && labelText(label)) || el.getAttribute('alt')
748
- || el.getAttribute('placeholder') || el.getAttribute('title') || (typedInto(el) || hasEditable(el) ? '' : el.innerText));
749
- };
750
- const describe = el => { const name = nameOf(el); return roleOf(el) + (name ? ' ' + JSON.stringify(name) : ''); };
751
- // el null: the page itself, e.g. Escape with nothing focused.
752
- const send = (action, el, extra, since) => {
753
- // Typing still waiting for its pause is recorded first, so the steps stay in order.
754
- if (action !== 'type') for (const field of [...pending.keys()]) flushTyping(field);
755
- if (typeof window.__pwReplWatch === 'function') window.__pwReplWatch(JSON.stringify({ action, target: el ? describe(el) : 'page', extra: extra || '', since: since || 0 }));
756
- };
757
- document.addEventListener('click', e => {
758
- const el = e.target.closest ? (e.target.closest(INTERACTIVE) || e.target) : e.target;
759
- if (!el.matches || el.matches('input[type=checkbox],input[type=radio],select') || isPassword(el)) return;
760
- send('click', el);
761
- }, true);
762
- // Typing is recorded once it pauses, not on change, so a typeahead's requests
763
- // land under the typing that caused them. A field typed into is not also
764
- // recorded as a fill when it loses focus.
765
- const TYPING_PAUSE = 600;
766
- const pending = new Map();
767
- const typed = new WeakSet();
768
- // Sent with how long ago the typing began, so the step is placed before the
769
- // requests the typing caused.
770
- const flushTyping = el => {
771
- if (!pending.has(el)) return;
772
- const { timer, start } = pending.get(el);
773
- clearTimeout(timer);
774
- pending.delete(el);
775
- typed.add(el);
776
- send('type', el, '', Date.now() - start);
777
- };
778
- const editable = el => el.isContentEditable ? (el.closest('[contenteditable]:not([contenteditable=false])') || el) : null;
779
- const textEntry = el => el.matches('textarea,input:not([type=checkbox],[type=radio],[type=button],[type=submit],[type=reset],[type=image],[type=file],[type=range],[type=color])');
780
- document.addEventListener('input', e => {
781
- const el = editable(e.target) || e.target;
782
- if (!el.matches || isPassword(el) || !(textEntry(el) || el.isContentEditable)) return;
783
- const start = pending.has(el) ? pending.get(el).start : Date.now();
784
- clearTimeout(pending.get(el)?.timer);
785
- pending.set(el, { start, timer: setTimeout(() => flushTyping(el), TYPING_PAUSE) });
786
- }, true);
787
- document.addEventListener('keydown', e => {
788
- if (e.key !== 'Enter' && e.key !== 'Escape') return;
789
- if (!e.target.matches) return;
790
- const el = editable(e.target) || e.target.closest(INTERACTIVE);
791
- if (el && isPassword(el)) return;
792
- // Enter on a button or link is recorded as the click it causes.
793
- if (e.key === 'Enter' && !(el && (textEntry(el) || el.isContentEditable))) return;
794
- if (el) flushTyping(el);
795
- send('press', el, e.key);
796
- }, true);
797
- document.addEventListener('change', e => {
798
- const el = e.target;
799
- if (!el.matches || isPassword(el)) return;
800
- if (el.matches('input[type=checkbox],input[type=radio]')) return send(el.checked ? 'check' : 'uncheck', el);
801
- if (el.matches('select')) return send('select', el, JSON.stringify(clip(el.selectedOptions[0]?.text)));
802
- if (pending.has(el)) { flushTyping(el); typed.delete(el); return; }
803
- if (typed.has(el)) { typed.delete(el); return; }
804
- send('fill', el);
805
- }, true);
806
- document.addEventListener('submit', e => send('submit', e.target), true);
807
- })()`;
808
-
809
- // What changed on screen between two snapshots, in a few lines. Refs, links'
810
- // URLs and focus are dropped, and so are the values of text fields, which may
811
- // be what the person typed. A new or removed element is shown once, with the
812
- // first few named things inside it, not line by line.
813
- const CHANGES_SHOWN = 5;
814
- const CHANGE_WIDTH = 100;
815
- const CHANGE_NAMES_SHOWN = 3;
816
- const FIELD_VALUE = /^(- (?:textbox|combobox|searchbox|spinbutton)\b[^:]*?)(?::.*)?$/;
817
-
818
- function snapshotNodes(text) {
819
- const nodes = [];
820
- const stack = [];
821
- for (const line of compactSnapshot(text).split('\n')) {
822
- let body = line.trim().replace(/ \[ref=[^\]]+\]/g, '').replace(/ \[active\]/g, '');
823
- if (!body || /^- \/url:/.test(body)) continue;
824
- body = body.replace(FIELD_VALUE, '$1').replace(/:$/, '');
825
- const indent = line.length - line.trimStart().length;
826
- while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();
827
- const node = { body, indent, parent: stack.length ? stack[stack.length - 1] : null, children: [] };
828
- if (node.parent) node.parent.children.push(node);
829
- nodes.push(node);
830
- stack.push(node);
831
- }
832
- return nodes;
833
- }
834
-
835
- // Nodes of `nodes` with no match in `others`, compared by their text; each
836
- // kept only if its parent is matched, so a new subtree counts once.
837
- function unmatchedTops(nodes, others) {
838
- const counts = new Map();
839
- for (const o of others) counts.set(o.body, (counts.get(o.body) || 0) + 1);
840
- const unmatched = new Set();
841
- for (const n of nodes) {
842
- const c = counts.get(n.body);
843
- if (c) counts.set(n.body, c - 1); else unmatched.add(n);
844
- }
845
- return nodes.filter(n => unmatched.has(n) && !(n.parent && unmatched.has(n.parent)));
846
- }
847
-
848
- // role "name", without flags or text: how an element is told apart.
849
- const identity = body => /^- ([^[:"]+?(?: "(?:[^"\\]|\\.)*")?)(?: \[|:|$)/.exec(body)?.[1] || body;
850
-
851
- function describeSubtree(node) {
852
- const named = [];
853
- // A named element by role and name; an unnamed one by its text, if it has any.
854
- const label = body => /^- [^:"]+ "(?:[^"\\]|\\.)*"/.exec(body)?.[0].slice(2) || /^- [^:"]+: (.+)$/.exec(body)?.[1];
855
- // What is inside a named element is mostly its name again, so it is not listed.
856
- const walk = n => {
857
- for (const c of n.children) {
858
- const l = label(c.body);
859
- if (l && named.length < 50) named.push(l);
860
- if (!/"/.test(l || '')) walk(c);
861
- }
862
- };
863
- walk(node);
864
- let text = node.body.replace(/^- /, '');
865
- // The count goes first so clipping a long line cuts names, not the count.
866
- if (named.length > CHANGE_NAMES_SHOWN) text += ` (${named.length} named inside)`;
867
- if (named.length) text += `: ${named.slice(0, CHANGE_NAMES_SHOWN).join(', ')}${named.length > CHANGE_NAMES_SHOWN ? ', …' : ''}`;
868
- return text;
869
- }
870
-
871
- function summarizeChanges(beforeText, afterText) {
872
- const before = snapshotNodes(beforeText);
873
- const after = snapshotNodes(afterText);
874
- const added = unmatchedTops(after, before);
875
- const removed = unmatchedTops(before, after);
876
- const lines = [];
877
- // Same element, different flags or text: one changed line, not a remove and an add.
878
- for (const a of added) {
879
- const r = removed.find(x => identity(x.body) === identity(a.body) && x.indent === a.indent);
880
- if (r) { removed.splice(removed.indexOf(r), 1); lines.push(`~ ${a.body.replace(/^- /, '')}`); }
881
- else lines.push(`+ ${describeSubtree(a)}`);
882
- }
883
- for (const r of removed) lines.push(`- ${describeSubtree(r)}`);
884
- const clipped = lines.map(l => (l.length > CHANGE_WIDTH ? `${l.slice(0, CHANGE_WIDTH - 1)}…` : l));
885
- if (clipped.length > CHANGES_SHOWN) return [...clipped.slice(0, CHANGES_SHOWN), `… ${clipped.length - CHANGES_SHOWN} more changes`];
886
- return clipped;
887
- }
888
-
889
- // With watch on --changes: once the page settles after a step (no new step
890
- // for a moment and none of its requests pending), snapshot it and attach the
891
- // difference from the previous snapshot to that step. With --live, that is
892
- // also when the step is printed, with its requests and changes.
893
- const SETTLE_QUIET = 800;
894
- const SETTLE_MAX = 3000;
895
-
896
- // Text in an editable area (contenteditable) may be what the person typed,
897
- // and the snapshot shows it as ordinary text, so it is blanked out: a line's
898
- // text or name is dropped when it is part of an editable area's text.
899
- async function snapshotText(p) {
900
- const text = await p.ariaSnapshot({ mode: 'ai', timeout: SETTLE_MAX });
901
- const edited = await p.evaluate(() => [...document.querySelectorAll('[contenteditable]:not([contenteditable=false])')]
902
- .map(el => el.innerText.replace(/\s+/g, ' ').trim()).filter(Boolean));
903
- return scrubEditable(text, edited);
904
- }
905
-
906
- function scrubEditable(text, edited) {
907
- if (!edited.length) return text;
908
- const typed = part => { const t = part.replace(/\s+/g, ' ').trim(); return !!t && edited.some(e => e.includes(t) || t.includes(e)); };
909
- return text.split('\n').map(line => {
910
- let out = line.replace(/ "((?:[^"\\]|\\.)*)"/, (whole, name) => (typed(name) ? '' : whole));
911
- const value = /^(\s*- [^:]*?): (.+)$/.exec(out);
912
- if (value && typed(value[2].replace(/^"(.*)"$/, '$1'))) out = value[1];
913
- return out;
914
- }).join('\n');
915
- }
916
-
917
- function scheduleSettle(p, watch) {
918
- clearTimeout(watch.settleTimer);
919
- if (!watch.changes && !watch.live) return;
920
- watch.settleTimer = setTimeout(async () => {
921
- const step = watch.events[watch.events.length - 1];
922
- const deadline = Date.now() + SETTLE_MAX;
923
- const busy = () => (recentLogs.get(p) || []).some(r => r.t >= step.t && r.status === 'pending' && !RECENT_HIDDEN_TYPES.has(r.type));
924
- while (busy() && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 100));
925
- // A newer step restarts the wait; its changes will include these.
926
- if (watch.events[watch.events.length - 1] !== step || p.isClosed()) return;
927
- if (watch.changes) {
928
- let text = null;
929
- try { text = await snapshotText(p); } catch {}
930
- if (watch.events[watch.events.length - 1] !== step) return;
931
- // Against another page, everything differs; the navigate step says enough.
932
- if (text !== null && step.action !== 'navigate' && watch.snapshot !== null) step.changes = summarizeChanges(watch.snapshot, text);
933
- if (text !== null) watch.snapshot = text;
934
- }
935
- printLive(p, watch);
936
- }, SETTLE_QUIET);
937
- }
938
-
939
- // Prints the step --live has not printed yet, above the prompt.
940
- function printLive(p, watch) {
941
- const step = watch.liveStep;
942
- if (!step) return;
943
- watch.liveStep = null;
944
- const lines = stepLines(step, requestsByStep(watch, p)(step));
945
- // Named by its URL when it is not the selected tab, so a step can be placed.
946
- const where = p === state.page ? '' : ` ${p.url().replace(/^[a-z]+:\/\//, '').slice(0, 60)}`;
947
- const prefix = `[watch${where}] `;
948
- out.aside(lines.map((line, i) => `${i ? ' '.repeat(prefix.length) : prefix}${line}`).join('\n'));
949
- }
950
-
951
- async function startWatching(p, changes = false, live = false) {
952
- await keepBodiesDurable(p);
953
- let watch = watches.get(p);
954
- if (!watch) {
955
- // shownSeq and shownRequestId mark how far watch new has read.
956
- const created = { on: false, startedAt: 0, changes: false, live: false, liveStep: null, snapshot: null, settleTimer: null, events: [], nextSeq: 1, shownSeq: 0, shownRequestId: 0 };
957
- // since backdates a step (typing is reported once it pauses), but never to
958
- // before the previous step, so the page cannot reorder the record.
959
- const record = (action, target, extra, since = 0) => {
960
- if (!created.on) return;
961
- const previous = created.events[created.events.length - 1];
962
- const t = Math.max(Date.now() - Math.min(Math.max(Number(since) || 0, 0), TYPING_BACKDATE_MAX), previous ? previous.t : 0);
963
- const step = { seq: created.nextSeq++, t, action, target: clipText(target), extra: clipText(extra) };
964
- keep(created.events, step);
965
- // A step that has not settled yet is printed now: this one ends it.
966
- printLive(p, created);
967
- if (created.live) created.liveStep = step;
968
- scheduleSettle(p, created);
969
- };
970
- // The page can call the binding itself, so only the page-side actions are
971
- // accepted from it, as plain strings; navigations come from here. A CDP
972
- // binding of its own, not Playwright's exposeBinding: every Playwright
973
- // client shares that one, so another tool attached to the browser would
974
- // answer each call with a "not exposed" error thrown into the page.
975
- if (!watchBindings.has(p)) {
976
- const binding = { record: null };
977
- const session = await keptSession(p);
978
- session.on('Runtime.bindingCalled', ({ name, payload }) => {
979
- if (name !== WATCH_BINDING || !binding.record) return;
980
- let event;
981
- try { event = JSON.parse(payload); } catch { return; }
982
- if (event && PAGE_ACTIONS.has(event.action)) binding.record(event.action, String(event.target || ''), String(event.extra || ''), event.since);
983
- });
984
- // Calls are reported only to a session with Runtime on.
985
- await session.send('Runtime.enable');
986
- await session.send('Runtime.addBinding', { name: WATCH_BINDING });
987
- watchBindings.set(p, binding);
988
- }
989
- watchBindings.get(p).record = record;
990
- await p.addInitScript(WATCH_SCRIPT);
991
- p.on('framenavigated', frame => { if (frame === p.mainFrame()) record('navigate', frame.url(), ''); });
992
- // Only now: a failed first attempt must leave nothing half set up to retry against.
993
- watches.set(p, created);
994
- watch = created;
995
- } else if (!watch.on) {
996
- // A watch on after watch off is a new recording: the old steps would read as part of it.
997
- watch.events.length = 0;
998
- watch.shownSeq = watch.nextSeq - 1;
999
- watch.shownRequestId = Math.max(0, ...(recentLogs.get(p) || []).map(r => r.id));
1000
- watch.liveStep = null;
1001
- }
1002
- await p.evaluate(WATCH_SCRIPT);
1003
- watch.changes = changes;
1004
- watch.live = live;
1005
- if (!live) watch.liveStep = null;
1006
- // The first step's changes are measured from how the page looks now. A page
1007
- // too busy to snapshot is not a reason to fail: the step after one that
1008
- // does snapshot is measured instead.
1009
- watch.snapshot = null;
1010
- if (changes) {
1011
- try { watch.snapshot = await snapshotText(p); }
1012
- catch { out.log('Could not snapshot the page yet; changes are shown from a later step.'); }
1013
- }
1014
- if (!watch.on) watch.startedAt = Date.now();
1015
- watch.on = true;
1016
- }
1017
-
1018
- // Set by watch on --next-tab: the next tab to open (by anyone), or the next
1019
- // whose URL contains part, is watched from its first page and selected. Each
1020
- // new tab is watched from the moment it opens, since its first page may load
1021
- // at once; one that turns out not to be it is left as it was.
1022
- let nextTab = null;
1023
-
1024
- // The sender's own --next-tab is still waiting: the tab will be selected for it.
1025
- function waitingForMe() {
1026
- return !!nextTab && nextTab.client === state.client;
1027
- }
1028
- const BROWSER_PAGE = /^(?:about|chrome|chrome-search|devtools|edge):/;
1029
-
1030
- function waitingText() {
1031
- return `Waiting to watch the next tab that opens${nextTab.part ? ` with a URL containing ${nextTab.part}` : ''}`;
1032
- }
1033
-
1034
- function watchNewTab(p) {
1035
- const want = nextTab;
1036
- startingWatches.add(p);
1037
- keepBodiesDurable(p);
1038
- startWatching(p, want.changes, want.live).then(() => {
1039
- startingWatches.delete(p);
1040
- watches.get(p).owner = want.client;
1041
- const onNavigate = frame => {
1042
- if (frame !== p.mainFrame() || BROWSER_PAGE.test(frame.url())) return;
1043
- const url = frame.url();
1044
- p.off('framenavigated', onNavigate);
1045
- const watch = watches.get(p);
1046
- if (nextTab !== want || (want.part && !url.includes(want.part))) {
1047
- stopWatching(p, watch);
1048
- watch.events.length = 0;
1049
- return;
1050
- }
1051
- nextTab = null;
1052
- // Its record starts at its own page, not the new-tab page before it.
1053
- const first = watch.events.findIndex(e => e.action === 'navigate' && e.target === clipText(url));
1054
- if (first > 0) watch.events.splice(0, first);
1055
- // A page that loaded before the watch was ready: its step is added, at its request's time.
1056
- if (first < 0) {
1057
- const request = (recentLogs.get(p) || []).filter(e => e.navigation && e.url === url).pop();
1058
- watch.events.unshift({ seq: watch.nextSeq++, t: request ? request.t : Date.now(), action: 'navigate', target: clipText(url), extra: '' });
1059
- }
1060
- // Selected for the client that asked, whoever's command runs now.
1061
- const record = clientRecord(want.client);
1062
- if (record.page !== p) { record.previousPage = record.page; record.page = p; }
1063
- keepDrawing(p);
1064
- out.notice(`Watching the new tab ${url}, now the selected tab (watch on --next-tab)`);
1065
- };
1066
- p.on('framenavigated', onNavigate);
1067
- // Opened on its page already, as a link to a new tab does.
1068
- if (p.url() && !BROWSER_PAGE.test(p.url())) onNavigate(p.mainFrame());
1069
- }).catch(error => {
1070
- startingWatches.delete(p);
1071
- out.notice(`Could not watch the new tab ${p.url()}: ${error.message}`);
1072
- });
1073
- }
1074
-
1075
- // For each step of a watch, the requests made between it and the next step.
1076
- function requestsByStep(watch, p = state.page) {
1077
- const requests = (recentLogs.get(p) || []).filter(r => !WATCH_HIDDEN_TYPES.has(r.type) && !r.url.startsWith('chrome-extension://'));
1078
- return e => {
1079
- const next = watch.events[watch.events.indexOf(e) + 1];
1080
- return requests.filter(r => r.t >= e.t && (!next || r.t < next.t));
1081
- };
1082
- }
1083
-
1084
- function stepLines(e, caused, note = '', withChanges = true) {
1085
- const lines = [`${clock(e.t)} ${e.action} ${e.target}${e.extra ? ` ${e.extra}` : ''}${note}`];
1086
- const indent = ' '.repeat(clock(e.t).length + 1);
1087
- for (const r of caused.slice(0, WATCH_REQUESTS_SHOWN)) lines.push(`${indent}#${r.id} ${r.method} ${r.status} ${r.url}`);
1088
- if (caused.length > WATCH_REQUESTS_SHOWN) lines.push(`${indent}… ${caused.length - WATCH_REQUESTS_SHOWN} more (requests)`);
1089
- if (e.changes && withChanges) for (const change of e.changes) lines.push(`${indent}${change}`);
1090
- return lines;
1091
- }
1092
-
1093
- // watch on its own: whether it is on, the last steps, and what to run next.
1094
- function showWatch(watch, all) {
1095
- const steps = watch ? watch.events.length : 0;
1096
- const counted = `${steps} step${steps === 1 ? '' : 's'}`;
1097
- const lines = [];
1098
- const extras = [watch?.changes && 'changes', watch?.live && 'live'].filter(Boolean);
1099
- if (watch?.on) lines.push(`Watching the selected tab since ${clock(watch.startedAt)}${extras.length ? ` (${extras.join(', ')})` : ''}: ${steps ? counted : 'nothing has happened yet'}`);
1100
- else if (steps) lines.push(`Not watching the selected tab; ${counted} recorded before watch off:`);
1101
- else lines.push('Not watching the selected tab.');
1102
- if (steps) {
1103
- const causedBy = requestsByStep(watch);
1104
- const shown = watch.events.slice(-RECENT_DEFAULT);
1105
- for (const e of shown) lines.push(...stepLines(e, causedBy(e)));
1106
- if (steps > shown.length) lines.push(`(last ${shown.length} of ${steps})`);
1107
- }
1108
- const more = ['watch <n>', `the last n steps (up to ${WATCH_MAX})`];
1109
- if (watch?.on) lines.push(hints([more, ['watch new', 'only the steps not shown by watch new yet'], ['watch off', 'stop recording']]));
1110
- else if (steps) lines.push(hints([more, ['watch on [--changes] [--live]', 'record again']]));
1111
- else lines.push(hints([['watch on', 'record clicks, typing, form changes and navigations'], ['watch on --changes', 'also record what each step changes on the page'], ['watch on --live', 'also print each step here as it happens']]));
1112
- printOutput(lines.join('\n'), all);
1113
- }
1114
-
1115
- function stopWatching(p, watch) {
1116
- clearTimeout(watch.settleTimer);
1117
- printLive(p, watch);
1118
- watch.on = false;
1119
- watch.changes = false;
1120
- watch.live = false;
1121
- }
1122
-
1123
- // The modes turned on in a tab, in the order the prompt shows them.
1124
- function activeModes(p) {
1125
- const modes = [];
1126
- if (watches.get(p)?.on) modes.push('watch');
1127
- const network = networkChanged.get(p);
1128
- if (network) modes.push(network.offline ? 'network:off' : 'network:slow');
1129
- const emulated = emulatedKinds(emulations.get(p));
1130
- if (emulated.length) modes.push(`emulate:${emulated.join(',')}`);
1131
- const routes = pageRoutes.get(p)?.size;
1132
- if (routes) modes.push(`routes:${routes}`);
1133
- if (cap && cap.page === p) modes.push('capture');
1134
- return modes;
1135
- }
1136
-
1137
- function listModes() {
1138
- const all = state.browser.contexts().flatMap(c => c.pages());
1139
- state.tabListing = all.slice();
1140
- const on = all.map((p, i) => [p, i, activeModes(p)]).filter(([, , modes]) => modes.length);
1141
- if (nextTab) out.log(`${waitingText()} (watch off stops waiting)${nextTab.client ? `, for ${nextTab.client}` : ''}${on.length ? '\n' : ''}`);
1142
- if (!on.length && nextTab) return;
1143
- if (!on.length) {
1144
- out.log('No modes are on in any tab.');
1145
- out.log(hints([
1146
- ['watch on', 'record what the person at the browser does'],
1147
- ['capture on', 'record requests and console messages together'],
1148
- ['route <url-glob> <status> <json-body>', 'fake a response'],
1149
- ['network off', 'cut the tab\'s network'],
1150
- ['emulate mobile', 'show the tab as a phone would'],
1151
- ]));
1152
- return;
1153
- }
1154
- const width = Math.max(...on.map(([p, i]) => `[${i}] ${p.url()}`.length));
1155
- for (const [p, i, modes] of on) {
1156
- out.log(`${p === state.page ? '*' : ' '} ${`[${i}] ${p.url()}`.padEnd(width)} (${modes.join(' ')})`);
1157
- const owners = modeOwners(p);
1158
- if (owners.some(([owner]) => owner)) out.log(` on by ${ownersText(owners)}`);
1159
- }
1160
- out.log(hints([['modes off', 'turn them all off']]));
1161
- }
1162
-
1163
- // Who turned on each mode in a tab: [client, what], client null for the prompt or an unnamed sender.
1164
- function modeOwners(p) {
1165
- const owners = [];
1166
- const watch = watches.get(p);
1167
- if (watch?.on) owners.push([watch.owner ?? null, 'watch']);
1168
- const network = networkChanged.get(p);
1169
- if (network) owners.push([network.owner ?? null, network.offline ? 'network:off' : 'network:slow']);
1170
- const emulated = emulations.get(p);
1171
- if (emulated) owners.push([emulated.owner ?? null, 'emulate']);
1172
- for (const [glob, route] of pageRoutes.get(p) || []) owners.push([route.owner ?? null, `route ${glob}`]);
1173
- if (cap && cap.page === p) owners.push([cap.owner ?? null, 'capture']);
1174
- return owners;
1175
- }
1176
-
1177
- function ownersText(owners) {
1178
- const byOwner = new Map();
1179
- for (const [owner, what] of owners) byOwner.set(owner, [...(byOwner.get(owner) || []), what]);
1180
- return [...byOwner].map(([owner, whats]) => `${owner || 'unnamed'}: ${whats.join(', ')}`).join('; ');
1181
- }
1182
-
1183
- // Turns off everything the REPL turned on, in every tab: nothing it leaves
1184
- // behind keeps acting on someone's browser. mine: only what the running client turned on.
1185
- async function allModesOff(mine = false) {
1186
- const all = state.browser.contexts().flatMap(c => c.pages());
1187
- const ours = owner => !mine || (owner ?? null) === state.client;
1188
- let any = false;
1189
- let failed = 0;
1190
- if (nextTab && ours(nextTab.client)) { nextTab = null; any = true; out.log('Stopped waiting to watch a new tab'); }
1191
- // A tab that fails (e.g. it crashed) must not keep the others' modes on.
1192
- for (const p of all) {
1193
- const done = [];
1194
- let problem = null;
1195
- try {
1196
- const watch = watches.get(p);
1197
- if (watch?.on && ours(watch.owner)) { stopWatching(p, watch); done.push('watch off'); }
1198
- if (cap && cap.page === p && ours(cap.owner)) { endCapture(); done.push('capture off (capture shows it)'); }
1199
- const globs = [...(pageRoutes.get(p) || [])].filter(([, route]) => ours(route.owner)).map(([glob]) => glob);
1200
- if (globs.length) { await unroute(p, globs); done.push(`${globs.length} route${globs.length === 1 ? '' : 's'} removed`); }
1201
- if (networkChanged.has(p) && ours(networkChanged.get(p).owner)) { await setNetwork(p, null); done.push('network on'); }
1202
- if (emulations.has(p) && ours(emulations.get(p).owner)) { await setEmulation(p, {}); done.push('emulate off'); }
1203
- } catch (error) {
1204
- problem = error.message;
1205
- }
1206
- if (done.length) { any = true; out.log(`${p.url()}: ${done.join(', ')}`); }
1207
- if (problem) { failed += 1; out.error(`${p.url()}: could not turn everything off: ${problem}`); }
1208
- }
1209
- if (failed) throw new Error(`Modes may still be on in ${failed} tab${failed === 1 ? '' : 's'}; modes lists them`);
1210
- if (!any) out.log(mine ? 'None of your modes were on.' : 'No modes were on.');
1211
- }
1212
-
1213
- // The type a page sees for a file it is given, from its extension.
1214
- const MIME_TYPES = {
1215
- png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
1216
- pdf: 'application/pdf', txt: 'text/plain', csv: 'text/csv', json: 'application/json', html: 'text/html',
1217
- xml: 'application/xml', zip: 'application/zip', mp4: 'video/mp4', webm: 'video/webm', mp3: 'audio/mpeg',
1218
- wav: 'audio/wav', doc: 'application/msword', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
1219
- docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
1220
- };
1221
-
1222
- function mimeType(file) {
1223
- return MIME_TYPES[path.extname(file).slice(1).toLowerCase()] || 'application/octet-stream';
1224
- }
1225
-
1226
- // Commands that take only a selector take the whole line, spaces and all.
1227
- function soleSelector(args, usage) {
1228
- const text = unquote((args || '').trim());
1229
- if (!text) throw new Error(`Usage: ${usage}`);
1230
- return toSelector(text);
1231
- }
1232
-
1233
- const MODIFIERS = ['Alt', 'Control', 'ControlOrMeta', 'Meta', 'Shift'];
1234
-
1235
- // click <selector> [button] [--modifiers=<keys>], as playwright-cli's click
1236
- // takes them. The button is read only after a ref or a quoted selector, so
1237
- // click text=Turn right is still one selector.
1238
- function clickArgs(args, usage) {
1239
- // Found outside quotes only, so a quoted selector keeps whatever is in it.
1240
- const { found, rest: text } = takeOptions((args || '').trim(), { modifiers: 'value' });
1241
- const modifiers = found.flatMap(o => (o.value || '').split(',').filter(Boolean).map(keyName));
1242
- const unknown = modifiers.find(m => !MODIFIERS.includes(m));
1243
- if (unknown) throw new Error(`Not a modifier: ${unknown}; they are ${MODIFIERS.join(', ')}`);
1244
- const parsed = splitSelector(text);
1245
- const button = parsed && /^(?:left|right|middle)$/.test(parsed.rest || '') && (REF.test(parsed.word) || /^["']/.test(text)) ? parsed.rest : null;
1246
- const selector = button ? parsed.selector : soleSelector(text, usage);
1247
- const options = { ...(button ? { button } : {}), ...(modifiers.length ? { modifiers } : {}) };
1248
- return { selector, shown: args.trim(), options };
1249
- }
1250
-
1251
- // A trailing --submit, as playwright-cli's fill and type take: Enter afterwards.
1252
- function submitFlag(args) {
1253
- if (/(?:^|\s)--submit\s/.test((args || '').trim())) throw new Error('--submit goes at the end of the line');
1254
- const match = /^([\s\S]*?)\s+--submit$/.exec((args || '').trim());
1255
- return match ? { args: match[1], submit: true } : { args, submit: false };
1256
- }
1257
-
1258
- function selectorAndValue(args, usage, example) {
1259
- const parsed = splitSelector(args);
1260
- if (!parsed || parsed.rest === undefined) {
1261
- throw new Error(`Usage: ${usage}, e.g. ${example}; quote a selector with spaces: "text=Your name"`);
1262
- }
1263
- return { selector: parsed.selector, value: unquote(parsed.rest), shown: parsed.word };
1264
- }
1265
-
1266
- // An element that never appeared is said plainly, not as Playwright's call
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.
1270
- async function onElement(selector, action) {
1271
- try {
1272
- return await action();
1273
- } catch (error) {
1274
- const message = String(error.message || '').replace(/\x1b\[[0-9;]*m/g, '');
1275
- if (/strict mode violation/.test(message)) throw error;
1276
- const ref = selector.startsWith('aria-ref=') ? selector.slice(9) : null;
1277
- const staleRef = ref ? `\n${ref} is a snapshot ref; if the page changed since that snapshot, take a new one.` : '';
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
- }
1407
- }
1408
- }
1409
-
1410
- const WAIT_DEFAULT = 10;
1411
- const WAIT_MAX = 120;
1412
-
1413
- // A pattern without * is a URL part; with * it is a glob (* within a path segment, ** across).
1414
- function urlMatcher(pattern) {
1415
- if (!pattern.includes('*')) return url => url.includes(pattern);
1416
- const source = pattern.split('**').map(chunk => chunk.split('*').map(text => text.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*')).join('.*');
1417
- const regex = new RegExp(`^${source}$`);
1418
- return url => regex.test(url);
1419
- }
1420
-
1421
- function printRequest(entry) {
1422
- out.log(`#${entry.id} ${entry.method} ${entry.status} ${entry.url}`);
1423
- }
1424
-
1425
- // Watches the recent log, which has every request from the moment it starts, so
1426
- // one that began or finished since the previous command (e.g. the click that
1427
- // caused it) is found rather than missed.
1428
- async function waitForRequest(pattern, timeout) {
1429
- const matches = urlMatcher(pattern);
1430
- const since = state.previousCommandAt || 0;
1431
- const deadline = Date.now() + timeout;
1432
- for (;;) {
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];
1440
- if (Date.now() >= deadline) throw new Error(`No response matching ${pattern} within ${timeout / 1000}s${entry ? ` (#${entry.id} is still pending)` : ''}`);
1441
- await new Promise(resolve => setTimeout(resolve, 50));
1442
- }
1443
- }
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
-
1456
- // Done once the page has loaded: after the latest navigation of the tab that
1457
- // began since the previous command did (the click on a link, say), or, with
1458
- // none, the document there now. Waiting on Playwright's load state alone would
1459
- // see the page being left, which has already loaded.
1460
- async function waitForLoad(timeout) {
1461
- const p = state.page;
1462
- const since = state.previousCommandAt || 0;
1463
- const deadline = Date.now() + timeout;
1464
- for (;;) {
1465
- const navigation = (recentLogs.get(p) || []).filter(e => e.navigation && e.t >= since).pop();
1466
- if (navigation?.failed && navigation.status !== 'pending') throw new Error(notLoaded(navigation));
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
- }
1474
- if (loaded) { out.log(`Loaded: ${p.url()} — ${await p.title().catch(() => '')}`); return; }
1475
- if (Date.now() >= deadline) throw new Error(`${p.url()} did not finish loading within ${timeout / 1000}s`);
1476
- await new Promise(resolve => setTimeout(resolve, 50));
1477
- }
1478
- }
1479
-
1480
- // Gone once no match is visible, whether it was removed or hidden.
1481
- async function waitGone(locator, what, timeout) {
1482
- try {
1483
- await locator.filter({ visible: true }).first().waitFor({ state: 'detached', timeout });
1484
- } catch (error) {
1485
- if (/Timeout \d+ms exceeded/.test(error.message)) throw new Error(`Still visible after ${timeout / 1000}s: ${what}`);
1486
- throw error;
1487
- }
1488
- out.log(`Gone: ${what}`);
1489
- }
1490
-
1491
- const SNAPSHOT_HINT_LINES = 60;
1492
-
1493
- // Each matching line (any part of it: role, name, flags such as [disabled]),
1494
- // with the named elements it sits in, so a match can be placed without the
1495
- // rest of the tree.
1496
- // needle: text to find in any case, or a RegExp.
1497
- function grepSnapshot(text, needle) {
1498
- const lower = typeof needle === 'string' ? needle.toLowerCase() : null;
1499
- const matches = line => (lower === null ? needle.test(line) : line.toLowerCase().includes(lower));
1500
- const stack = [];
1501
- const hits = [];
1502
- const label = line => line.trim().replace(/^- /, '').replace(/:$/, '').replace(/^'(.*)'$/, '$1');
1503
- for (const line of text.split('\n')) {
1504
- const indent = line.length - line.trimStart().length;
1505
- while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();
1506
- if (matches(line)) {
1507
- const around = stack.filter(a => !/^generic(?: \[|$)/.test(a.label));
1508
- // A hit with no ref of its own (text) keeps the ref of what holds it, so
1509
- // snapshot <ref> can show what sits beside it: the value next to a label.
1510
- const own = /\[ref=/.test(line);
1511
- const path = around.map((a, i) => (!own && i === around.length - 1 ? a.label : a.label.replace(/ \[ref=[^\]]+\]/g, '')));
1512
- hits.push([...path, label(line)].join(' › '));
1513
- }
1514
- stack.push({ indent, label: label(line) });
1515
- }
1516
- return hits;
1517
- }
1518
-
1519
- // For --regex, as playwright-cli's find --regex and requests --filter take one.
1520
- function regexOf(pattern) {
1521
- try { return new RegExp(pattern); } catch (error) { throw new Error(`Not a regular expression: ${error.message}`); }
1522
- }
1523
-
1524
- // A pattern without its quotes, and with \" inside them as ", as snapshot
1525
- // reads one: other backslashes are the regexp's own (\d).
1526
- function patternText(text) {
1527
- return text.replace(/^(["'])(.*)\1$/, '$2').replace(/\\"/g, '"');
1528
- }
1529
-
1530
- // Local time with its offset (18:16:15.721-06:00), so it reads against the
1531
- // user's clock and is still unambiguous.
1532
- function clock(t) {
1533
- const d = new Date(t);
1534
- const pad = (n, width = 2) => String(n).padStart(width, '0');
1535
- const offset = -d.getTimezoneOffset();
1536
- const sign = offset < 0 ? '-' : '+';
1537
- const zone = `${sign}${pad(Math.floor(Math.abs(offset) / 60))}:${pad(Math.abs(offset) % 60)}`;
1538
- return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}${zone}`;
1539
- }
1540
-
1541
- // One line per event, from the start of the capture: a request as requests
1542
- // shows it (its status is looked up now, so it is known if it has finished),
1543
- // a console message or page error as console shows it.
1544
- function printCapture(capture, all = false) {
1545
- const lines = capture.events.map(event => {
1546
- const at = `+${((event.t - capture.startedAt) / 1000).toFixed(3)}s`;
1547
- if (event.tag !== 'request') return `${at} [${event.tag}] ${event.text}`;
1548
- const entry = requestEntries.get(event.req);
1549
- return entry ? `${at} #${entry.id} ${entry.method} ${entry.status} ${entry.url}` : `${at} ${event.text}`;
1550
- });
1551
- if (lines.length) printOutput(lines.join('\n'), all);
1552
- else out.log('Nothing captured');
1553
- const notes = [];
1554
- if (capture.dropped) notes.push(`${capture.dropped} event(s) omitted`);
1555
- if (capture.shortened) notes.push(`${capture.shortened} message(s) shortened`);
1556
- if (notes.length) out.log(`[capture limits: ${notes.join('; ')}]`);
1557
- }
1558
-
1559
- function discardCapture() {
1560
- if (!cap) return;
1561
- cap.handlers.forEach(([event, handler]) => cap.page.off(event, handler));
1562
- cap = null;
1563
- }
1564
-
1565
- // A tab of the REPL's own: closing it can go back to the tab before. It opens
1566
- // in the background, so it does not take the front of the window from the
1567
- // person using the browser; Playwright's newPage would bring it to the front.
1568
- const OPEN_TIMEOUT = 10000;
1569
-
1570
- // True while tab new opens a tab: watch on --next-tab waits for one someone
1571
- // else opens, not one a client of the REPL opens for itself.
1572
- let opening = false;
1573
-
1574
- async function openTab() {
1575
- opening = true;
1576
- try { return await openOwnTab(); } finally { opening = false; }
1577
- }
1578
-
1579
- async function openOwnTab() {
1580
- const ctx = state.browser.contexts()[0];
1581
- let opened = null;
1582
- const session = await state.browser.newBrowserCDPSession().catch(() => null);
1583
- if (session) {
1584
- try {
1585
- const { targetId } = await session.send('Target.createTarget', { url: 'about:blank', background: true });
1586
- opened = await pageForTarget(ctx, targetId);
1587
- } catch {} finally {
1588
- await session.detach().catch(() => {});
1589
- }
1590
- }
1591
- // A browser that cannot open one in the background (e.g. some headless ones) opens it as usual.
1592
- if (!opened) opened = await ctx.newPage();
1593
- openedTabs.add(opened);
1594
- tabOwners.set(opened, state.client);
1595
- keepDrawing(opened);
1596
- return opened;
1597
- }
1598
-
1599
- // page -> its CDP target id, asked for once per page.
1600
- const targetIds = new WeakMap();
1601
-
1602
- async function targetIdOf(ctx, p) {
1603
- if (!targetIds.has(p)) {
1604
- const cdp = await ctx.newCDPSession(p).catch(() => null);
1605
- if (!cdp) return null;
1606
- const info = await cdp.send('Target.getTargetInfo').catch(() => null);
1607
- await cdp.detach().catch(() => {});
1608
- if (!info) return null;
1609
- targetIds.set(p, info.targetInfo.targetId);
1610
- }
1611
- return targetIds.get(p);
1612
- }
1613
-
1614
- async function pageForTarget(ctx, targetId) {
1615
- const deadline = Date.now() + OPEN_TIMEOUT;
1616
- while (Date.now() < deadline) {
1617
- for (const p of ctx.pages()) {
1618
- if (openedTabs.has(p)) continue;
1619
- if (await targetIdOf(ctx, p) === targetId) return p;
1620
- }
1621
- await new Promise(resolve => setTimeout(resolve, 50));
1622
- }
1623
- return null;
1624
- }
1625
-
1626
- // Every tab, URL first, with * on the selected one. The numbers are what tab <index> uses.
1627
- async function listTabs() {
1628
- const all = state.browser.contexts().flatMap(c => c.pages());
1629
- if (state.page && !all.includes(state.page)) state.page = null;
1630
- state.tabListing = all.slice();
1631
- if (!all.length) { out.log('No tabs. Use tab new.'); return; }
1632
- for (const [i, p] of all.entries()) {
1633
- let title;
1634
- try { title = await p.title(); } catch { title = '[title unavailable]'; }
1635
- const marker = p === state.page ? '*' : ' ';
1636
- // URL first on its own line: it is what distinguishes otherwise
1637
- // identically titled tabs, and titles wrap less badly when indented.
1638
- if (i) out.log('');
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(', ')})` : ''}`);
1641
- out.log(` ${JSON.stringify(title)}`);
1642
- }
1643
- }
1644
-
1645
- // A function written out (el => ..., function (el) {...}), as one expression.
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;
1650
- if (!/^\s*(?:async\s+)?(?:function\b|\([^)]*\)\s*=>|[\w$]+\s*=>)/.test(text)) return false;
1651
- try { new Function(`return (${text}\n);`); return true; } catch { return false; }
1652
- }
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
-
6
+ const { tabState } = require('./tabstate');
7
+ const { commands: dialogsCommands, ensureDialogHandler } = require('./dialogs');
8
+ const { commands: elementsCommands } = require('./elements');
9
+ const { commands: requestlogCommands, ensureRecentLog } = require('./requestlog');
10
+ const { commands: routesCommands, routesFor } = require('./routes');
11
+ const { commands: networkCommands } = require('./network');
12
+ const { commands: emulationCommands, EMULATE_KINDS } = require('./emulation');
13
+ const { commands: captureCommands } = require('./capture');
14
+ const { commands: inspectCommands } = require('./inspect');
15
+ const { commands: watchCommands, nextTabWanted, waitingForMe, watchNewTab } = require('./watch');
16
+ const { commands: tabsCommands, isOpening } = require('./tabs');
17
+ const { commands: modesCommands, allModesOff } = require('./modes');
18
+
19
+ // Every facility's commands. Each module is required here, so its shutdown hook is registered.
1664
20
  const commands = {
1665
- async tab(args) {
1666
- const trimmed = (args || '').trim();
1667
- if (!trimmed) {
1668
- await listTabs();
1669
- if (!state.page) out.log('\nNo tab is selected.');
1670
- out.log(hints([
1671
- ['tab <index|url-part>', 'select a tab'],
1672
- ['tab new [url]', 'open a tab of your own and select it'],
1673
- ['tab close [url-part]', 'close the selected tab, or the one whose URL contains url-part'],
1674
- ]));
1675
- return;
1676
- }
1677
- const usage = 'Usage: tab [<index> | <url-part> | new [url] | close [<url-part>]]';
1678
- const parts = trimmed.split(/\s+/);
1679
- const subcommand = parts.shift();
1680
- const all = state.browser.contexts().flatMap(c => c.pages());
1681
- // Matching on the URL, not an index, so a stale listing cannot point at someone else's tab.
1682
- // Remembers where the REPL was, so closing a tab can go back there, but
1683
- // only to a tab this REPL opened: the previous one may be someone else's.
1684
- const select = p => {
1685
- if (p !== state.page) state.previousPage = state.page;
1686
- state.page = p;
1687
- ensureDialogHandler(p);
1688
- keepDrawing(p);
1689
- };
1690
- const byUrl = part => {
1691
- const matches = all.filter(p => p.url().includes(part));
1692
- if (matches.length === 1) return matches[0];
1693
- if (!matches.length) throw new Error(`No tab URL contains "${part}"`);
1694
- throw new Error(`${matches.length} tabs match "${part}"; use a longer part:\n${matches.map(p => ` ${p.url()}`).join('\n')}`);
1695
- };
1696
- if (/^\d+$/.test(subcommand)) {
1697
- if (parts.length) throw new Error(usage);
1698
- const target = state.tabListing[Number(subcommand)];
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.`);
1702
- // A number that is not a tab in the listing may be part of a URL (a port).
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
- }
1709
- }
1710
- if (subcommand === 'new') {
1711
- select(await openTab());
1712
- const url = parts.join(' ');
1713
- if (url) {
1714
- try { await commands.goto(url); }
1715
- catch (error) { throw new Error(`${error.message}\nThe new tab stays open and selected; tab close closes it.`); }
1716
- } else {
1717
- out.log('New tab created and selected');
1718
- }
1719
- return listTabs();
1720
- }
1721
- if (subcommand === 'close') {
1722
- const target = parts.length ? byUrl(parts.join(' ')) : state.page;
1723
- if (!target || target.isClosed() || !all.includes(target)) throw new Error('Selected tab is unavailable. Run tab again.');
1724
- if (all.length <= 1) throw new Error('Refusing to close the last tab');
1725
- const url = target.url();
1726
- const wasSelected = target === state.page;
1727
- await target.close();
1728
- if (wasSelected) {
1729
- const back = state.previousPage;
1730
- state.page = back && !back.isClosed() && openedTabs.has(back) ? back : null;
1731
- state.previousPage = null;
1732
- }
1733
- out.log(`Closed ${url}${wasSelected && !state.page ? '; no tab is selected now' : ''}`);
1734
- return listTabs();
1735
- }
1736
- select(byUrl(trimmed));
1737
- return commands.info();
1738
- },
1739
-
1740
- async goto(args) {
1741
- if (!args) throw new Error('Usage: goto <url>');
1742
- let url = args;
1743
- // As a browser's address bar does: a local dev server is almost always plain http.
1744
- const local = /^(?:localhost|127(?:\.\d+){3}|\[::1\]|0\.0\.0\.0)(?:[:/?#]|$)/i.test(url);
1745
- if (!/^[a-z][a-z\d+.-]*:\/\//i.test(url) && !/^(about|data|file|javascript):/i.test(url)) url = `${local ? 'http' : 'https'}://${url}`;
1746
- await navigating(() => state.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 }));
1747
- out.log(`${state.page.url()} — ${await state.page.title()}`);
1748
- },
1749
-
1750
- async back() {
1751
- await historyStep(() => state.page.goBack({ waitUntil: 'commit', timeout: 10000 }), 'back');
1752
- out.log(`Back to: ${state.page.url()}`);
1753
- },
1754
-
1755
- async forward() {
1756
- await historyStep(() => state.page.goForward({ waitUntil: 'commit', timeout: 10000 }), 'forward');
1757
- out.log(`Forward to: ${state.page.url()}`);
1758
- },
1759
-
1760
- async reload() {
1761
- await navigating(() => state.page.reload({ waitUntil: 'domcontentloaded', timeout: 15000 }));
1762
- out.log(`Reloaded: ${state.page.url()}`);
1763
- },
1764
-
1765
- async info() {
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
- }
1772
- out.log(` Title: ${await state.page.title()}`);
1773
- out.log(` Viewport: ${await viewportText()}`);
1774
- },
1775
-
1776
- async click(args) {
1777
- const { selector, shown, options } = clickArgs(args, 'click <selector> [left|right|middle] [--modifiers=<key>[,<key>]]');
1778
- const which = await pointerAction(selector, (on, sel, o) => (sel ? on.click(sel, o) : on.click(o)), options);
1779
- out.log(`Clicked: ${shown}${which}`);
1780
- },
1781
-
1782
- async dblclick(args) {
1783
- const { selector, shown, options } = clickArgs(args, 'dblclick <selector> [left|right|middle] [--modifiers=<key>[,<key>]]');
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}`);
1786
- },
1787
-
1788
- async hover(args) {
1789
- const selector = soleSelector(args, 'hover <selector>');
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}`);
1820
- },
1821
-
1822
- async fill(line) {
1823
- const { args, submit } = submitFlag(line);
1824
- const { selector, value, shown } = selectorAndValue(args, 'fill <selector> <value> [--submit]', 'fill #name Ada Lovelace');
1825
- await onElement(selector, () => state.page.fill(selector, value, { timeout: 5000 }));
1826
- if (submit) await state.page.press(selector, 'Enter', { timeout: 5000 });
1827
- out.log(`Filled${submit ? ' and submitted' : ''}: ${shown}`);
1828
- },
1829
-
1830
- async type(line) {
1831
- const { args, submit } = submitFlag(line);
1832
- // One word, or one quoted, is typed into the focused element, as playwright-cli's type does.
1833
- const sole = splitSelector(args);
1834
- if (sole && sole.rest === undefined) {
1835
- // Named, since it may not be the element meant; one that takes no text is refused.
1836
- const focused = await state.page.evaluate(() => {
1837
- const el = document.activeElement;
1838
- if (!el || el === document.body || el === document.documentElement) return null;
1839
- const editable = el.isContentEditable || el.matches('textarea, input:not([type=checkbox],[type=radio],[type=button],[type=submit],[type=reset],[type=image],[type=file],[type=range],[type=color],[type=hidden])');
1840
- const id = el.id ? `#${el.id}` : el.name ? `[name=${el.name}]` : el.placeholder ? `[placeholder="${el.placeholder}"]` : '';
1841
- return { editable, name: `${el.tagName.toLowerCase()}${id}` };
1842
- });
1843
- const usage = 'type <selector> <text> types into an element, e.g. type #search garden hose';
1844
- if (!focused) throw new Error(`Nothing on the page has focus: ${usage}`);
1845
- if (!focused.editable) throw new Error(`The focused element, ${focused.name}, takes no text: ${usage}`);
1846
- await state.page.keyboard.type(sole.word);
1847
- if (submit) await state.page.keyboard.press('Enter');
1848
- out.log(`Typed into the focused ${focused.name}${submit ? ', and submitted' : ''}`);
1849
- return;
1850
- }
1851
- const { selector, value, shown } = selectorAndValue(args, 'type [<selector>] <text> [--submit]', 'type #search garden hose');
1852
- // Typed after what the field already holds, as someone clicking into it
1853
- // at the end would; focusing it alone leaves the caret at the start.
1854
- await onElement(selector, () => state.page.locator(selector).first().evaluate(el => {
1855
- el.focus();
1856
- if (typeof el.value === 'string' && typeof el.setSelectionRange === 'function') {
1857
- try { el.setSelectionRange(el.value.length, el.value.length); } catch {}
1858
- } else if (el.isContentEditable) {
1859
- const range = document.createRange();
1860
- range.selectNodeContents(el);
1861
- range.collapse(false);
1862
- getSelection().removeAllRanges();
1863
- getSelection().addRange(range);
1864
- }
1865
- }, null, { timeout: 5000 }));
1866
- await state.page.keyboard.type(value);
1867
- if (submit) await state.page.keyboard.press('Enter');
1868
- out.log(`Typed into${submit ? ' and submitted' : ''}: ${shown}`);
1869
- },
1870
-
1871
- async press(args) {
1872
- const parsed = splitSelector(args);
1873
- if (!parsed) throw new Error('Usage: press <key> | press <selector> <key>, e.g. press Enter or press #name Enter');
1874
- if (parsed.rest === undefined) {
1875
- await state.page.keyboard.press(keyName(parsed.word));
1876
- out.log(`Pressed: ${keyName(parsed.word)}`);
1877
- return;
1878
- }
1879
- const key = keyName(unquote(parsed.rest));
1880
- await onElement(parsed.selector, () => state.page.press(parsed.selector, key, { timeout: 5000 }));
1881
- out.log(`Pressed ${key} on ${parsed.word}`);
1882
- },
1883
-
1884
- async select(args) {
1885
- const { selector, value, shown } = selectorAndValue(args, 'select <selector> <value>', 'select #country Canada');
1886
- await onElement(selector, () => state.page.selectOption(selector, value, { timeout: 5000 }));
1887
- out.log(`Selected "${value}" in ${shown}`);
1888
- },
1889
-
1890
- async check(args) {
1891
- const selector = soleSelector(args, 'check <selector>');
1892
- await onElement(selector, () => state.page.check(selector, { timeout: 5000 }));
1893
- out.log(`Checked: ${args.trim()}`);
1894
- },
1895
-
1896
- async uncheck(args) {
1897
- const selector = soleSelector(args, 'uncheck <selector>');
1898
- await onElement(selector, () => state.page.uncheck(selector, { timeout: 5000 }));
1899
- out.log(`Unchecked: ${args.trim()}`);
1900
- },
1901
-
1902
- // The files are read here and handed to the page as their contents, not
1903
- // their paths: the browser may run on another machine, or in another
1904
- // container, where the paths mean nothing.
1905
- async upload(args) {
1906
- const parsed = splitSelector(args);
1907
- // playwright-cli's upload <file> answers a file picker a click opened. Here
1908
- // the input is named instead: catching pickers would catch the person's too.
1909
- const usage = 'Usage: upload <selector> <file>..., naming the file input (or the button that opens it) first, e.g. upload e12 ./doc.pdf; quote a path with spaces';
1910
- if (!parsed || parsed.rest === undefined) throw new Error(usage);
1911
- // A path, not a selector; // starts an XPath.
1912
- if (/^(?:\.{1,2}\/|\/(?!\/)|~)/.test(parsed.word) || (!REF.test(parsed.word) && fs.statSync(path.resolve(parsed.word), { throwIfNoEntry: false })?.isFile())) throw new Error(`${parsed.word} is a file, not the input: ${usage.slice(7)}`);
1913
- const files = [...parsed.rest.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g)].map(m => path.resolve(m[1] !== undefined ? m[1].replace(/\\(.)/g, '$1') : m[2] ?? m[3]));
1914
- const payloads = files.map(file => {
1915
- let buffer;
1916
- try { buffer = fs.readFileSync(file); } catch (error) { throw new Error(`Cannot read ${file}: ${error.code === 'ENOENT' ? 'no such file' : error.code === 'EISDIR' ? 'it is a folder' : error.message}`); }
1917
- return { name: path.basename(file), mimeType: mimeType(file), buffer };
1918
- });
1919
- const { selector } = parsed;
1920
- await onElement(selector, async () => {
1921
- try {
1922
- await state.page.setInputFiles(selector, payloads, { timeout: 5000 });
1923
- } catch (error) {
1924
- if (!/not an HTMLInputElement/.test(error.message)) throw error;
1925
- // A button that opens the file picker itself: click it, and answer the picker.
1926
- const [chooser] = await Promise.all([state.page.waitForEvent('filechooser', { timeout: 5000 }), state.page.click(selector, { timeout: 5000 })]);
1927
- await chooser.setFiles(payloads);
1928
- }
1929
- });
1930
- out.log(`Chose ${files.length === 1 ? 'a file' : `${files.length} files`} in ${parsed.word}: ${files.join(', ')}`);
1931
- },
1932
-
1933
- async text(args, all) {
1934
- const selector = soleSelector(args, 'text <selector>');
1935
- printOutput(await onElement(selector, () => state.page.innerText(selector, { timeout: 5000 })), all);
1936
- },
1937
-
1938
- async html(args, all) {
1939
- const selector = soleSelector(args, 'html <selector>');
1940
- printOutput(await onElement(selector, () => state.page.$eval(selector, el => el.outerHTML)), all);
1941
- },
1942
-
1943
- async attrs(args, all) {
1944
- const selector = soleSelector(args, 'attrs <selector>');
1945
- const result = await onElement(selector, () => state.page.$eval(selector, el => {
1946
- const out = {};
1947
- for (const attr of el.attributes) out[attr.name] = attr.value;
1948
- return out;
1949
- }));
1950
- printOutput(result, all);
1951
- },
1952
-
1953
- // The page's own event listeners on an element, which only DevTools can
1954
- // list: the element is handed to a CDP session through the page, so any
1955
- // Playwright selector works, and the session asks for its listeners.
1956
- async listeners(args, all) {
1957
- const text = (args || '').trim();
1958
- const usage = 'listeners <selector> | listeners document | listeners window';
1959
- if (!text) throw new Error(`Usage: ${usage}`);
1960
- const which = text === 'document' || text === 'window' ? text : null;
1961
- const selector = which ? null : soleSelector(args, usage);
1962
- if (selector) await onElement(selector, () => state.page.locator(selector).first().evaluate(el => { window.__pwReplListenersOf = el; }, null, { timeout: 5000 }));
1963
- const cdp = await state.page.context().newCDPSession(state.page);
1964
- try {
1965
- // Chrome includes each handler's source only for an object in a named group.
1966
- const { result } = await cdp.send('Runtime.evaluate', { expression: which || 'window.__pwReplListenersOf', objectGroup: 'pw-repl-listeners' });
1967
- if (!result.objectId) throw new Error(`No element matches ${text}`);
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; }
1975
- const lines = listeners.map(l => {
1976
- const how = [l.useCapture && 'capture', l.once && 'once', l.passive && 'passive'].filter(Boolean).join(', ');
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)}…`;
1984
- return `${l.type}${how ? ` (${how})` : ''}: ${handler} (line ${l.lineNumber + 1})`;
1985
- });
1986
- printOutput(lines.join('\n'), all);
1987
- if (note) out.log(note.trim());
1988
- } finally {
1989
- await cdp.send('Runtime.evaluate', { expression: 'delete window.__pwReplListenersOf' }).catch(() => {});
1990
- await cdp.send('Runtime.releaseObjectGroup', { objectGroup: 'pw-repl-listeners' }).catch(() => {});
1991
- await cdp.detach().catch(() => {});
1992
- }
1993
- },
1994
-
1995
- async count(args) {
1996
- const selector = soleSelector(args, 'count <selector>');
1997
- const els = await state.page.$$(selector);
1998
- out.log(`${els.length} element(s)`);
1999
- },
2000
-
2001
- async visible(args) {
2002
- const selector = soleSelector(args, 'visible <selector>');
2003
- const el = await state.page.$(selector);
2004
- if (!el) { out.log('Not found'); return; }
2005
- out.log(await el.isVisible() ? 'visible' : 'hidden');
2006
- },
2007
-
2008
- async links(_args, all) {
2009
- const links = await state.page.$$eval('a[href]', els =>
2010
- els.map(e => ({ text: e.textContent.trim(), href: e.href }))
2011
- .filter(l => l.text)
2012
- );
2013
- if (!links.length) { out.log('No links found'); return; }
2014
- printOutput(links, all);
2015
- },
2016
-
2017
- async inputs(_args, all) {
2018
- const inputs = await state.page.$$eval('input, select, textarea', els =>
2019
- els.map(e => ({
2020
- tag: e.tagName.toLowerCase(),
2021
- type: e.type || '',
2022
- name: e.name || '',
2023
- id: e.id || '',
2024
- placeholder: e.placeholder || '',
2025
- disabled: e.disabled,
2026
- autocomplete: e.autocomplete || ''
2027
- }))
2028
- );
2029
- if (!inputs.length) { out.log('No inputs found'); return; }
2030
- printOutput(inputs, all);
2031
- },
2032
-
2033
- async screenshot(args) {
2034
- const usage = 'Usage: screenshot [<ref>] [--full] [--delay|-d <seconds>] [name | --filename=<file>]\nNames may contain letters, digits, underscores, and hyphens.';
2035
- // The file first: a path may be quoted, with spaces in it.
2036
- let file = null;
2037
- const rest = (args || '').replace(/(?:^|\s)--filename(?:=|\s+)("(?:[^"\\]|\\.)*"|'[^']*'|\S+)/, (_, value) => { file = unquote(value); return ' '; });
2038
- const tokens = rest.trim().split(/\s+/).filter(Boolean);
2039
- let full = false;
2040
- let delay = 0;
2041
- let ref = null;
2042
- const names = [];
2043
- for (let i = 0; i < tokens.length; i++) {
2044
- const token = tokens[i];
2045
- // --full-page and --filename are playwright-cli's.
2046
- if (token === '--full' || token === '--full-page') { full = true; continue; }
2047
- if (token === '--filename' || token.startsWith('--filename=')) throw new Error(usage);
2048
- if (!ref && !names.length && REF.test(token)) { ref = token; continue; }
2049
- if (token === '--delay' || token === '-d') {
2050
- const value = tokens[++i];
2051
- // Capped low on purpose: a timed shot is human-in-the-loop, and a typo
2052
- // like `-d 300` would otherwise park the serialized command queue.
2053
- if (!value || !/^\d+$/.test(value) || Number(value) > SCREENSHOT_DELAY_MAX) throw new Error(`Usage: screenshot --delay <seconds> (maximum ${SCREENSHOT_DELAY_MAX})`);
2054
- delay = Number(value);
2055
- continue;
2056
- }
2057
- names.push(token);
2058
- }
2059
- if (names.length > 1 || (names[0] && !/^[a-zA-Z0-9_-]+$/.test(names[0])) || (file && names.length) || (ref && full)) throw new Error(usage);
2060
- const name = names[0] || null;
2061
- // Counted down out loud so a watcher can time a hover or menu state, and so
2062
- // the wait is distinguishable from a hung command in a tmux capture.
2063
- for (let remaining = delay; remaining > 0; remaining--) {
2064
- out.log(`${remaining}...`);
2065
- await state.page.waitForTimeout(1000);
2066
- }
2067
- // Named after the countdown so a default filename timestamps the capture.
2068
- // A file given is relative to the REPL's folder; pw-repl send makes it the sender's.
2069
- const filepath = file ? path.resolve(file) : nextScreenshotPath(name);
2070
- const type = /\.jpe?g$/i.test(filepath) ? 'jpeg' : 'png';
2071
- // Checked before the tab is brought to the front, which can move someone's view.
2072
- if (fs.existsSync(filepath)) throw new Error(`Screenshot already exists: ${filepath}`);
2073
- if (!fs.existsSync(path.dirname(filepath))) throw new Error(`No folder ${path.dirname(filepath)} to save ${path.basename(filepath)} in`);
2074
- // Chrome does not draw a tab that is not in front, and tabs open in the
2075
- // background, so the tab is brought to the front for the shot.
2076
- await state.page.bringToFront();
2077
- let image;
2078
- const device = emulations.get(state.page)?.device;
2079
- try {
2080
- if (device) {
2081
- image = await deviceShot(device, { ref, full, type });
2082
- } else if (ref) {
2083
- const selector = toSelector(ref);
2084
- image = await onElement(selector, () => state.page.locator(selector).screenshot({ type, timeout: 5000 }));
2085
- } else {
2086
- image = await state.page.screenshot({ fullPage: full, type });
2087
- }
2088
- } finally {
2089
- // Playwright's screenshot resets the screen size and pixel ratio it
2090
- // finds; an emulated phone's are set again.
2091
- if (device) {
2092
- const session = await keptSession(state.page);
2093
- await session.send('Emulation.clearDeviceMetricsOverride');
2094
- await deviceMetrics(session, device);
2095
- }
2096
- }
2097
- try {
2098
- fs.writeFileSync(filepath, image, { flag: 'wx', mode: 0o600 });
2099
- } catch (error) {
2100
- if (error.code === 'EEXIST') throw new Error(`Screenshot already exists: ${filepath}`);
2101
- throw error;
2102
- }
2103
- out.log(`Saved: ${filepath}`);
2104
- },
2105
-
2106
- async snapshot(args, all) {
2107
- let rest = (args || '').trim();
2108
- const full = /^--full(?:\s|$)/.test(rest);
2109
- if (full) rest = rest.slice(6).trim();
2110
- let needle = null;
2111
- const grep = /^--(grep|regex)(?:\s|$)/.exec(rest);
2112
- if (grep) {
2113
- needle = rest.slice(grep[0].length).trim().replace(/^(["'])(.*)\1$/, '$2').replace(/\\"/g, '"');
2114
- if (!needle) throw new Error(`Usage: snapshot [--full] --${grep[1]} <${grep[1] === 'grep' ? 'text' : 'pattern'}>`);
2115
- if (grep[1] === 'regex') needle = regexOf(needle);
2116
- rest = '';
2117
- }
2118
- // Playwright's labels are e5, or frame-prefixed like f1e5 in newer versions.
2119
- const selector = toSelector(rest);
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
- }
2135
- const text = full ? raw : compactSnapshot(raw);
2136
- if (needle !== null) {
2137
- const hits = grepSnapshot(text, needle);
2138
- if (!hits.length) { out.log(`No snapshot lines match ${needle}`); return; }
2139
- printOutput(hits.join('\n'), all);
2140
- return;
2141
- }
2142
- const lines = text.split('\n').length;
2143
- printOutput(lines > SNAPSHOT_HINT_LINES ? `${text}\n[${lines} lines; snapshot --grep <text> or snapshot <eN> shows less]` : text, all);
2144
- },
2145
-
2146
- async viewport(args) {
2147
- if (!args) { out.log(await viewportText()); return; }
2148
- const [w, h] = args.split('x').map(Number);
2149
- if (!w || !h) throw new Error('Usage: viewport <width>x<height>');
2150
- // Both set the screen size; whichever came last would win without saying so.
2151
- if (emulations.get(state.page)?.device) throw new Error('emulate mobile sets the size of the selected tab; emulate mobile off first');
2152
- await state.page.setViewportSize({ width: w, height: h });
2153
- out.log(`Viewport set to ${w}x${h}`);
2154
- },
2155
-
2156
- // Per tab, like network: the settings live in the tab's kept CDP session.
2157
- async emulate(args) {
2158
- const words = (args || '').trim().split(/\s+/).filter(Boolean);
2159
- if (!words.length) return showEmulation();
2160
- const usage = 'Usage: emulate [mobile [device] | dark | light | locale <tag> | timezone <zone>], emulate <what> off, emulate off';
2161
- const [what, ...rest] = words;
2162
- const value = rest.join(' ');
2163
- const current = emulations.get(state.page) || {};
2164
- if (what === 'off' && !rest.length) {
2165
- const was = emulatedKinds(current);
2166
- await setEmulation(state.page, {});
2167
- out.log(was.length ? `Stopped emulating in the selected tab: ${was.join(', ')}` : 'Nothing was emulated in the selected tab.');
2168
- return;
2169
- }
2170
- if (!EMULATE_KINDS.includes(what)) throw new Error(usage);
2171
- const key = { mobile: 'device', dark: 'scheme', light: 'scheme', locale: 'locale', timezone: 'timezone' }[what];
2172
- if (value === 'off') {
2173
- await setEmulation(state.page, { ...current, [key]: undefined });
2174
- out.log(`Stopped emulating ${key === 'scheme' ? 'the color scheme' : what} in the selected tab`);
2175
- // As when turned on: the page keeps what it read at load until it loads again.
2176
- if ((what === 'mobile' || what === 'locale') && state.page.url() !== 'about:blank') out.log('The page sees its own user agent, touch and languages again from its next load: reload to see all of it.');
2177
- return;
2178
- }
2179
- let next;
2180
- if (what === 'mobile') {
2181
- next = deviceNamed(value || DEFAULT_DEVICE);
2182
- } else if (what === 'dark' || what === 'light') {
2183
- if (value) throw new Error(usage);
2184
- next = what;
2185
- } else if (!value || rest.length > 1) {
2186
- throw new Error(`Usage: emulate ${what} ${what === 'locale' ? '<tag>, e.g. fr-FR' : '<zone>, e.g. Asia/Tokyo'}`);
2187
- } else if (what === 'locale') {
2188
- try { next = Intl.getCanonicalLocales(value)[0]; } catch { throw new Error(`Not a locale: ${value}; e.g. fr-FR, de, pt-BR`); }
2189
- } else {
2190
- // Checked, but kept as given: Node's names can be older ones (Asia/Calcutta for Asia/Kolkata).
2191
- try { new Intl.DateTimeFormat('en-US', { timeZone: value }); } catch { throw new Error(`Not a timezone: ${value}; e.g. Asia/Tokyo, America/New_York, UTC`); }
2192
- next = value;
2193
- }
2194
- await setEmulation(state.page, { ...current, [key]: next });
2195
- const shown = what === 'mobile' ? `mobile (${deviceText(next)})` : what === 'dark' || what === 'light' ? `${what} mode` : `${what} ${next}`;
2196
- out.log(`Emulating ${shown} in the selected tab`);
2197
- // The page reads these when it loads; the rest applies at once. A blank tab has nothing to reload.
2198
- if ((what === 'mobile' || what === 'locale') && state.page.url() !== 'about:blank') out.log('The page sees its user agent, touch and languages from its next load: reload to see all of it.');
2199
- },
2200
-
2201
- async wait(args) {
2202
- const usage = 'Usage: wait <selector> | wait text <text> | wait request <url-part|glob> | wait load, with optional [seconds]; --gone for a selector or text';
2203
- let tokens = (args || '').trim().split(/\s+/).filter(Boolean);
2204
- const gone = tokens.includes('--gone');
2205
- tokens = tokens.filter(t => t !== '--gone');
2206
- let seconds = WAIT_DEFAULT;
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());
2210
- if (!tokens.length || seconds < 1 || seconds > WAIT_MAX) throw new Error(`${usage} (1-${WAIT_MAX})`);
2211
- const timeout = seconds * 1000;
2212
- // Said plainly, not as Playwright's call log.
2213
- const plainly = message => error => { throw /Timeout \d+ms exceeded/.test(error.message) ? new Error(message) : error; };
2214
- const kind = tokens[0];
2215
- if (kind === 'load' && tokens.length === 1) {
2216
- if (gone) throw new Error(usage);
2217
- return waitForLoad(timeout);
2218
- }
2219
- if ((kind === 'text' || kind === 'request') && tokens.length > 1) {
2220
- const what = tokens.slice(1).join(' ').replace(/^(["'])(.*)\1$/, '$2').replace(/\\"/g, '"');
2221
- if (kind === 'request') {
2222
- if (gone) throw new Error(usage);
2223
- return waitForRequest(what, timeout);
2224
- }
2225
- if (gone) return waitGone(state.page.getByText(what), what, timeout);
2226
- await state.page.getByText(what).first().waitFor({ state: 'visible', timeout }).catch(plainly(`No visible text matches ${what} within ${seconds}s`));
2227
- out.log(`Visible: ${what}`);
2228
- return;
2229
- }
2230
- const selector = toSelector(unquote(tokens.join(' ')));
2231
- if (gone) return waitGone(state.page.locator(selector), tokens.join(' '), timeout);
2232
- await state.page.waitForSelector(selector, { state: 'visible', timeout }).catch(plainly(`No visible element matches ${tokens.join(' ')} within ${seconds}s`));
2233
- out.log(`Visible: ${tokens.join(' ')}`);
2234
- },
2235
-
2236
- async sleep(args) {
2237
- if (!args || !/^\d+$/.test(args) || Number(args) > 3600000) throw new Error('Usage: sleep <ms> (maximum 3600000)');
2238
- await state.page.waitForTimeout(Number(args));
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`);
2241
- },
2242
-
2243
- // A stopped upstream does not reach the browser as a failure: the dev proxy
2244
- // holds the request open instead of refusing it. Cutting the connection at
2245
- // the browser is what a visitor's wifi or VPN dropping looks like to the page.
2246
- async network(args) {
2247
- const words = (args || '').trim().toLowerCase().split(/\s+/).filter(Boolean);
2248
- const setting = networkChanged.get(state.page);
2249
- if (!words.length) {
2250
- out.log(`The selected tab's network is ${setting ? networkText(setting) : 'on'}.`);
2251
- if (setting) out.log(hints([['network on', 'restore it']]));
2252
- else out.log(hints([['network off', 'cut it, like dropped wifi'], ['network slow [<ms> [<kbps>]]', `slow it (default ${networkText(SLOW_DEFAULT).slice(6, -1)})`]]));
2253
- return;
2254
- }
2255
- const [how, ...rest] = words;
2256
- if ((how === 'on' || how === 'off') && !rest.length) {
2257
- await setNetwork(state.page, how === 'on' ? null : { offline: true });
2258
- out.log(`The selected tab's network is ${how}`);
2259
- return;
2260
- }
2261
- const usage = `Usage: network [on | off | slow [<latency-ms> [<kbps>]]] (latency up to ${SLOW_LATENCY_MAX}ms)`;
2262
- if (how !== 'slow' || rest.length > 2 || !rest.every(w => /^\d+$/.test(w))) throw new Error(usage);
2263
- const [latency, kbps] = rest.map(Number);
2264
- if (latency > SLOW_LATENCY_MAX || kbps === 0) throw new Error(usage);
2265
- const slow = rest.length ? { latency, down: kbps || SLOW_DEFAULT.down, up: kbps || SLOW_DEFAULT.up } : SLOW_DEFAULT;
2266
- await setNetwork(state.page, slow);
2267
- out.log(`The selected tab's network is ${networkText(slow)}`);
2268
- },
2269
-
2270
- // Fulfilled inside the browser, so the page's own code handles the fake
2271
- // exactly as a real response and the request never reaches the network.
2272
- async route(args) {
2273
- const trimmed = (args || '').trim();
2274
- if (!trimmed) return listRoutes();
2275
- // A glob may be quoted, as it is in a shell.
2276
- const off = /^off(?:\s+(\S+))?$/.exec(trimmed);
2277
- if (off) return removeRoutes(off[1] && unquote(off[1]));
2278
- const usage = 'Usage: route <url-glob> <status> [json-body] | route <url-glob> patch <json> | route <url-glob> delay <seconds> | route <url-glob> abort | route off <url-glob>|--all';
2279
- const parsed = splitSelector(trimmed);
2280
- const match = /^(\S+)(?:\s+([\s\S]+))?$/.exec(parsed?.rest || '');
2281
- if (!match) throw new Error(usage);
2282
- const glob = parsed.word;
2283
- const [, how, rest] = match;
2284
- const route = routeKind(how, rest, usage);
2285
- const routes = routesFor(state.page);
2286
- const previous = routes.get(glob);
2287
- if (previous) await state.page.unroute(glob, previous.handler);
2288
- const handler = async r => {
2289
- const req = r.request();
2290
- const tag = () => { const id = requestEntries.get(req)?.id; return `${id ? `#${id} ` : ''}${req.method()} ${req.url()}`; };
2291
- try {
2292
- await route.handle(r, req, tag);
2293
- } catch (error) {
2294
- // Aborted, never continued: a request meant to be changed must not reach the page as it was.
2295
- await r.abort().catch(() => {});
2296
- out.notice(`Route failed: ${tag()} — ${error.message}; the request was aborted`);
2297
- }
2298
- };
2299
- await state.page.route(glob, handler);
2300
- routes.set(glob, { text: route.text, handler, owner: state.client });
2301
- out.log(`${previous ? 'Replaced' : 'Routed'}: ${glob} -> ${route.text}`);
2302
- },
2303
-
2304
- async requests(args) {
2305
- // --regex <pattern> takes the rest of the line.
2306
- let regex = null;
2307
- let regexArg = null;
2308
- const text = (args || '').replace(/(?:^|\s)--regex(?:\s+([\s\S]*))?$/, (_, pattern) => {
2309
- if (!pattern?.trim()) throw new Error('Usage: requests [--all] [n] --regex <pattern>');
2310
- regexArg = pattern.trim();
2311
- regex = regexOf(patternText(regexArg));
2312
- return '';
2313
- });
2314
- const tokens = text.trim().split(/\s+/).filter(Boolean);
2315
- const everything = tokens.includes('--all');
2316
- if (everything) tokens.splice(tokens.indexOf('--all'), 1);
2317
- let count = RECENT_DEFAULT;
2318
- if (tokens.length && /^\d+$/.test(tokens[0])) count = Number(tokens.shift());
2319
- if (count < 1 || count > RECENT_MAX || tokens.length > (regex ? 0 : 1)) throw new Error(`Usage: requests [--all] [n] [url-filter | --regex <pattern>] (n from 1 to ${RECENT_MAX})`);
2320
- const filter = regex ? String(regex) : tokens[0];
2321
- const wanted = regex ? url => regex.test(url) : url => !filter || url.includes(filter);
2322
- const log = recentLogs.get(state.page) || [];
2323
- const shown = everything ? log : log.filter(e => !RECENT_HIDDEN_TYPES.has(e.type) && !e.url.startsWith('chrome-extension://'));
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);
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();
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;
2339
- const ms = e.ms === null ? '-' : `${e.ms}ms`;
2340
- out.log(`#${e.id} ${clock(e.t)} ${e.method} ${e.status} ${ms} ${e.type} ${e.url}`);
2341
- }
2342
- for (const e of loads) delete e.marked;
2343
- // The numbers skip what is hidden; say so, or they look like requests went missing.
2344
- const first = matches[0].id;
2345
- const last = matches[matches.length - 1].id;
2346
- // With a filter, the numbers skip what does not match too, so the count would mislead.
2347
- const hidden = everything || filter ? 0 : log.filter(e => e.id > first && e.id < last && !shown.includes(e)).length;
2348
- if (hidden) out.log(`(${hidden} hidden between these: images, fonts, stylesheets, media and extension requests; requests --all shows them)`);
2349
- },
2350
-
2351
- async body(args, all) {
2352
- const arg = (args || '').trim();
2353
- if (!arg) throw new Error('Usage: body <#> | body <url-part> (the number from requests, or the latest request whose URL contains url-part)');
2354
- const log = recentLogs.get(state.page) || [];
2355
- const match = /^#?(\d+)$/.exec(arg);
2356
- // The latest that has finished: one still pending has no body yet.
2357
- const entry = match ? log.find(e => e.id === Number(match[1])) : log.filter(e => e.url.includes(arg) && e.status !== 'pending').pop();
2358
- if (!entry) throw new Error(match ? `No request #${match[1]} on the selected tab; requests lists them` : `No finished request on the selected tab has a URL containing ${arg}`);
2359
- const id = entry.id;
2360
- // A fake is marked answered before the browser hands over its response.
2361
- for (let waited = 0; !entry.response && /(?:faked|patched)$/.test(entry.status) && waited < 2000; waited += 50) {
2362
- await new Promise(resolve => setTimeout(resolve, 50));
2363
- }
2364
- if (!entry.response) throw new Error(`#${id} has no response${entry.status === 'pending' ? ' yet' : ` (${entry.status})`}`);
2365
- let buffer = entry.kept;
2366
- if (!buffer) {
2367
- // Not withTimeout: its "timed out" error would disconnect the REPL, and
2368
- // reading a body cannot have changed anything.
2369
- let timer;
2370
- const late = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`did not arrive within ${BODY_TIMEOUT / 1000}s`)), BODY_TIMEOUT); });
2371
- try { buffer = await Promise.race([entry.response.body(), late]); }
2372
- // Playwright's reason ends in advice for its own API ("Read response.body() before..."), so only its first line is kept.
2373
- catch (error) { throw new Error(`The body of #${id} is not available: ${String(error.message).split('\n')[0]} (the browser drops bodies, e.g. after the tab navigates; a watched tab keeps them as they arrive)`); }
2374
- finally { clearTimeout(timer); }
2375
- }
2376
- const type = entry.response.headers()['content-type'] || '';
2377
- out.log(`#${id} ${entry.method} ${entry.status} ${entry.url} (${type || 'no content type'}, ${buffer.length} bytes)`);
2378
- if (!buffer.length) 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; }
2381
- let text = buffer.toString('utf8');
2382
- if (/json/.test(type)) { try { text = JSON.stringify(JSON.parse(text), null, 2); } catch {} }
2383
- printOutput(text, all);
2384
- },
2385
-
2386
- async console(args, all) {
2387
- const tokens = (args || '').trim().split(/\s+/).filter(Boolean);
2388
- let count = RECENT_DEFAULT;
2389
- if (tokens.length && /^\d+$/.test(tokens[0])) count = Number(tokens.shift());
2390
- if (count < 1 || count > RECENT_MAX || tokens.length > 1) throw new Error(`Usage: console [n] [level | filter] (n from 1 to ${RECENT_MAX})`);
2391
- const filter = tokens[0];
2392
- const level = CONSOLE_LEVELS.indexOf(filter);
2393
- const shown = level === -1
2394
- ? e => !filter || e.type.includes(filter) || e.text.includes(filter)
2395
- : e => CONSOLE_LEVELS.indexOf(consoleLevel(e.type)) <= level;
2396
- const logs = consoleLogs.get(state.page) || [];
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);
2401
- if (!matches.length) { out.log(filter ? `No console messages matching ${filter}` : 'No console messages on the selected tab'); return; }
2402
- // Where the page loaded again, so a message from an earlier load does not read as the current one's.
2403
- // From the load the first message shown came from, if it is still kept.
2404
- let first = logs.indexOf(matches[0]);
2405
- while (first > 0 && logs[first].type !== 'load') first -= 1;
2406
- const lines = logs.filter((e, i) => i >= first && (e.type === 'load' || matches.includes(e)));
2407
- printOutput(lines.map(e => (e.type === 'load' ? `${clock(e.t)} --- the page loaded ${e.text}` : `${clock(e.t)} [${e.type}] ${e.text}`)).join('\n'), all);
2408
- },
2409
-
2410
- async watch(args, all) {
2411
- const arg = (args || '').trim();
2412
- const watch = watches.get(state.page);
2413
- const [sub, ...flags] = arg.split(/\s+/);
2414
- const next = flags.indexOf('--next-tab');
2415
- if (sub === 'on' && next !== -1) {
2416
- const part = flags[next + 1] && !flags[next + 1].startsWith('--') ? flags[next + 1] : null;
2417
- const rest = flags.filter((f, i) => i !== next && !(part && i === next + 1));
2418
- if (!rest.every(f => f === '--changes' || f === '--live')) throw new Error('Usage: watch on [--changes] [--live] --next-tab [url-part]');
2419
- nextTab = { part, changes: rest.includes('--changes'), live: rest.includes('--live'), client: state.client };
2420
- out.log(`${waitingText()}: it is watched from its first page, and selected. watch off stops waiting.`);
2421
- return;
2422
- }
2423
- if (sub !== 'on' && arg !== 'off' && waitingForMe() && (!state.page || state.page.isClosed())) {
2424
- out.log(`${waitingText()} (watch on --next-tab); it is selected once it opens, and watch then shows its steps.`);
2425
- return;
2426
- }
2427
- if (sub === 'on' && flags.every(f => f === '--changes' || f === '--live')) {
2428
- if (!state.page || state.page.isClosed()) throw new Error('No tab is selected; select one with tab <index|url-part>, or open one with tab new');
2429
- const changes = flags.includes('--changes');
2430
- const live = flags.includes('--live');
2431
- await startWatching(state.page, changes, live);
2432
- watches.get(state.page).owner = state.client;
2433
- out.log(`Watching the selected tab: clicks, typing, form changes, submits and navigations${changes ? ', and what each changed on screen' : ''} (typed values are not recorded)`);
2434
- out.log(live ? 'Each step prints here once it settles; watch shows them again.' : 'watch shows what it has recorded; watch on --live also prints each step here as it happens.');
2435
- return;
2436
- }
2437
- if (arg === 'off') {
2438
- if (nextTab) { nextTab = null; out.log('Stopped waiting to watch a new tab'); }
2439
- if (!state.page || state.page.isClosed()) return;
2440
- if (!watch?.on) { out.log('Not watching the selected tab.'); return; }
2441
- stopWatching(state.page, watch);
2442
- const steps = watch.events.length;
2443
- out.log(`Stopped watching the selected tab${steps ? `; ${steps} step${steps === 1 ? '' : 's'} recorded (watch shows them)` : ''}`);
2444
- return;
2445
- }
2446
- if (!arg) return showWatch(watch, all);
2447
- const usage = `Usage: watch on [--changes] [--live] | watch off | watch [n] | watch new (n from 1 to ${WATCH_MAX})`;
2448
- const onlyNew = arg === 'new';
2449
- if (arg && !onlyNew && !/^\d+$/.test(arg)) throw new Error(usage);
2450
- const count = onlyNew ? RECENT_DEFAULT : Number(arg);
2451
- if (count < 1 || count > WATCH_MAX) throw new Error(usage);
2452
- if (!watch || !watch.events.length) { out.log(watch?.on ? 'Watching; nothing has happened yet' : 'Not watching the selected tab; watch on starts it'); return; }
2453
- const causedBy = requestsByStep(watch);
2454
- const lines = [];
2455
- const printStep = (e, caused, note = '') => {
2456
- lines.push(...stepLines(e, caused, note, !(onlyNew && e.changesShown)));
2457
- if (onlyNew && e.changes) e.changesShown = true;
2458
- };
2459
- let events;
2460
- if (onlyNew) {
2461
- // Requests and changes can arrive after their step was read; they are shown under it again.
2462
- const lastShown = watch.events.find(e => e.seq === watch.shownSeq);
2463
- const late = lastShown ? causedBy(lastShown).filter(r => r.id > watch.shownRequestId) : [];
2464
- const lateChanges = lastShown?.changes && !lastShown.changesShown;
2465
- if (late.length || lateChanges) printStep(lastShown, late, ' (continued)');
2466
- events = watch.events.filter(e => e.seq > watch.shownSeq);
2467
- if (!events.length && !late.length && !lateChanges) lines.push('No new steps');
2468
- } else {
2469
- events = watch.events.slice(-count);
2470
- }
2471
- for (const e of events) printStep(e, causedBy(e));
2472
- // Only watch new keeps track of what has been read.
2473
- if (onlyNew) {
2474
- const last = watch.events[watch.events.length - 1];
2475
- watch.shownSeq = last.seq;
2476
- watch.shownRequestId = Math.max(watch.shownRequestId, ...causedBy(last).map(r => r.id));
2477
- }
2478
- if (!watch.on) lines.push('[watch is off]');
2479
- printOutput(lines.join('\n'), all);
2480
- },
2481
-
2482
- async eval(args, all) {
2483
- if (!args) throw new Error('Usage: eval <js-expression>');
2484
- // eval <function> <ref> calls the function with that element, as playwright-cli's eval does.
2485
- const onRef = /^([\s\S]+?)\s+((?:f\d+)?e\d+)$/.exec(args.trim());
2486
- if (onRef && isFunctionText(unquote(onRef[1]))) {
2487
- const selector = toSelector(onRef[2]);
2488
- const el = await onElement(selector, () => state.page.locator(selector).first().elementHandle({ timeout: 5000 }));
2489
- try {
2490
- // Made from its text by the debugger, as eval is, so a page's CSP cannot
2491
- // refuse it, and in the element's own frame, which may be an iframe.
2492
- const fn = await withTimeout((await el.ownerFrame()).evaluateHandle(unquote(onRef[1])), 'Page evaluation', COMMAND_TIMEOUT);
2493
- try {
2494
- printOutput(await el.evaluate((e, f) => f(e), fn), all);
2495
- } finally {
2496
- await fn.dispose().catch(() => {});
2497
- }
2498
- } finally {
2499
- await el.dispose().catch(() => {});
2500
- }
2501
- return;
2502
- }
2503
- // A function, as playwright-cli's eval takes, is called rather than returned.
2504
- // Only a single expression can be one; statements are evaluated as they are.
2505
- // await at the top level: one expression is run as an async function; statements as DevTools'
2506
- // console runs them (replMode), which gives back the last one's value.
2507
- const compiles = (Kind, body) => { try { new Kind(body); return true; } catch { return false; } };
2508
- const AsyncFunction = (async () => {}).constructor;
2509
- const awaits = /\bawait\b/.test(args) && !compiles(Function, `return (${args}\n);`);
2510
- const single = compiles(awaits ? AsyncFunction : Function, `return (${args}\n);`);
2511
- const call = `const value = (${args}\n); return typeof value === 'function' ? value() : value;`;
2512
- if (awaits && !single && compiles(AsyncFunction, args)) {
2513
- const session = await keptSession(state.page);
2514
- const { result, exceptionDetails } = await withTimeout(session.send('Runtime.evaluate', { expression: args, replMode: true, awaitPromise: true, returnByValue: true }), 'Page evaluation', COMMAND_TIMEOUT);
2515
- if (exceptionDetails) throw new Error(exceptionDetails.exception?.description || exceptionDetails.text);
2516
- printOutput(result.value !== undefined ? result.value : result.description ?? 'undefined', all);
2517
- return;
2518
- }
2519
- const expression = single ? `(${awaits ? 'async ' : ''}() => { ${call} })()` : args;
2520
- const value = await withTimeout(state.page.evaluate(expression), 'Page evaluation', COMMAND_TIMEOUT);
2521
- // An empty string is shown as one, not as no output at all.
2522
- printOutput(value === '' ? '""' : value, all);
2523
- },
2524
-
2525
- async cdp(args, all) {
2526
- const match = /^(\S+)\s+([\s\S]+)$/.exec(args || '');
2527
- if (!match) throw new Error('Usage: cdp <method> <JSON object>');
2528
- if (['Browser.close', 'Target.closeTarget'].includes(match[1])) throw new Error('Browser lifecycle commands are reserved; use quit or tab close.');
2529
- const params = JSON.parse(match[2]);
2530
- if (!params || Array.isArray(params) || typeof params !== 'object') throw new Error('Parameters must be a JSON object');
2531
- const operation = async () => {
2532
- const cdp = await state.page.context().newCDPSession(state.page);
2533
- try {
2534
- return await cdp.send(match[1], params);
2535
- } finally {
2536
- await cdp.detach().catch(() => {});
2537
- }
2538
- };
2539
- printOutput(await withTimeout(operation(), 'CDP operation', COMMAND_TIMEOUT), all);
2540
- },
2541
-
2542
- async cookies(_args, all) {
2543
- const cookies = await state.page.context().cookies();
2544
- if (!cookies.length) { out.log('No cookies'); return; }
2545
- printOutput(cookies.map(({ name, domain, path, expires, httpOnly, secure, sameSite }) =>
2546
- ({ name, domain, path, expires, httpOnly, secure, sameSite })), all);
2547
- },
2548
-
2549
- async storage(_args, all) {
2550
- const keys = await state.page.evaluate(() =>
2551
- Array.from({ length: localStorage.length }, (_, i) => localStorage.key(i))
2552
- );
2553
- if (!keys.length) { out.log('localStorage is empty'); return; }
2554
- printOutput(keys, all);
2555
- },
2556
-
2557
- async capture(args, all) {
2558
- const [sub, ...rest] = (args || '').trim().split(/\s+/).filter(Boolean);
2559
- if (sub === 'on') return startCapture(rest);
2560
- if (sub === 'off' && !rest.length) return stopCapture(all);
2561
- if (sub) throw new Error('Usage: capture on [requests|console] [seconds] | capture off');
2562
- if (cap) {
2563
- out.log(`Capturing ${cap.label} on ${cap.page.url()} since ${clock(cap.startedAt)} (${cap.events.length} so far)`);
2564
- out.log(hints([['capture off', 'stop and print what it recorded']]));
2565
- return;
2566
- }
2567
- if (lastCapture) {
2568
- out.log(`Not capturing. The last capture, of ${lastCapture.label} from ${clock(lastCapture.startedAt)}:`);
2569
- printCapture(lastCapture, all);
2570
- } else {
2571
- out.log('Not capturing.');
2572
- }
2573
- out.log(hints([['capture on [requests|console] [seconds]', 'record the selected tab\'s requests and console messages in time order']]));
2574
- },
2575
-
2576
- async dialog(args) {
2577
- out.log(await dialogCommand(args));
2578
- },
2579
-
2580
- async modes(args) {
2581
- const arg = (args || '').trim();
2582
- if (!arg) return listModes();
2583
- if (arg !== 'off' && arg !== 'off --mine') throw new Error('Usage: modes [off [--mine]]');
2584
- await allModesOff(arg === 'off --mine');
2585
- },
21
+ ...tabsCommands,
22
+ ...elementsCommands,
23
+ ...inspectCommands,
24
+ ...watchCommands,
25
+ ...requestlogCommands,
26
+ ...routesCommands,
27
+ ...networkCommands,
28
+ ...emulationCommands,
29
+ ...captureCommands,
30
+ ...dialogsCommands,
31
+ ...modesCommands,
2586
32
 
2587
33
  async help(args) {
2588
34
  const topic = (args || '').trim();
@@ -2624,19 +70,16 @@ const cliCommands = {
2624
70
  throw new Error('close turns off the modes you turned on, which needs a client name: pw-repl send -c <name> close. Without one, they cannot be told from the prompt\'s; modes lists what is on.');
2625
71
  }
2626
72
  await allModesOff(true);
2627
- const own = state.browser.contexts().flatMap(c => c.pages()).filter(p => tabOwners.get(p) === state.client);
73
+ const own = state.browser.contexts().flatMap(c => c.pages()).filter(p => tabState(p).owner === state.client);
2628
74
  if (own.length) out.log(`Tabs you opened are still open: ${own.map(p => p.url()).join(', ')}; tab close <url-part> closes one, or select it and tab close.`);
2629
75
  },
2630
76
  };
2631
77
 
2632
- onShutdown(discardCapture);
2633
- onShutdown(resetEmulations);
2634
-
2635
- // Hooks every page needs from the moment the REPL sees it.
2636
- function watchPage(p) {
78
+ // Hooks every page needs from the moment the REPL sees it; opened, it opened while the REPL runs.
79
+ function watchPage(p, opened = false) {
2637
80
  ensureDialogHandler(p);
2638
- ensureRecentLog(p);
2639
- if (nextTab && !opening) watchNewTab(p);
81
+ ensureRecentLog(p, opened);
82
+ if (nextTabWanted() && !isOpening()) watchNewTab(p);
2640
83
  }
2641
84
 
2642
85
  // watch on --next-tab waits for a tab, and watch off stops waiting, with none selected;
@@ -2676,4 +119,4 @@ function complete(line) {
2676
119
  return [[], current];
2677
120
  }
2678
121
 
2679
- module.exports = { commands, cliCommands, needsTab, dialogCommand, listTabs, openTab, startTabs, activeModes, watchPage, complete, compactSnapshot, grepSnapshot, summarizeChanges, scrubEditable, clock };
122
+ module.exports = { commands, cliCommands, needsTab, watchPage, complete };