pw-repl 0.4.2 → 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,2282 +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) return `${devices[device].viewport.width}x${devices[device].viewport.height} (emulate mobile: ${device})`;
150
- const set = state.page.viewportSize();
151
- if (set) return `${set.width}x${set.height} (set with viewport)`;
152
- const size = await state.page.evaluate(() => `${innerWidth}x${innerHeight}`).catch(() => null);
153
- return size ? `${size} (the window's size)` : 'unknown';
154
- }
155
-
156
- // A page restored from the back/forward cache never fires its load events
157
- // again, so back and forward wait only for the navigation, then briefly for
158
- // the document to be parsed, which a restored one already is.
159
- async function historyStep(go, direction) {
160
- const before = state.page.url();
161
- const response = await go();
162
- if (response === null && state.page.url() === before) throw new Error(`No page to go ${direction} to`);
163
- await state.page.waitForFunction(() => document.readyState !== 'loading', null, { timeout: 3000 }).catch(() => {});
164
- }
165
-
166
- const SCREENSHOT_DELAY_MAX = 60;
167
-
168
- function nextScreenshotPath(name) {
169
- const filename = name ? `screenshot-${name}` : `screenshot-${Date.now()}`;
170
- // A REPL on a socket of its own keeps its screenshots next to it, as its log is.
171
- return path.join(process.env.PW_SCREENSHOT_DIR || state.ownDir || '/tmp', `${filename}.png`);
172
- }
173
-
174
- // Per page: a CDP session's emulation applies to the page it is attached to
175
- // and resets, in part, when it detaches, so one is kept for each tab.
176
- const keptSessions = new WeakMap();
177
-
178
- function keptSession(p) {
179
- if (!keptSessions.has(p)) keptSessions.set(p, p.context().newCDPSession(p).catch(error => { keptSessions.delete(p); throw error; }));
180
- return keptSessions.get(p);
181
- }
182
-
183
- // Chrome all but stops drawing a tab that is not in front once it has had
184
- // input (about a frame a second), and Playwright waits for frames to see an
185
- // element hold still before acting on it, so each click in a tab opened in the
186
- // background took 1-2s. A screencast keeps the tab drawing without bringing it
187
- // to the front, for as long as the REPL runs (the throttle is back within
188
- // seconds of stopping it). Its frames are tiny and rare; the cost is that the
189
- // tab renders at full rate, as if in front, an animation or a video included.
190
- const drawingPages = new WeakSet();
191
-
192
- async function keepDrawing(p) {
193
- if (drawingPages.has(p)) return;
194
- drawingPages.add(p);
195
- try {
196
- const session = await keptSession(p);
197
- session.on('Page.screencastFrame', frame => session.send('Page.screencastFrameAck', { sessionId: frame.sessionId }).catch(() => {}));
198
- await session.send('Page.startScreencast', { format: 'jpeg', quality: 1, maxWidth: 8, maxHeight: 8, everyNthFrame: 60 });
199
- } catch {
200
- // Only slower without it.
201
- drawingPages.delete(p);
202
- }
203
- }
204
-
205
- const networkEnabled = new WeakSet();
206
- // page -> how its network is changed: { offline: true }, or slowed { latency, down, up } (ms, kbps).
207
- const networkChanged = new WeakMap();
208
- // DevTools' "Slow 4G".
209
- const SLOW_DEFAULT = { latency: 563, down: 1440, up: 675 };
210
- const SLOW_LATENCY_MAX = 10000;
211
-
212
- // setting null restores the network.
213
- async function setNetwork(p, setting) {
214
- const session = await keptSession(p);
215
- if (!networkEnabled.has(p)) { await session.send('Network.enable'); networkEnabled.add(p); }
216
- await session.send('Network.emulateNetworkConditions', {
217
- offline: !!setting?.offline,
218
- latency: setting?.latency || 0,
219
- // kbps to bytes per second; -1 is no limit.
220
- downloadThroughput: setting?.down ? setting.down * 125 : -1,
221
- uploadThroughput: setting?.up ? setting.up * 125 : -1,
222
- });
223
- if (setting) networkChanged.set(p, { ...setting, owner: state.client }); else networkChanged.delete(p);
224
- }
225
-
226
- function networkText(setting) {
227
- return setting.offline ? 'off (offline)' : `slow (${setting.latency}ms latency, ${setting.down} kbps down, ${setting.up} kbps up)`;
228
- }
229
-
230
- // page -> what emulate changed: { device, scheme, locale, timezone }, each unset when off.
231
- const emulations = new WeakMap();
232
- const DEFAULT_DEVICE = 'Pixel 7';
233
- const EMULATE_KINDS = ['mobile', 'dark', 'light', 'locale', 'timezone'];
234
-
235
- function emulatedKinds(e) {
236
- return [e?.device && 'mobile', e?.scheme, e?.locale && 'locale', e?.timezone && 'timezone'].filter(Boolean);
237
- }
238
-
239
- const DEVICES_SHOWN = 20;
240
-
241
- function deviceNamed(name) {
242
- const names = Object.keys(devices);
243
- const found = names.find(d => d.toLowerCase() === name.toLowerCase());
244
- if (found) return found;
245
- // The names that have every word typed, so a near miss finds the exact name.
246
- const words = name.toLowerCase().split(/\s+/);
247
- const close = names.filter(d => !/ landscape$/.test(d) && words.every(w => d.toLowerCase().includes(w)));
248
- 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+`);
249
- const more = close.length > DEVICES_SHOWN ? `, and ${close.length - DEVICES_SHOWN} more` : '';
250
- throw new Error(`No device ${JSON.stringify(name)}; matching: ${close.slice(0, DEVICES_SHOWN).join(', ')}${more} (each also as "<name> landscape")`);
251
- }
252
-
253
- function deviceText(name) {
254
- const d = devices[name];
255
- return `${name}, ${d.viewport.width}x${d.viewport.height} at ${d.deviceScaleFactor}x${d.hasTouch ? ', touch' : ''}`;
256
- }
257
-
258
- // Sends only what changed. Chrome takes the user agent and the languages
259
- // together, so a change to either sends both.
260
- function deviceMetrics(session, name) {
261
- const d = devices[name];
262
- return session.send('Emulation.setDeviceMetricsOverride', {
263
- width: d.viewport.width, height: d.viewport.height, deviceScaleFactor: d.deviceScaleFactor, mobile: d.isMobile,
264
- screenWidth: d.screen?.width || d.viewport.width, screenHeight: d.screen?.height || d.viewport.height,
265
- });
266
- }
267
-
268
- async function setEmulation(p, next) {
269
- const session = await keptSession(p);
270
- const previous = emulations.get(p) || {};
271
- const send = (method, params) => session.send(method, params);
272
- if (next.device !== previous.device) {
273
- const d = devices[next.device];
274
- if (d) {
275
- await deviceMetrics(session, next.device);
276
- } else {
277
- await send('Emulation.clearDeviceMetricsOverride');
278
- }
279
- await send('Emulation.setTouchEmulationEnabled', d?.hasTouch ? { enabled: true, maxTouchPoints: 5 } : { enabled: false });
280
- }
281
- if (next.device !== previous.device || next.locale !== previous.locale) {
282
- if (next.device || next.locale) {
283
- const userAgent = next.device ? devices[next.device].userAgent : (await send('Browser.getVersion')).userAgent;
284
- await send('Emulation.setUserAgentOverride', { userAgent, ...(next.locale ? { acceptLanguage: next.locale } : {}) });
285
- } else {
286
- // An empty user agent ends the override.
287
- await send('Emulation.setUserAgentOverride', { userAgent: '' });
288
- }
289
- }
290
- if (next.locale !== previous.locale) await send('Emulation.setLocaleOverride', next.locale ? { locale: next.locale } : {});
291
- if (next.timezone !== previous.timezone) await send('Emulation.setTimezoneOverride', { timezoneId: next.timezone || '' });
292
- if (next.scheme !== previous.scheme) await send('Emulation.setEmulatedMedia', { features: next.scheme ? [{ name: 'prefers-color-scheme', value: next.scheme }] : [] });
293
- if (emulatedKinds(next).length) emulations.set(p, { ...next, owner: state.client }); else emulations.delete(p);
294
- }
295
-
296
- function showEmulation() {
297
- const e = emulations.get(state.page);
298
- const forms = [
299
- ['emulate mobile [device]', `a phone's screen, touch and user agent (default ${DEFAULT_DEVICE})`],
300
- ['emulate dark | light', 'the color scheme the page sees'],
301
- ['emulate locale <tag>', 'its language and formats, e.g. fr-FR'],
302
- ['emulate timezone <zone>', 'e.g. Asia/Tokyo'],
303
- ];
304
- if (!e) {
305
- out.log('Nothing is emulated in the selected tab.');
306
- out.log(hints(forms));
307
- return;
308
- }
309
- out.log('Emulated in the selected tab:');
310
- const rows = [
311
- e.device && ['mobile', deviceText(e.device)],
312
- e.scheme && ['color scheme', e.scheme],
313
- e.locale && ['locale', e.locale],
314
- e.timezone && ['timezone', e.timezone],
315
- ].filter(Boolean);
316
- for (const [what, value] of rows) out.log(` ${what.padEnd(12)} ${value}`);
317
- out.log(hints([...forms, ['emulate <what> off | emulate off', 'stop one, or all']]));
318
- }
319
-
320
- // Resets every tab's emulation, which detaching would only partly undo.
321
- async function resetEmulations() {
322
- if (!state.browser || state.connectionLost) return;
323
- const pages = state.browser.contexts().flatMap(c => c.pages()).filter(p => emulations.has(p) && !p.isClosed());
324
- await Promise.all(pages.map(p => setEmulation(p, {}).catch(() => {})));
325
- }
326
-
327
- // page -> Map(glob -> { status, body, handler }). Playwright routes are
328
- // registered per page, so the listing is too.
329
- const pageRoutes = new WeakMap();
330
- const ROUTE_PREVIEW = 80;
331
-
332
- function routesFor(p) {
333
- if (!pageRoutes.has(p)) pageRoutes.set(p, new Map());
334
- return pageRoutes.get(p);
335
- }
336
-
337
- const ROUTE_DELAY_MAX = 120;
338
-
339
- // Marks a request a route answered, now: the browser reports it finished only later.
340
- function markChanged(req, status, how) {
341
- changedRequests.set(req, how);
342
- const entry = requestEntries.get(req);
343
- if (entry) { entry.status = `${status} ${how}`; entry.ms = Date.now() - entry.t; }
344
- }
345
-
346
- // A JSON Merge Patch (RFC 7386): objects merge, null removes a key, anything else replaces.
347
- function mergePatch(target, patch) {
348
- if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return patch;
349
- const result = target && typeof target === 'object' && !Array.isArray(target) ? { ...target } : {};
350
- for (const [key, value] of Object.entries(patch)) {
351
- if (value === null) delete result[key];
352
- else result[key] = mergePatch(result[key], value);
353
- }
354
- return result;
355
- }
356
-
357
- function parseJson(text, what) {
358
- try { return JSON.parse(text); } catch (error) { throw new Error(`${what} is not valid JSON: ${error.message}`); }
359
- }
360
-
361
- // How a route treats the requests it matches: its text for listing, and what it does to each one.
362
- function routeKind(how, rest, usage) {
363
- if (/^\d{3}$/.test(how)) {
364
- const status = Number(how);
365
- if (status < 200 || status > 599) throw new Error('Status must be from 200 to 599');
366
- // JSON unless its type is given, so a typo in the JSON is not served as text.
367
- const typed = /^--content-type=("(?:[^"\\]|\\.)*"|'[^']*'|\S+)(?:\s+([\s\S]*))?$/.exec(rest || '');
368
- const body = typed ? typed[2] || '' : rest || '';
369
- if (!typed && body) {
370
- try { JSON.parse(body); } catch (error) {
371
- 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>`);
372
- }
373
- }
374
- // Quoted when it has a parameter: --content-type="text/html; charset=utf-8".
375
- const contentType = typed ? unquote(typed[1]) : body ? 'application/json' : undefined;
376
- const preview = body.length > ROUTE_PREVIEW ? `${body.slice(0, ROUTE_PREVIEW)}…` : body;
377
- return {
378
- text: `${status}${typed ? ` (${contentType})` : ''}${preview ? ` ${preview}` : ''}`,
379
- async handle(r, req, tag) {
380
- markChanged(req, status, 'faked');
381
- await r.fulfill({ status, contentType, body });
382
- out.notice(`Faked: ${tag()} -> ${status}`);
383
- },
384
- };
385
- }
386
- if (how === 'patch') {
387
- if (!rest) throw new Error(usage);
388
- const patch = parseJson(rest, 'Patch');
389
- if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new Error('Patch must be a JSON object, e.g. {"total": 0}');
390
- return {
391
- text: `patch ${rest.length > ROUTE_PREVIEW ? `${rest.slice(0, ROUTE_PREVIEW)}…` : rest}`,
392
- async handle(r, req, tag) {
393
- const response = await r.fetch();
394
- let body;
395
- try { body = await response.json(); } catch {
396
- await r.fulfill({ response });
397
- out.notice(`Not patched: ${tag()} — its response is not JSON, so it went through unchanged`);
398
- return;
399
- }
400
- markChanged(req, response.status(), 'patched');
401
- await r.fulfill({ response, json: mergePatch(body, patch) });
402
- out.notice(`Patched: ${tag()} -> ${response.status()}`);
403
- },
404
- };
405
- }
406
- if (how === 'delay') {
407
- const seconds = Number(rest);
408
- 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})`);
409
- return {
410
- text: `delay ${seconds}s`,
411
- async handle(r, req, tag) {
412
- await new Promise(resolve => setTimeout(resolve, seconds * 1000));
413
- await r.continue();
414
- out.notice(`Delayed ${seconds}s: ${tag()}`);
415
- },
416
- };
417
- }
418
- if (how === 'abort' && !rest) {
419
- return {
420
- text: 'abort',
421
- async handle(r, req, tag) {
422
- await r.abort('failed');
423
- out.notice(`Aborted: ${tag()}`);
424
- },
425
- };
426
- }
427
- throw new Error(usage);
428
- }
429
-
430
- function listRoutes() {
431
- const routes = routesFor(state.page);
432
- const forms = [
433
- ['route <url-glob> <status> [json-body]', 'answer it with this status and JSON'],
434
- ['route <url-glob> <status> --content-type=<type> <body>', 'or with a body of another type'],
435
- ['route <url-glob> patch <json>', 'let it through, then change its JSON'],
436
- ['route <url-glob> delay <seconds>', 'hold it, then let it through'],
437
- ['route <url-glob> abort', 'fail it as if the connection broke'],
438
- ];
439
- if (!routes.size) {
440
- out.log('No routes on the selected tab.');
441
- out.log(hints(forms));
442
- return;
443
- }
444
- out.log(`Routes on the selected tab (${routes.size}):`);
445
- const width = Math.max(...[...routes.keys()].map(glob => glob.length));
446
- for (const [glob, { text }] of routes) out.log(` ${glob.padEnd(width)} ${text}`);
447
- out.log(hints([...forms, ['route off <url-glob> | route off --all', 'remove']]));
448
- }
449
-
450
- async function removeRoutes(glob) {
451
- if (!glob) throw new Error('Usage: route off <url-glob> | route off --all');
452
- const routes = routesFor(state.page);
453
- if (glob !== '--all' && !routes.has(glob)) throw new Error(`No route for ${glob} on the selected tab`);
454
- const removed = await unroute(state.page, glob === '--all' ? [...routes.keys()] : [glob]);
455
- for (const g of removed) out.log(`Removed: ${g}`);
456
- if (!removed.length) out.log('No routes on the selected tab');
457
- }
458
-
459
- async function unroute(p, globs) {
460
- const routes = routesFor(p);
461
- for (const g of globs) {
462
- await p.unroute(g, routes.get(g).handler);
463
- routes.delete(g);
464
- }
465
- return globs;
466
- }
467
-
468
- // Always-on request and console logs, so what happened can be checked after
469
- // someone has already clicked through without a capture running. Requests
470
- // keep metadata plus a handle to the response; a body is read from the
471
- // browser only when asked for, and only while the browser still has it.
472
- const RECENT_MAX = 200;
473
- const RECENT_DEFAULT = 20;
474
- const BODY_TIMEOUT = 15000;
475
- // Hidden unless asked for: they are rarely what is being checked and crowd out
476
- // the API calls that are.
477
- const RECENT_HIDDEN_TYPES = new Set(['image', 'font', 'stylesheet', 'media']);
478
- // The bodies body prints; any other is binary.
479
- const TEXT_BODY = /json|^text\/|javascript|xml|html|x-www-form-urlencoded/;
480
-
481
- // UTF-8 with no control characters but tabs and line breaks.
482
- function looksLikeText(buffer) {
483
- let text;
484
- try { text = new TextDecoder('utf-8', { fatal: true }).decode(buffer); } catch { return false; }
485
- return !/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(text);
486
- }
487
- // A watched tab keeps each body watch shows as it arrives, since the browser
488
- // drops them once the tab navigates, often before anyone has read them.
489
- const KEPT_BODY_MAX = 100 * 1024;
490
- const recentLogs = new WeakMap();
491
- const consoleLogs = new WeakMap();
492
- // request -> how a route changed it (faked or patched), shown after its status.
493
- const changedRequests = new WeakMap();
494
- const requestEntries = new WeakMap();
495
- // page -> when its page last fired its load event.
496
- const loadedAt = new WeakMap();
497
- // Tabs this REPL opened with tab new.
498
- const openedTabs = new WeakSet();
499
- // page -> the client that opened it (null: the prompt, or a sender without a name).
500
- const tabOwners = new WeakMap();
501
- // The tab the REPL opened for its start URL, which tab marks: in a kept profile it sits among restored tabs.
502
- const startTabs = new WeakSet();
503
-
504
- function clipText(text) {
505
- return text.length > MAX_EVENT_TEXT ? `${text.slice(0, MAX_EVENT_TEXT)}…` : text;
506
- }
507
-
508
- // The browser's own "Failed to load resource" message does not say which one.
509
- function consoleText(msg) {
510
- const text = msg.text();
511
- const url = msg.location()?.url;
512
- return /^Failed to load resource/.test(text) && url ? `${text}: ${url}` : text;
513
- }
514
-
515
- // A level includes the ones above it, as playwright-cli's console [min-level]
516
- // does, which sorts the types the same way; a page error is an error.
517
- const CONSOLE_LEVELS = ['error', 'warning', 'info', 'debug'];
518
- const DEBUG_TYPES = new Set(['clear', 'debug', 'endGroup', 'profile', 'profileEnd', 'startGroup', 'startGroupCollapsed', 'trace']);
519
-
520
- function consoleLevel(type) {
521
- if (type === 'error' || type === 'assert' || type === 'pageerror') return 'error';
522
- if (type === 'warning') return 'warning';
523
- return DEBUG_TYPES.has(type) ? 'debug' : 'info';
524
- }
525
-
526
- function keep(log, entry) {
527
- log.push(entry);
528
- if (log.length > RECENT_MAX) log.shift();
529
- }
530
-
531
- function ensureRecentLog(p) {
532
- if (recentLogs.has(p)) return;
533
- const log = [];
534
- const logs = [];
535
- let nextId = 1;
536
- let newDocument = false;
537
- recentLogs.set(p, log);
538
- consoleLogs.set(p, logs);
539
- p.on('request', req => {
540
- let navigation = false;
541
- try { navigation = req.isNavigationRequest() && req.frame() === p.mainFrame(); } catch {}
542
- const entry = { id: nextId++, t: Date.now(), method: req.method(), url: req.url(), type: req.resourceType(), status: 'pending', ms: null, response: null, navigation };
543
- if (navigation) newDocument = true;
544
- requestEntries.set(req, entry);
545
- keep(log, entry);
546
- });
547
- const finish = (req, status, response = null) => {
548
- const entry = requestEntries.get(req);
549
- if (!entry) return;
550
- entry.status = status;
551
- entry.ms = Date.now() - entry.t;
552
- entry.response = response;
553
- };
554
- p.on('requestfinished', async req => {
555
- let status = 'no response';
556
- let res = null;
557
- try {
558
- res = await req.response();
559
- if (res) status = String(res.status());
560
- } catch {}
561
- finish(req, changedRequests.has(req) ? `${status} ${changedRequests.get(req)}` : status, res);
562
- keepBody(p, requestEntries.get(req), res);
563
- });
564
- p.on('requestfailed', req => {
565
- finish(req, `failed: ${req.failure()?.errorText || 'unknown'}`);
566
- // A page that never loaded: the app's next route change is not a load either.
567
- if (requestEntries.get(req)?.navigation) newDocument = false;
568
- });
569
- p.on('load', () => loadedAt.set(p, Date.now()));
570
- // A page load, marked among the messages it comes between; it ages out with them. Only a new
571
- // document counts: an app's own route change (pushState, a hash) navigates without loading.
572
- p.on('framenavigated', frame => {
573
- if (frame !== p.mainFrame() || !newDocument) return;
574
- newDocument = false;
575
- keep(logs, { t: Date.now(), type: 'load', text: frame.url() });
576
- });
577
- p.on('console', msg => keep(logs, { t: Date.now(), type: msg.type(), text: clipText(consoleText(msg)) }));
578
- // Uncaught exceptions never reach the console event.
579
- p.on('pageerror', error => keep(logs, { t: Date.now(), type: 'pageerror', text: clipText(error.stack || error.message) }));
580
- }
581
-
582
- // Unnamed generic nodes are layout wrappers (mostly divs): they add depth and
583
- // nothing to read. Drop them, lift their children, and drop cursor hints.
584
- function compactSnapshot(text) {
585
- const dropped = [];
586
- const lines = [];
587
- for (const line of text.split('\n')) {
588
- const indent = line.length - line.trimStart().length;
589
- while (dropped.length && dropped[dropped.length - 1] >= indent) dropped.pop();
590
- if (/^- generic(?: \[[^\]]+\])*:?$/.test(line.trim())) { dropped.push(indent); continue; }
591
- lines.push(' '.repeat(Math.max(0, indent - 2 * dropped.length)) + line.trimStart().replace(/ \[cursor=pointer\]/g, ''));
592
- }
593
- return lines.join('\n');
594
- }
595
-
596
- // Opt-in record of what the person at the browser does, so an agent can see
597
- // the steps and the requests each one caused. Values typed into fields are
598
- // never recorded, and password fields not at all.
599
- const watches = new WeakMap();
600
- const WATCH_MAX = 200;
601
- const WATCH_REQUESTS_SHOWN = 5;
602
- // Scripts too: a dev server loads dozens per navigation, crowding out the API calls.
603
- const WATCH_HIDDEN_TYPES = new Set([...RECENT_HIDDEN_TYPES, 'script']);
604
-
605
- // Tabs whose watch on --next-tab is still starting: the page can load before it is ready.
606
- const startingWatches = new WeakSet();
607
-
608
- // A page that navigates as soon as a request answers (a login that redirects)
609
- // takes the body with it before the request is even reported finished; told to
610
- // in time, the browser keeps them outside the page instead. Once per tab, from
611
- // its first watch, and never switched off: up to 50 MB of the browser's memory
612
- // per watched tab, for as long as the REPL runs.
613
- const DURABLE_TOTAL = 50 * 1024 * 1024;
614
- const durablePages = new WeakSet();
615
-
616
- async function keepBodiesDurable(p) {
617
- if (durablePages.has(p)) return;
618
- durablePages.add(p);
619
- try {
620
- const session = await keptSession(p);
621
- await session.send('Network.configureDurableMessages', { maxTotalBufferSize: DURABLE_TOTAL, maxResourceBufferSize: KEPT_BODY_MAX });
622
- } catch {
623
- // An older Chrome: bodies are still kept once their requests finish.
624
- }
625
- }
626
-
627
- async function keepBody(p, entry, res) {
628
- if (!entry || !res || !(watches.get(p)?.on || startingWatches.has(p)) || WATCH_HIDDEN_TYPES.has(entry.type)) return;
629
- const headers = res.headers();
630
- if (!TEXT_BODY.test(headers['content-type'] || '') || Number(headers['content-length']) > KEPT_BODY_MAX) return;
631
- try {
632
- const buffer = await res.body();
633
- if (buffer.length <= KEPT_BODY_MAX) entry.kept = buffer;
634
- } catch {}
635
- }
636
- const TYPING_BACKDATE_MAX = 60000;
637
- const PAGE_ACTIONS = new Set(['click', 'check', 'uncheck', 'select', 'fill', 'type', 'press', 'submit']);
638
- // page -> its binding's record function; the binding is added once per tab.
639
- const watchBindings = new WeakMap();
640
- const WATCH_BINDING = '__pwReplWatch';
641
-
642
- // Runs in the page. Describes elements the way snapshot does: role and name.
643
- const WATCH_SCRIPT = `(() => {
644
- if (window.__pwReplWatching) return;
645
- window.__pwReplWatching = true;
646
- const INTERACTIVE = 'a,button,input,select,textarea,summary,label,[role],[onclick],[tabindex]';
647
- const roleOf = el => {
648
- const explicit = el.getAttribute('role');
649
- if (explicit) return explicit;
650
- const tag = el.tagName.toLowerCase();
651
- const type = (el.getAttribute('type') || '').toLowerCase();
652
- if (tag === 'a' && el.hasAttribute('href')) return 'link';
653
- if (tag === 'button' || (tag === 'input' && ['button', 'submit', 'reset', 'image'].includes(type))) return 'button';
654
- if (tag === 'input' && type === 'checkbox') return 'checkbox';
655
- if (tag === 'input' && type === 'radio') return 'radio';
656
- if (tag === 'input' || tag === 'textarea') return 'textbox';
657
- if (tag === 'select') return 'combobox';
658
- if (/^h[1-6]$/.test(tag)) return 'heading';
659
- return tag;
660
- };
661
- // Text inside these may be what the person typed, so it is never used as a name.
662
- const typedInto = el => el.matches('input,select,textarea,form') || el.isContentEditable || !!el.closest('[contenteditable]:not([contenteditable=false])');
663
- const isPassword = el => el.matches('input[type=password]') || (el.control && el.control.matches('input[type=password]'));
664
- // An element holding an editable area would be named partly by what was typed there.
665
- const hasEditable = el => !!el.querySelector('[contenteditable]:not([contenteditable=false])');
666
- const clip = text => (text || '').replace(/\\s+/g, ' ').trim().slice(0, 60);
667
- // A label's own words, without the options or values of the controls inside it.
668
- const labelText = label => {
669
- const copy = label.cloneNode(true);
670
- copy.querySelectorAll('select,textarea,input,button').forEach(n => n.remove());
671
- return copy.textContent;
672
- };
673
- const nameOf = el => {
674
- const labelledBy = el.getAttribute('aria-labelledby');
675
- const byId = labelledBy && labelledBy.split(/\\s+/).map(id => document.getElementById(id)?.innerText).join(' ');
676
- const label = (el.id && document.querySelector('label[for="' + CSS.escape(el.id) + '"]')) || el.closest('label');
677
- return clip(el.getAttribute('aria-label') || byId || (label && label !== el && labelText(label)) || el.getAttribute('alt')
678
- || el.getAttribute('placeholder') || el.getAttribute('title') || (typedInto(el) || hasEditable(el) ? '' : el.innerText));
679
- };
680
- const describe = el => { const name = nameOf(el); return roleOf(el) + (name ? ' ' + JSON.stringify(name) : ''); };
681
- // el null: the page itself, e.g. Escape with nothing focused.
682
- const send = (action, el, extra, since) => {
683
- // Typing still waiting for its pause is recorded first, so the steps stay in order.
684
- if (action !== 'type') for (const field of [...pending.keys()]) flushTyping(field);
685
- if (typeof window.__pwReplWatch === 'function') window.__pwReplWatch(JSON.stringify({ action, target: el ? describe(el) : 'page', extra: extra || '', since: since || 0 }));
686
- };
687
- document.addEventListener('click', e => {
688
- const el = e.target.closest ? (e.target.closest(INTERACTIVE) || e.target) : e.target;
689
- if (!el.matches || el.matches('input[type=checkbox],input[type=radio],select') || isPassword(el)) return;
690
- send('click', el);
691
- }, true);
692
- // Typing is recorded once it pauses, not on change, so a typeahead's requests
693
- // land under the typing that caused them. A field typed into is not also
694
- // recorded as a fill when it loses focus.
695
- const TYPING_PAUSE = 600;
696
- const pending = new Map();
697
- const typed = new WeakSet();
698
- // Sent with how long ago the typing began, so the step is placed before the
699
- // requests the typing caused.
700
- const flushTyping = el => {
701
- if (!pending.has(el)) return;
702
- const { timer, start } = pending.get(el);
703
- clearTimeout(timer);
704
- pending.delete(el);
705
- typed.add(el);
706
- send('type', el, '', Date.now() - start);
707
- };
708
- const editable = el => el.isContentEditable ? (el.closest('[contenteditable]:not([contenteditable=false])') || el) : null;
709
- 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])');
710
- document.addEventListener('input', e => {
711
- const el = editable(e.target) || e.target;
712
- if (!el.matches || isPassword(el) || !(textEntry(el) || el.isContentEditable)) return;
713
- const start = pending.has(el) ? pending.get(el).start : Date.now();
714
- clearTimeout(pending.get(el)?.timer);
715
- pending.set(el, { start, timer: setTimeout(() => flushTyping(el), TYPING_PAUSE) });
716
- }, true);
717
- document.addEventListener('keydown', e => {
718
- if (e.key !== 'Enter' && e.key !== 'Escape') return;
719
- if (!e.target.matches) return;
720
- const el = editable(e.target) || e.target.closest(INTERACTIVE);
721
- if (el && isPassword(el)) return;
722
- // Enter on a button or link is recorded as the click it causes.
723
- if (e.key === 'Enter' && !(el && (textEntry(el) || el.isContentEditable))) return;
724
- if (el) flushTyping(el);
725
- send('press', el, e.key);
726
- }, true);
727
- document.addEventListener('change', e => {
728
- const el = e.target;
729
- if (!el.matches || isPassword(el)) return;
730
- if (el.matches('input[type=checkbox],input[type=radio]')) return send(el.checked ? 'check' : 'uncheck', el);
731
- if (el.matches('select')) return send('select', el, JSON.stringify(clip(el.selectedOptions[0]?.text)));
732
- if (pending.has(el)) { flushTyping(el); typed.delete(el); return; }
733
- if (typed.has(el)) { typed.delete(el); return; }
734
- send('fill', el);
735
- }, true);
736
- document.addEventListener('submit', e => send('submit', e.target), true);
737
- })()`;
738
-
739
- // What changed on screen between two snapshots, in a few lines. Refs, links'
740
- // URLs and focus are dropped, and so are the values of text fields, which may
741
- // be what the person typed. A new or removed element is shown once, with the
742
- // first few named things inside it, not line by line.
743
- const CHANGES_SHOWN = 5;
744
- const CHANGE_WIDTH = 100;
745
- const CHANGE_NAMES_SHOWN = 3;
746
- const FIELD_VALUE = /^(- (?:textbox|combobox|searchbox|spinbutton)\b[^:]*?)(?::.*)?$/;
747
-
748
- function snapshotNodes(text) {
749
- const nodes = [];
750
- const stack = [];
751
- for (const line of compactSnapshot(text).split('\n')) {
752
- let body = line.trim().replace(/ \[ref=[^\]]+\]/g, '').replace(/ \[active\]/g, '');
753
- if (!body || /^- \/url:/.test(body)) continue;
754
- body = body.replace(FIELD_VALUE, '$1').replace(/:$/, '');
755
- const indent = line.length - line.trimStart().length;
756
- while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();
757
- const node = { body, indent, parent: stack.length ? stack[stack.length - 1] : null, children: [] };
758
- if (node.parent) node.parent.children.push(node);
759
- nodes.push(node);
760
- stack.push(node);
761
- }
762
- return nodes;
763
- }
764
-
765
- // Nodes of `nodes` with no match in `others`, compared by their text; each
766
- // kept only if its parent is matched, so a new subtree counts once.
767
- function unmatchedTops(nodes, others) {
768
- const counts = new Map();
769
- for (const o of others) counts.set(o.body, (counts.get(o.body) || 0) + 1);
770
- const unmatched = new Set();
771
- for (const n of nodes) {
772
- const c = counts.get(n.body);
773
- if (c) counts.set(n.body, c - 1); else unmatched.add(n);
774
- }
775
- return nodes.filter(n => unmatched.has(n) && !(n.parent && unmatched.has(n.parent)));
776
- }
777
-
778
- // role "name", without flags or text: how an element is told apart.
779
- const identity = body => /^- ([^[:"]+?(?: "(?:[^"\\]|\\.)*")?)(?: \[|:|$)/.exec(body)?.[1] || body;
780
-
781
- function describeSubtree(node) {
782
- const named = [];
783
- // A named element by role and name; an unnamed one by its text, if it has any.
784
- const label = body => /^- [^:"]+ "(?:[^"\\]|\\.)*"/.exec(body)?.[0].slice(2) || /^- [^:"]+: (.+)$/.exec(body)?.[1];
785
- // What is inside a named element is mostly its name again, so it is not listed.
786
- const walk = n => {
787
- for (const c of n.children) {
788
- const l = label(c.body);
789
- if (l && named.length < 50) named.push(l);
790
- if (!/"/.test(l || '')) walk(c);
791
- }
792
- };
793
- walk(node);
794
- let text = node.body.replace(/^- /, '');
795
- // The count goes first so clipping a long line cuts names, not the count.
796
- if (named.length > CHANGE_NAMES_SHOWN) text += ` (${named.length} named inside)`;
797
- if (named.length) text += `: ${named.slice(0, CHANGE_NAMES_SHOWN).join(', ')}${named.length > CHANGE_NAMES_SHOWN ? ', …' : ''}`;
798
- return text;
799
- }
800
-
801
- function summarizeChanges(beforeText, afterText) {
802
- const before = snapshotNodes(beforeText);
803
- const after = snapshotNodes(afterText);
804
- const added = unmatchedTops(after, before);
805
- const removed = unmatchedTops(before, after);
806
- const lines = [];
807
- // Same element, different flags or text: one changed line, not a remove and an add.
808
- for (const a of added) {
809
- const r = removed.find(x => identity(x.body) === identity(a.body) && x.indent === a.indent);
810
- if (r) { removed.splice(removed.indexOf(r), 1); lines.push(`~ ${a.body.replace(/^- /, '')}`); }
811
- else lines.push(`+ ${describeSubtree(a)}`);
812
- }
813
- for (const r of removed) lines.push(`- ${describeSubtree(r)}`);
814
- const clipped = lines.map(l => (l.length > CHANGE_WIDTH ? `${l.slice(0, CHANGE_WIDTH - 1)}…` : l));
815
- if (clipped.length > CHANGES_SHOWN) return [...clipped.slice(0, CHANGES_SHOWN), `… ${clipped.length - CHANGES_SHOWN} more changes`];
816
- return clipped;
817
- }
818
-
819
- // With watch on --changes: once the page settles after a step (no new step
820
- // for a moment and none of its requests pending), snapshot it and attach the
821
- // difference from the previous snapshot to that step. With --live, that is
822
- // also when the step is printed, with its requests and changes.
823
- const SETTLE_QUIET = 800;
824
- const SETTLE_MAX = 3000;
825
-
826
- // Text in an editable area (contenteditable) may be what the person typed,
827
- // and the snapshot shows it as ordinary text, so it is blanked out: a line's
828
- // text or name is dropped when it is part of an editable area's text.
829
- async function snapshotText(p) {
830
- const text = await p.ariaSnapshot({ mode: 'ai', timeout: SETTLE_MAX });
831
- const edited = await p.evaluate(() => [...document.querySelectorAll('[contenteditable]:not([contenteditable=false])')]
832
- .map(el => el.innerText.replace(/\s+/g, ' ').trim()).filter(Boolean));
833
- return scrubEditable(text, edited);
834
- }
835
-
836
- function scrubEditable(text, edited) {
837
- if (!edited.length) return text;
838
- const typed = part => { const t = part.replace(/\s+/g, ' ').trim(); return !!t && edited.some(e => e.includes(t) || t.includes(e)); };
839
- return text.split('\n').map(line => {
840
- let out = line.replace(/ "((?:[^"\\]|\\.)*)"/, (whole, name) => (typed(name) ? '' : whole));
841
- const value = /^(\s*- [^:]*?): (.+)$/.exec(out);
842
- if (value && typed(value[2].replace(/^"(.*)"$/, '$1'))) out = value[1];
843
- return out;
844
- }).join('\n');
845
- }
846
-
847
- function scheduleSettle(p, watch) {
848
- clearTimeout(watch.settleTimer);
849
- if (!watch.changes && !watch.live) return;
850
- watch.settleTimer = setTimeout(async () => {
851
- const step = watch.events[watch.events.length - 1];
852
- const deadline = Date.now() + SETTLE_MAX;
853
- const busy = () => (recentLogs.get(p) || []).some(r => r.t >= step.t && r.status === 'pending' && !RECENT_HIDDEN_TYPES.has(r.type));
854
- while (busy() && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 100));
855
- // A newer step restarts the wait; its changes will include these.
856
- if (watch.events[watch.events.length - 1] !== step || p.isClosed()) return;
857
- if (watch.changes) {
858
- let text = null;
859
- try { text = await snapshotText(p); } catch {}
860
- if (watch.events[watch.events.length - 1] !== step) return;
861
- // Against another page, everything differs; the navigate step says enough.
862
- if (text !== null && step.action !== 'navigate' && watch.snapshot !== null) step.changes = summarizeChanges(watch.snapshot, text);
863
- if (text !== null) watch.snapshot = text;
864
- }
865
- printLive(p, watch);
866
- }, SETTLE_QUIET);
867
- }
868
-
869
- // Prints the step --live has not printed yet, above the prompt.
870
- function printLive(p, watch) {
871
- const step = watch.liveStep;
872
- if (!step) return;
873
- watch.liveStep = null;
874
- const lines = stepLines(step, requestsByStep(watch, p)(step));
875
- // Named by its URL when it is not the selected tab, so a step can be placed.
876
- const where = p === state.page ? '' : ` ${p.url().replace(/^[a-z]+:\/\//, '').slice(0, 60)}`;
877
- const prefix = `[watch${where}] `;
878
- out.aside(lines.map((line, i) => `${i ? ' '.repeat(prefix.length) : prefix}${line}`).join('\n'));
879
- }
880
-
881
- async function startWatching(p, changes = false, live = false) {
882
- await keepBodiesDurable(p);
883
- let watch = watches.get(p);
884
- if (!watch) {
885
- // shownSeq and shownRequestId mark how far watch new has read.
886
- const created = { on: false, startedAt: 0, changes: false, live: false, liveStep: null, snapshot: null, settleTimer: null, events: [], nextSeq: 1, shownSeq: 0, shownRequestId: 0 };
887
- // since backdates a step (typing is reported once it pauses), but never to
888
- // before the previous step, so the page cannot reorder the record.
889
- const record = (action, target, extra, since = 0) => {
890
- if (!created.on) return;
891
- const previous = created.events[created.events.length - 1];
892
- const t = Math.max(Date.now() - Math.min(Math.max(Number(since) || 0, 0), TYPING_BACKDATE_MAX), previous ? previous.t : 0);
893
- const step = { seq: created.nextSeq++, t, action, target: clipText(target), extra: clipText(extra) };
894
- keep(created.events, step);
895
- // A step that has not settled yet is printed now: this one ends it.
896
- printLive(p, created);
897
- if (created.live) created.liveStep = step;
898
- scheduleSettle(p, created);
899
- };
900
- // The page can call the binding itself, so only the page-side actions are
901
- // accepted from it, as plain strings; navigations come from here. A CDP
902
- // binding of its own, not Playwright's exposeBinding: every Playwright
903
- // client shares that one, so another tool attached to the browser would
904
- // answer each call with a "not exposed" error thrown into the page.
905
- if (!watchBindings.has(p)) {
906
- const binding = { record: null };
907
- const session = await keptSession(p);
908
- session.on('Runtime.bindingCalled', ({ name, payload }) => {
909
- if (name !== WATCH_BINDING || !binding.record) return;
910
- let event;
911
- try { event = JSON.parse(payload); } catch { return; }
912
- if (event && PAGE_ACTIONS.has(event.action)) binding.record(event.action, String(event.target || ''), String(event.extra || ''), event.since);
913
- });
914
- // Calls are reported only to a session with Runtime on.
915
- await session.send('Runtime.enable');
916
- await session.send('Runtime.addBinding', { name: WATCH_BINDING });
917
- watchBindings.set(p, binding);
918
- }
919
- watchBindings.get(p).record = record;
920
- await p.addInitScript(WATCH_SCRIPT);
921
- p.on('framenavigated', frame => { if (frame === p.mainFrame()) record('navigate', frame.url(), ''); });
922
- // Only now: a failed first attempt must leave nothing half set up to retry against.
923
- watches.set(p, created);
924
- watch = created;
925
- } else if (!watch.on) {
926
- // A watch on after watch off is a new recording: the old steps would read as part of it.
927
- watch.events.length = 0;
928
- watch.shownSeq = watch.nextSeq - 1;
929
- watch.shownRequestId = Math.max(0, ...(recentLogs.get(p) || []).map(r => r.id));
930
- watch.liveStep = null;
931
- }
932
- await p.evaluate(WATCH_SCRIPT);
933
- watch.changes = changes;
934
- watch.live = live;
935
- if (!live) watch.liveStep = null;
936
- // The first step's changes are measured from how the page looks now. A page
937
- // too busy to snapshot is not a reason to fail: the step after one that
938
- // does snapshot is measured instead.
939
- watch.snapshot = null;
940
- if (changes) {
941
- try { watch.snapshot = await snapshotText(p); }
942
- catch { out.log('Could not snapshot the page yet; changes are shown from a later step.'); }
943
- }
944
- if (!watch.on) watch.startedAt = Date.now();
945
- watch.on = true;
946
- }
947
-
948
- // Set by watch on --next-tab: the next tab to open (by anyone), or the next
949
- // whose URL contains part, is watched from its first page and selected. Each
950
- // new tab is watched from the moment it opens, since its first page may load
951
- // at once; one that turns out not to be it is left as it was.
952
- let nextTab = null;
953
-
954
- // The sender's own --next-tab is still waiting: the tab will be selected for it.
955
- function waitingForMe() {
956
- return !!nextTab && nextTab.client === state.client;
957
- }
958
- const BROWSER_PAGE = /^(?:about|chrome|chrome-search|devtools|edge):/;
959
-
960
- function waitingText() {
961
- return `Waiting to watch the next tab that opens${nextTab.part ? ` with a URL containing ${nextTab.part}` : ''}`;
962
- }
963
-
964
- function watchNewTab(p) {
965
- const want = nextTab;
966
- startingWatches.add(p);
967
- keepBodiesDurable(p);
968
- startWatching(p, want.changes, want.live).then(() => {
969
- startingWatches.delete(p);
970
- watches.get(p).owner = want.client;
971
- const onNavigate = frame => {
972
- if (frame !== p.mainFrame() || BROWSER_PAGE.test(frame.url())) return;
973
- const url = frame.url();
974
- p.off('framenavigated', onNavigate);
975
- const watch = watches.get(p);
976
- if (nextTab !== want || (want.part && !url.includes(want.part))) {
977
- stopWatching(p, watch);
978
- watch.events.length = 0;
979
- return;
980
- }
981
- nextTab = null;
982
- // Its record starts at its own page, not the new-tab page before it.
983
- const first = watch.events.findIndex(e => e.action === 'navigate' && e.target === clipText(url));
984
- if (first > 0) watch.events.splice(0, first);
985
- // A page that loaded before the watch was ready: its step is added, at its request's time.
986
- if (first < 0) {
987
- const request = (recentLogs.get(p) || []).filter(e => e.navigation && e.url === url).pop();
988
- watch.events.unshift({ seq: watch.nextSeq++, t: request ? request.t : Date.now(), action: 'navigate', target: clipText(url), extra: '' });
989
- }
990
- // Selected for the client that asked, whoever's command runs now.
991
- const record = clientRecord(want.client);
992
- if (record.page !== p) { record.previousPage = record.page; record.page = p; }
993
- keepDrawing(p);
994
- out.notice(`Watching the new tab ${url}, now the selected tab (watch on --next-tab)`);
995
- };
996
- p.on('framenavigated', onNavigate);
997
- // Opened on its page already, as a link to a new tab does.
998
- if (p.url() && !BROWSER_PAGE.test(p.url())) onNavigate(p.mainFrame());
999
- }).catch(error => {
1000
- startingWatches.delete(p);
1001
- out.notice(`Could not watch the new tab ${p.url()}: ${error.message}`);
1002
- });
1003
- }
1004
-
1005
- // For each step of a watch, the requests made between it and the next step.
1006
- function requestsByStep(watch, p = state.page) {
1007
- const requests = (recentLogs.get(p) || []).filter(r => !WATCH_HIDDEN_TYPES.has(r.type) && !r.url.startsWith('chrome-extension://'));
1008
- return e => {
1009
- const next = watch.events[watch.events.indexOf(e) + 1];
1010
- return requests.filter(r => r.t >= e.t && (!next || r.t < next.t));
1011
- };
1012
- }
1013
-
1014
- function stepLines(e, caused, note = '', withChanges = true) {
1015
- const lines = [`${clock(e.t)} ${e.action} ${e.target}${e.extra ? ` ${e.extra}` : ''}${note}`];
1016
- const indent = ' '.repeat(clock(e.t).length + 1);
1017
- for (const r of caused.slice(0, WATCH_REQUESTS_SHOWN)) lines.push(`${indent}#${r.id} ${r.method} ${r.status} ${r.url}`);
1018
- if (caused.length > WATCH_REQUESTS_SHOWN) lines.push(`${indent}… ${caused.length - WATCH_REQUESTS_SHOWN} more (requests)`);
1019
- if (e.changes && withChanges) for (const change of e.changes) lines.push(`${indent}${change}`);
1020
- return lines;
1021
- }
1022
-
1023
- // watch on its own: whether it is on, the last steps, and what to run next.
1024
- function showWatch(watch, all) {
1025
- const steps = watch ? watch.events.length : 0;
1026
- const counted = `${steps} step${steps === 1 ? '' : 's'}`;
1027
- const lines = [];
1028
- const extras = [watch?.changes && 'changes', watch?.live && 'live'].filter(Boolean);
1029
- if (watch?.on) lines.push(`Watching the selected tab since ${clock(watch.startedAt)}${extras.length ? ` (${extras.join(', ')})` : ''}: ${steps ? counted : 'nothing has happened yet'}`);
1030
- else if (steps) lines.push(`Not watching the selected tab; ${counted} recorded before watch off:`);
1031
- else lines.push('Not watching the selected tab.');
1032
- if (steps) {
1033
- const causedBy = requestsByStep(watch);
1034
- const shown = watch.events.slice(-RECENT_DEFAULT);
1035
- for (const e of shown) lines.push(...stepLines(e, causedBy(e)));
1036
- if (steps > shown.length) lines.push(`(last ${shown.length} of ${steps})`);
1037
- }
1038
- const more = ['watch <n>', `the last n steps (up to ${WATCH_MAX})`];
1039
- if (watch?.on) lines.push(hints([more, ['watch new', 'only the steps not shown by watch new yet'], ['watch off', 'stop recording']]));
1040
- else if (steps) lines.push(hints([more, ['watch on [--changes] [--live]', 'record again']]));
1041
- 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']]));
1042
- printOutput(lines.join('\n'), all);
1043
- }
1044
-
1045
- function stopWatching(p, watch) {
1046
- clearTimeout(watch.settleTimer);
1047
- printLive(p, watch);
1048
- watch.on = false;
1049
- watch.changes = false;
1050
- watch.live = false;
1051
- }
1052
-
1053
- // The modes turned on in a tab, in the order the prompt shows them.
1054
- function activeModes(p) {
1055
- const modes = [];
1056
- if (watches.get(p)?.on) modes.push('watch');
1057
- const network = networkChanged.get(p);
1058
- if (network) modes.push(network.offline ? 'network:off' : 'network:slow');
1059
- const emulated = emulatedKinds(emulations.get(p));
1060
- if (emulated.length) modes.push(`emulate:${emulated.join(',')}`);
1061
- const routes = pageRoutes.get(p)?.size;
1062
- if (routes) modes.push(`routes:${routes}`);
1063
- if (cap && cap.page === p) modes.push('capture');
1064
- return modes;
1065
- }
1066
-
1067
- function listModes() {
1068
- const all = state.browser.contexts().flatMap(c => c.pages());
1069
- state.tabListing = all.slice();
1070
- const on = all.map((p, i) => [p, i, activeModes(p)]).filter(([, , modes]) => modes.length);
1071
- if (nextTab) out.log(`${waitingText()} (watch off stops waiting)${nextTab.client ? `, for ${nextTab.client}` : ''}${on.length ? '\n' : ''}`);
1072
- if (!on.length && nextTab) return;
1073
- if (!on.length) {
1074
- out.log('No modes are on in any tab.');
1075
- out.log(hints([
1076
- ['watch on', 'record what the person at the browser does'],
1077
- ['capture on', 'record requests and console messages together'],
1078
- ['route <url-glob> <status> <json-body>', 'fake a response'],
1079
- ['network off', 'cut the tab\'s network'],
1080
- ['emulate mobile', 'show the tab as a phone would'],
1081
- ]));
1082
- return;
1083
- }
1084
- const width = Math.max(...on.map(([p, i]) => `[${i}] ${p.url()}`.length));
1085
- for (const [p, i, modes] of on) {
1086
- out.log(`${p === state.page ? '*' : ' '} ${`[${i}] ${p.url()}`.padEnd(width)} (${modes.join(' ')})`);
1087
- const owners = modeOwners(p);
1088
- if (owners.some(([owner]) => owner)) out.log(` on by ${ownersText(owners)}`);
1089
- }
1090
- out.log(hints([['modes off', 'turn them all off']]));
1091
- }
1092
-
1093
- // Who turned on each mode in a tab: [client, what], client null for the prompt or an unnamed sender.
1094
- function modeOwners(p) {
1095
- const owners = [];
1096
- const watch = watches.get(p);
1097
- if (watch?.on) owners.push([watch.owner ?? null, 'watch']);
1098
- const network = networkChanged.get(p);
1099
- if (network) owners.push([network.owner ?? null, network.offline ? 'network:off' : 'network:slow']);
1100
- const emulated = emulations.get(p);
1101
- if (emulated) owners.push([emulated.owner ?? null, 'emulate']);
1102
- for (const [glob, route] of pageRoutes.get(p) || []) owners.push([route.owner ?? null, `route ${glob}`]);
1103
- if (cap && cap.page === p) owners.push([cap.owner ?? null, 'capture']);
1104
- return owners;
1105
- }
1106
-
1107
- function ownersText(owners) {
1108
- const byOwner = new Map();
1109
- for (const [owner, what] of owners) byOwner.set(owner, [...(byOwner.get(owner) || []), what]);
1110
- return [...byOwner].map(([owner, whats]) => `${owner || 'unnamed'}: ${whats.join(', ')}`).join('; ');
1111
- }
1112
-
1113
- // Turns off everything the REPL turned on, in every tab: nothing it leaves
1114
- // behind keeps acting on someone's browser. mine: only what the running client turned on.
1115
- async function allModesOff(mine = false) {
1116
- const all = state.browser.contexts().flatMap(c => c.pages());
1117
- const ours = owner => !mine || (owner ?? null) === state.client;
1118
- let any = false;
1119
- let failed = 0;
1120
- if (nextTab && ours(nextTab.client)) { nextTab = null; any = true; out.log('Stopped waiting to watch a new tab'); }
1121
- // A tab that fails (e.g. it crashed) must not keep the others' modes on.
1122
- for (const p of all) {
1123
- const done = [];
1124
- let problem = null;
1125
- try {
1126
- const watch = watches.get(p);
1127
- if (watch?.on && ours(watch.owner)) { stopWatching(p, watch); done.push('watch off'); }
1128
- if (cap && cap.page === p && ours(cap.owner)) { endCapture(); done.push('capture off (capture shows it)'); }
1129
- const globs = [...(pageRoutes.get(p) || [])].filter(([, route]) => ours(route.owner)).map(([glob]) => glob);
1130
- if (globs.length) { await unroute(p, globs); done.push(`${globs.length} route${globs.length === 1 ? '' : 's'} removed`); }
1131
- if (networkChanged.has(p) && ours(networkChanged.get(p).owner)) { await setNetwork(p, null); done.push('network on'); }
1132
- if (emulations.has(p) && ours(emulations.get(p).owner)) { await setEmulation(p, {}); done.push('emulate off'); }
1133
- } catch (error) {
1134
- problem = error.message;
1135
- }
1136
- if (done.length) { any = true; out.log(`${p.url()}: ${done.join(', ')}`); }
1137
- if (problem) { failed += 1; out.error(`${p.url()}: could not turn everything off: ${problem}`); }
1138
- }
1139
- if (failed) throw new Error(`Modes may still be on in ${failed} tab${failed === 1 ? '' : 's'}; modes lists them`);
1140
- if (!any) out.log(mine ? 'None of your modes were on.' : 'No modes were on.');
1141
- }
1142
-
1143
- // The type a page sees for a file it is given, from its extension.
1144
- const MIME_TYPES = {
1145
- png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
1146
- pdf: 'application/pdf', txt: 'text/plain', csv: 'text/csv', json: 'application/json', html: 'text/html',
1147
- xml: 'application/xml', zip: 'application/zip', mp4: 'video/mp4', webm: 'video/webm', mp3: 'audio/mpeg',
1148
- wav: 'audio/wav', doc: 'application/msword', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
1149
- docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
1150
- };
1151
-
1152
- function mimeType(file) {
1153
- return MIME_TYPES[path.extname(file).slice(1).toLowerCase()] || 'application/octet-stream';
1154
- }
1155
-
1156
- // Commands that take only a selector take the whole line, spaces and all.
1157
- function soleSelector(args, usage) {
1158
- const text = unquote((args || '').trim());
1159
- if (!text) throw new Error(`Usage: ${usage}`);
1160
- return toSelector(text);
1161
- }
1162
-
1163
- const MODIFIERS = ['Alt', 'Control', 'ControlOrMeta', 'Meta', 'Shift'];
1164
-
1165
- // click <selector> [button] [--modifiers=<keys>], as playwright-cli's click
1166
- // takes them. The button is read only after a ref or a quoted selector, so
1167
- // click text=Turn right is still one selector.
1168
- function clickArgs(args, usage) {
1169
- // Found outside quotes only, so a quoted selector keeps whatever is in it.
1170
- const { found, rest: text } = takeOptions((args || '').trim(), { modifiers: 'value' });
1171
- const modifiers = found.flatMap(o => (o.value || '').split(',').filter(Boolean).map(keyName));
1172
- const unknown = modifiers.find(m => !MODIFIERS.includes(m));
1173
- if (unknown) throw new Error(`Not a modifier: ${unknown}; they are ${MODIFIERS.join(', ')}`);
1174
- const parsed = splitSelector(text);
1175
- const button = parsed && /^(?:left|right|middle)$/.test(parsed.rest || '') && (REF.test(parsed.word) || /^["']/.test(text)) ? parsed.rest : null;
1176
- const selector = button ? parsed.selector : soleSelector(text, usage);
1177
- const options = { ...(button ? { button } : {}), ...(modifiers.length ? { modifiers } : {}) };
1178
- return { selector, shown: args.trim(), options };
1179
- }
1180
-
1181
- // A trailing --submit, as playwright-cli's fill and type take: Enter afterwards.
1182
- function submitFlag(args) {
1183
- if (/(?:^|\s)--submit\s/.test((args || '').trim())) throw new Error('--submit goes at the end of the line');
1184
- const match = /^([\s\S]*?)\s+--submit$/.exec((args || '').trim());
1185
- return match ? { args: match[1], submit: true } : { args, submit: false };
1186
- }
1187
-
1188
- function selectorAndValue(args, usage, example) {
1189
- const parsed = splitSelector(args);
1190
- if (!parsed || parsed.rest === undefined) {
1191
- throw new Error(`Usage: ${usage}, e.g. ${example}; quote a selector with spaces: "text=Your name"`);
1192
- }
1193
- return { selector: parsed.selector, value: unquote(parsed.rest), shown: parsed.word };
1194
- }
1195
-
1196
- // An element that never appeared is said plainly, not as Playwright's call
1197
- // log. A ref that no longer matches usually means the page changed since the
1198
- // snapshot it came from.
1199
- async function onElement(selector, action) {
1200
- try {
1201
- return await action();
1202
- } catch (error) {
1203
- if (/resolved to/.test(error.message)) throw error;
1204
- const ref = selector.startsWith('aria-ref=') ? selector.slice(9) : null;
1205
- const staleRef = ref ? `\n${ref} is a snapshot ref; if the page changed since that snapshot, take a new one.` : '';
1206
- const waited = /Timeout (\d+)ms exceeded[\s\S]*waiting for locator/.exec(error.message);
1207
- if (waited) throw new Error(`No element matches ${ref || selector} (waited ${waited[1] / 1000}s)${staleRef}`);
1208
- error.message += staleRef;
1209
- throw error;
1210
- }
1211
- }
1212
-
1213
- const WAIT_DEFAULT = 10;
1214
- const WAIT_MAX = 120;
1215
-
1216
- // A pattern without * is a URL part; with * it is a glob (* within a path segment, ** across).
1217
- function urlMatcher(pattern) {
1218
- if (!pattern.includes('*')) return url => url.includes(pattern);
1219
- const source = pattern.split('**').map(chunk => chunk.split('*').map(text => text.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*')).join('.*');
1220
- const regex = new RegExp(`^${source}$`);
1221
- return url => regex.test(url);
1222
- }
1223
-
1224
- function printRequest(entry) {
1225
- out.log(`#${entry.id} ${entry.method} ${entry.status} ${entry.url}`);
1226
- }
1227
-
1228
- // Watches the recent log, which has every request from the moment it starts, so
1229
- // one that began or finished since the previous command (e.g. the click that
1230
- // caused it) is found rather than missed.
1231
- async function waitForRequest(pattern, timeout) {
1232
- const matches = urlMatcher(pattern);
1233
- const since = state.previousCommandAt || 0;
1234
- const deadline = Date.now() + timeout;
1235
- for (;;) {
1236
- const entry = (recentLogs.get(state.page) || []).find(e => e.t >= since && matches(e.url));
1237
- if (entry && entry.status !== 'pending') return printRequest(entry);
1238
- if (Date.now() >= deadline) throw new Error(`No response matching ${pattern} within ${timeout / 1000}s${entry ? ` (#${entry.id} is still pending)` : ''}`);
1239
- await new Promise(resolve => setTimeout(resolve, 50));
1240
- }
1241
- }
1242
-
1243
- // Done once the page has loaded: after the latest navigation of the tab that
1244
- // began since the previous command did (the click on a link, say), or, with
1245
- // none, the document there now. Waiting on Playwright's load state alone would
1246
- // see the page being left, which has already loaded.
1247
- async function waitForLoad(timeout) {
1248
- const p = state.page;
1249
- const since = state.previousCommandAt || 0;
1250
- const deadline = Date.now() + timeout;
1251
- for (;;) {
1252
- const navigation = (recentLogs.get(p) || []).filter(e => e.navigation && e.t >= since).pop();
1253
- if (navigation && navigation.status.startsWith('failed')) throw new Error(`The page did not load: #${navigation.id} ${navigation.url} ${navigation.status}`);
1254
- const loaded = navigation ? (loadedAt.get(p) || 0) >= navigation.t : await p.evaluate(() => document.readyState === 'complete').catch(() => false);
1255
- if (loaded && !navigation && (loadedAt.get(p) || 0) < since) {
1256
- // Nothing loaded since the previous command: a page that changes its URL itself (pushState) does not
1257
- // load, and may not have changed it yet.
1258
- 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.)`);
1259
- return;
1260
- }
1261
- if (loaded) { out.log(`Loaded: ${p.url()} — ${await p.title().catch(() => '')}`); return; }
1262
- if (Date.now() >= deadline) throw new Error(`${p.url()} did not finish loading within ${timeout / 1000}s`);
1263
- await new Promise(resolve => setTimeout(resolve, 50));
1264
- }
1265
- }
1266
-
1267
- // Gone once no match is visible, whether it was removed or hidden.
1268
- async function waitGone(locator, what, timeout) {
1269
- try {
1270
- await locator.filter({ visible: true }).first().waitFor({ state: 'detached', timeout });
1271
- } catch (error) {
1272
- if (/Timeout \d+ms exceeded/.test(error.message)) throw new Error(`Still visible after ${timeout / 1000}s: ${what}`);
1273
- throw error;
1274
- }
1275
- out.log(`Gone: ${what}`);
1276
- }
1277
-
1278
- const SNAPSHOT_HINT_LINES = 60;
1279
-
1280
- // Each matching line (any part of it: role, name, flags such as [disabled]),
1281
- // with the named elements it sits in, so a match can be placed without the
1282
- // rest of the tree.
1283
- // needle: text to find in any case, or a RegExp.
1284
- function grepSnapshot(text, needle) {
1285
- const lower = typeof needle === 'string' ? needle.toLowerCase() : null;
1286
- const matches = line => (lower === null ? needle.test(line) : line.toLowerCase().includes(lower));
1287
- const stack = [];
1288
- const hits = [];
1289
- const label = line => line.trim().replace(/^- /, '').replace(/:$/, '').replace(/^'(.*)'$/, '$1');
1290
- for (const line of text.split('\n')) {
1291
- const indent = line.length - line.trimStart().length;
1292
- while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();
1293
- if (matches(line)) {
1294
- const around = stack.filter(a => !/^generic(?: \[|$)/.test(a.label));
1295
- // A hit with no ref of its own (text) keeps the ref of what holds it, so
1296
- // snapshot <ref> can show what sits beside it: the value next to a label.
1297
- const own = /\[ref=/.test(line);
1298
- const path = around.map((a, i) => (!own && i === around.length - 1 ? a.label : a.label.replace(/ \[ref=[^\]]+\]/g, '')));
1299
- hits.push([...path, label(line)].join(' › '));
1300
- }
1301
- stack.push({ indent, label: label(line) });
1302
- }
1303
- return hits;
1304
- }
1305
-
1306
- // For --regex, as playwright-cli's find --regex and requests --filter take one.
1307
- function regexOf(pattern) {
1308
- try { return new RegExp(pattern); } catch (error) { throw new Error(`Not a regular expression: ${error.message}`); }
1309
- }
1310
-
1311
- // A pattern without its quotes, and with \" inside them as ", as snapshot
1312
- // reads one: other backslashes are the regexp's own (\d).
1313
- function patternText(text) {
1314
- return text.replace(/^(["'])(.*)\1$/, '$2').replace(/\\"/g, '"');
1315
- }
1316
-
1317
- // Local time with its offset (18:16:15.721-06:00), so it reads against the
1318
- // user's clock and is still unambiguous.
1319
- function clock(t) {
1320
- const d = new Date(t);
1321
- const pad = (n, width = 2) => String(n).padStart(width, '0');
1322
- const offset = -d.getTimezoneOffset();
1323
- const sign = offset < 0 ? '-' : '+';
1324
- const zone = `${sign}${pad(Math.floor(Math.abs(offset) / 60))}:${pad(Math.abs(offset) % 60)}`;
1325
- return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}${zone}`;
1326
- }
1327
-
1328
- // One line per event, from the start of the capture: a request as requests
1329
- // shows it (its status is looked up now, so it is known if it has finished),
1330
- // a console message or page error as console shows it.
1331
- function printCapture(capture, all = false) {
1332
- const lines = capture.events.map(event => {
1333
- const at = `+${((event.t - capture.startedAt) / 1000).toFixed(3)}s`;
1334
- if (event.tag !== 'request') return `${at} [${event.tag}] ${event.text}`;
1335
- const entry = requestEntries.get(event.req);
1336
- return entry ? `${at} #${entry.id} ${entry.method} ${entry.status} ${entry.url}` : `${at} ${event.text}`;
1337
- });
1338
- if (lines.length) printOutput(lines.join('\n'), all);
1339
- else out.log('Nothing captured');
1340
- const notes = [];
1341
- if (capture.dropped) notes.push(`${capture.dropped} event(s) omitted`);
1342
- if (capture.shortened) notes.push(`${capture.shortened} message(s) shortened`);
1343
- if (notes.length) out.log(`[capture limits: ${notes.join('; ')}]`);
1344
- }
1345
-
1346
- function discardCapture() {
1347
- if (!cap) return;
1348
- cap.handlers.forEach(([event, handler]) => cap.page.off(event, handler));
1349
- cap = null;
1350
- }
1351
-
1352
- // A tab of the REPL's own: closing it can go back to the tab before. It opens
1353
- // in the background, so it does not take the front of the window from the
1354
- // person using the browser; Playwright's newPage would bring it to the front.
1355
- const OPEN_TIMEOUT = 10000;
1356
-
1357
- // True while tab new opens a tab: watch on --next-tab waits for one someone
1358
- // else opens, not one a client of the REPL opens for itself.
1359
- let opening = false;
1360
-
1361
- async function openTab() {
1362
- opening = true;
1363
- try { return await openOwnTab(); } finally { opening = false; }
1364
- }
1365
-
1366
- async function openOwnTab() {
1367
- const ctx = state.browser.contexts()[0];
1368
- let opened = null;
1369
- const session = await state.browser.newBrowserCDPSession().catch(() => null);
1370
- if (session) {
1371
- try {
1372
- const { targetId } = await session.send('Target.createTarget', { url: 'about:blank', background: true });
1373
- opened = await pageForTarget(ctx, targetId);
1374
- } catch {} finally {
1375
- await session.detach().catch(() => {});
1376
- }
1377
- }
1378
- // A browser that cannot open one in the background (e.g. some headless ones) opens it as usual.
1379
- if (!opened) opened = await ctx.newPage();
1380
- openedTabs.add(opened);
1381
- tabOwners.set(opened, state.client);
1382
- keepDrawing(opened);
1383
- return opened;
1384
- }
1385
-
1386
- // page -> its CDP target id, asked for once per page.
1387
- const targetIds = new WeakMap();
1388
-
1389
- async function targetIdOf(ctx, p) {
1390
- if (!targetIds.has(p)) {
1391
- const cdp = await ctx.newCDPSession(p).catch(() => null);
1392
- if (!cdp) return null;
1393
- const info = await cdp.send('Target.getTargetInfo').catch(() => null);
1394
- await cdp.detach().catch(() => {});
1395
- if (!info) return null;
1396
- targetIds.set(p, info.targetInfo.targetId);
1397
- }
1398
- return targetIds.get(p);
1399
- }
1400
-
1401
- async function pageForTarget(ctx, targetId) {
1402
- const deadline = Date.now() + OPEN_TIMEOUT;
1403
- while (Date.now() < deadline) {
1404
- for (const p of ctx.pages()) {
1405
- if (openedTabs.has(p)) continue;
1406
- if (await targetIdOf(ctx, p) === targetId) return p;
1407
- }
1408
- await new Promise(resolve => setTimeout(resolve, 50));
1409
- }
1410
- return null;
1411
- }
1412
-
1413
- // Every tab, URL first, with * on the selected one. The numbers are what tab <index> uses.
1414
- async function listTabs() {
1415
- const all = state.browser.contexts().flatMap(c => c.pages());
1416
- if (state.page && !all.includes(state.page)) state.page = null;
1417
- state.tabListing = all.slice();
1418
- if (!all.length) { out.log('No tabs. Use tab new.'); return; }
1419
- for (const [i, p] of all.entries()) {
1420
- let title;
1421
- try { title = await p.title(); } catch { title = '[title unavailable]'; }
1422
- const marker = p === state.page ? '*' : ' ';
1423
- // URL first on its own line: it is what distinguishes otherwise
1424
- // identically titled tabs, and titles wrap less badly when indented.
1425
- if (i) out.log('');
1426
- const notes = [startTabs.has(p) && 'the start URL', tabOwners.get(p) && `opened by ${tabOwners.get(p)}`].filter(Boolean);
1427
- out.log(`${marker} [${i}] ${p.url()}${notes.length ? ` (${notes.join(', ')})` : ''}`);
1428
- out.log(` ${JSON.stringify(title)}`);
1429
- }
1430
- }
1431
-
1432
- // A function written out (el => ..., function (el) {...}), as one expression.
1433
- function isFunctionText(text) {
1434
- if (!/^\s*(?:async\s+)?(?:function\b|\([^)]*\)\s*=>|[\w$]+\s*=>)/.test(text)) return false;
1435
- try { new Function(`return (${text}\n);`); return true; } catch { return false; }
1436
- }
1437
-
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.
1438
20
  const commands = {
1439
- async tab(args) {
1440
- const trimmed = (args || '').trim();
1441
- if (!trimmed) {
1442
- await listTabs();
1443
- if (!state.page) out.log('\nNo tab is selected.');
1444
- out.log(hints([
1445
- ['tab <index|url-part>', 'select a tab'],
1446
- ['tab new [url]', 'open a tab of your own and select it'],
1447
- ['tab close [url-part]', 'close the selected tab, or the one whose URL contains url-part'],
1448
- ]));
1449
- return;
1450
- }
1451
- const usage = 'Usage: tab [<index> | <url-part> | new [url] | close [<url-part>]]';
1452
- const parts = trimmed.split(/\s+/);
1453
- const subcommand = parts.shift();
1454
- const all = state.browser.contexts().flatMap(c => c.pages());
1455
- // Matching on the URL, not an index, so a stale listing cannot point at someone else's tab.
1456
- // Remembers where the REPL was, so closing a tab can go back there, but
1457
- // only to a tab this REPL opened: the previous one may be someone else's.
1458
- const select = p => {
1459
- if (p !== state.page) state.previousPage = state.page;
1460
- state.page = p;
1461
- ensureDialogHandler(p);
1462
- keepDrawing(p);
1463
- };
1464
- const byUrl = part => {
1465
- const matches = all.filter(p => p.url().includes(part));
1466
- if (matches.length === 1) return matches[0];
1467
- if (!matches.length) throw new Error(`No tab URL contains "${part}"`);
1468
- throw new Error(`${matches.length} tabs match "${part}"; use a longer part:\n${matches.map(p => ` ${p.url()}`).join('\n')}`);
1469
- };
1470
- if (/^\d+$/.test(subcommand)) {
1471
- if (parts.length) throw new Error(usage);
1472
- const target = state.tabListing[Number(subcommand)];
1473
- if (target && all.includes(target)) { select(target); return commands.info(); }
1474
- // Not read as part of a URL instead: that could land in someone else's tab without a word.
1475
- if (target) throw new Error(`Tab [${subcommand}] has closed since the latest listing. Run tab to list them again.`);
1476
- // A number that is not a tab in the listing may be part of a URL (a port).
1477
- const containing = all.filter(p => p.url().includes(subcommand)).length;
1478
- if (!containing) throw new Error(`No tab [${subcommand}] in the latest listing, and no tab URL contains ${subcommand}. Run tab to list them.`);
1479
- if (containing > 1) {
1480
- const listing = state.tabListing.length ? 'the latest listing' : 'a listing yet: each client has its own, from tab';
1481
- throw new Error(`No tab [${subcommand}] in ${listing}. As part of a URL, ${subcommand} is in ${containing} tabs. Run tab, then tab <index>.`);
1482
- }
1483
- }
1484
- if (subcommand === 'new') {
1485
- select(await openTab());
1486
- const url = parts.join(' ');
1487
- if (url) {
1488
- try { await commands.goto(url); }
1489
- catch (error) { throw new Error(`${error.message}\nThe new tab stays open and selected; tab close closes it.`); }
1490
- } else {
1491
- out.log('New tab created and selected');
1492
- }
1493
- return listTabs();
1494
- }
1495
- if (subcommand === 'close') {
1496
- const target = parts.length ? byUrl(parts.join(' ')) : state.page;
1497
- if (!target || target.isClosed() || !all.includes(target)) throw new Error('Selected tab is unavailable. Run tab again.');
1498
- if (all.length <= 1) throw new Error('Refusing to close the last tab');
1499
- const url = target.url();
1500
- const wasSelected = target === state.page;
1501
- await target.close();
1502
- if (wasSelected) {
1503
- const back = state.previousPage;
1504
- state.page = back && !back.isClosed() && openedTabs.has(back) ? back : null;
1505
- state.previousPage = null;
1506
- }
1507
- out.log(`Closed ${url}${wasSelected && !state.page ? '; no tab is selected now' : ''}`);
1508
- return listTabs();
1509
- }
1510
- select(byUrl(trimmed));
1511
- return commands.info();
1512
- },
1513
-
1514
- async goto(args) {
1515
- if (!args) throw new Error('Usage: goto <url>');
1516
- let url = args;
1517
- // As a browser's address bar does: a local dev server is almost always plain http.
1518
- const local = /^(?:localhost|127(?:\.\d+){3}|\[::1\]|0\.0\.0\.0)(?:[:/?#]|$)/i.test(url);
1519
- if (!/^[a-z][a-z\d+.-]*:\/\//i.test(url) && !/^(about|data|file|javascript):/i.test(url)) url = `${local ? 'http' : 'https'}://${url}`;
1520
- await state.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
1521
- out.log(`${state.page.url()} — ${await state.page.title()}`);
1522
- },
1523
-
1524
- async back() {
1525
- await historyStep(() => state.page.goBack({ waitUntil: 'commit', timeout: 10000 }), 'back');
1526
- out.log(`Back to: ${state.page.url()}`);
1527
- },
1528
-
1529
- async forward() {
1530
- await historyStep(() => state.page.goForward({ waitUntil: 'commit', timeout: 10000 }), 'forward');
1531
- out.log(`Forward to: ${state.page.url()}`);
1532
- },
1533
-
1534
- async reload() {
1535
- await state.page.reload({ waitUntil: 'domcontentloaded', timeout: 15000 });
1536
- out.log(`Reloaded: ${state.page.url()}`);
1537
- },
1538
-
1539
- async info() {
1540
- out.log(` URL: ${state.page.url()}`);
1541
- out.log(` Title: ${await state.page.title()}`);
1542
- out.log(` Viewport: ${await viewportText()}`);
1543
- },
1544
-
1545
- async click(args) {
1546
- const { selector, shown, options } = clickArgs(args, 'click <selector> [left|right|middle] [--modifiers=<key>[,<key>]]');
1547
- await onElement(selector, () => state.page.click(selector, { ...options, timeout: 5000 }));
1548
- out.log(`Clicked: ${shown}`);
1549
- },
1550
-
1551
- async dblclick(args) {
1552
- const { selector, shown, options } = clickArgs(args, 'dblclick <selector> [left|right|middle] [--modifiers=<key>[,<key>]]');
1553
- await onElement(selector, () => state.page.dblclick(selector, { ...options, timeout: 5000 }));
1554
- out.log(`Double-clicked: ${shown}`);
1555
- },
1556
-
1557
- async hover(args) {
1558
- const selector = soleSelector(args, 'hover <selector>');
1559
- await onElement(selector, () => state.page.hover(selector, { timeout: 5000 }));
1560
- out.log(`Hovered: ${args.trim()}`);
1561
- },
1562
-
1563
- async fill(line) {
1564
- const { args, submit } = submitFlag(line);
1565
- const { selector, value, shown } = selectorAndValue(args, 'fill <selector> <value> [--submit]', 'fill #name Ada Lovelace');
1566
- await onElement(selector, () => state.page.fill(selector, value, { timeout: 5000 }));
1567
- if (submit) await state.page.press(selector, 'Enter', { timeout: 5000 });
1568
- out.log(`Filled${submit ? ' and submitted' : ''}: ${shown}`);
1569
- },
1570
-
1571
- async type(line) {
1572
- const { args, submit } = submitFlag(line);
1573
- // One word, or one quoted, is typed into the focused element, as playwright-cli's type does.
1574
- const sole = splitSelector(args);
1575
- if (sole && sole.rest === undefined) {
1576
- // Named, since it may not be the element meant; one that takes no text is refused.
1577
- const focused = await state.page.evaluate(() => {
1578
- const el = document.activeElement;
1579
- if (!el || el === document.body || el === document.documentElement) return null;
1580
- 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])');
1581
- const id = el.id ? `#${el.id}` : el.name ? `[name=${el.name}]` : el.placeholder ? `[placeholder="${el.placeholder}"]` : '';
1582
- return { editable, name: `${el.tagName.toLowerCase()}${id}` };
1583
- });
1584
- const usage = 'type <selector> <text> types into an element, e.g. type #search garden hose';
1585
- if (!focused) throw new Error(`Nothing on the page has focus: ${usage}`);
1586
- if (!focused.editable) throw new Error(`The focused element, ${focused.name}, takes no text: ${usage}`);
1587
- await state.page.keyboard.type(sole.word);
1588
- if (submit) await state.page.keyboard.press('Enter');
1589
- out.log(`Typed into the focused ${focused.name}${submit ? ', and submitted' : ''}`);
1590
- return;
1591
- }
1592
- const { selector, value, shown } = selectorAndValue(args, 'type [<selector>] <text> [--submit]', 'type #search garden hose');
1593
- // Typed after what the field already holds, as someone clicking into it
1594
- // at the end would; focusing it alone leaves the caret at the start.
1595
- await onElement(selector, () => state.page.locator(selector).first().evaluate(el => {
1596
- el.focus();
1597
- if (typeof el.value === 'string' && typeof el.setSelectionRange === 'function') {
1598
- try { el.setSelectionRange(el.value.length, el.value.length); } catch {}
1599
- } else if (el.isContentEditable) {
1600
- const range = document.createRange();
1601
- range.selectNodeContents(el);
1602
- range.collapse(false);
1603
- getSelection().removeAllRanges();
1604
- getSelection().addRange(range);
1605
- }
1606
- }, null, { timeout: 5000 }));
1607
- await state.page.keyboard.type(value);
1608
- if (submit) await state.page.keyboard.press('Enter');
1609
- out.log(`Typed into${submit ? ' and submitted' : ''}: ${shown}`);
1610
- },
1611
-
1612
- async press(args) {
1613
- const parsed = splitSelector(args);
1614
- if (!parsed) throw new Error('Usage: press <key> | press <selector> <key>, e.g. press Enter or press #name Enter');
1615
- if (parsed.rest === undefined) {
1616
- await state.page.keyboard.press(keyName(parsed.word));
1617
- out.log(`Pressed: ${keyName(parsed.word)}`);
1618
- return;
1619
- }
1620
- const key = keyName(unquote(parsed.rest));
1621
- await onElement(parsed.selector, () => state.page.press(parsed.selector, key, { timeout: 5000 }));
1622
- out.log(`Pressed ${key} on ${parsed.word}`);
1623
- },
1624
-
1625
- async select(args) {
1626
- const { selector, value, shown } = selectorAndValue(args, 'select <selector> <value>', 'select #country Canada');
1627
- await onElement(selector, () => state.page.selectOption(selector, value, { timeout: 5000 }));
1628
- out.log(`Selected "${value}" in ${shown}`);
1629
- },
1630
-
1631
- async check(args) {
1632
- const selector = soleSelector(args, 'check <selector>');
1633
- await onElement(selector, () => state.page.check(selector, { timeout: 5000 }));
1634
- out.log(`Checked: ${args.trim()}`);
1635
- },
1636
-
1637
- async uncheck(args) {
1638
- const selector = soleSelector(args, 'uncheck <selector>');
1639
- await onElement(selector, () => state.page.uncheck(selector, { timeout: 5000 }));
1640
- out.log(`Unchecked: ${args.trim()}`);
1641
- },
1642
-
1643
- // The files are read here and handed to the page as their contents, not
1644
- // their paths: the browser may run on another machine, or in another
1645
- // container, where the paths mean nothing.
1646
- async upload(args) {
1647
- const parsed = splitSelector(args);
1648
- // playwright-cli's upload <file> answers a file picker a click opened. Here
1649
- // the input is named instead: catching pickers would catch the person's too.
1650
- 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';
1651
- if (!parsed || parsed.rest === undefined) throw new Error(usage);
1652
- // A path, not a selector; // starts an XPath.
1653
- 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)}`);
1654
- const files = [...parsed.rest.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g)].map(m => path.resolve(m[1] !== undefined ? m[1].replace(/\\(.)/g, '$1') : m[2] ?? m[3]));
1655
- const payloads = files.map(file => {
1656
- let buffer;
1657
- 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}`); }
1658
- return { name: path.basename(file), mimeType: mimeType(file), buffer };
1659
- });
1660
- const { selector } = parsed;
1661
- await onElement(selector, async () => {
1662
- try {
1663
- await state.page.setInputFiles(selector, payloads, { timeout: 5000 });
1664
- } catch (error) {
1665
- if (!/not an HTMLInputElement/.test(error.message)) throw error;
1666
- // A button that opens the file picker itself: click it, and answer the picker.
1667
- const [chooser] = await Promise.all([state.page.waitForEvent('filechooser', { timeout: 5000 }), state.page.click(selector, { timeout: 5000 })]);
1668
- await chooser.setFiles(payloads);
1669
- }
1670
- });
1671
- out.log(`Chose ${files.length === 1 ? 'a file' : `${files.length} files`} in ${parsed.word}: ${files.join(', ')}`);
1672
- },
1673
-
1674
- async text(args, all) {
1675
- const selector = soleSelector(args, 'text <selector>');
1676
- printOutput(await onElement(selector, () => state.page.innerText(selector, { timeout: 5000 })), all);
1677
- },
1678
-
1679
- async html(args, all) {
1680
- const selector = soleSelector(args, 'html <selector>');
1681
- printOutput(await onElement(selector, () => state.page.$eval(selector, el => el.outerHTML)), all);
1682
- },
1683
-
1684
- async attrs(args, all) {
1685
- const selector = soleSelector(args, 'attrs <selector>');
1686
- const result = await onElement(selector, () => state.page.$eval(selector, el => {
1687
- const out = {};
1688
- for (const attr of el.attributes) out[attr.name] = attr.value;
1689
- return out;
1690
- }));
1691
- printOutput(result, all);
1692
- },
1693
-
1694
- // The page's own event listeners on an element, which only DevTools can
1695
- // list: the element is handed to a CDP session through the page, so any
1696
- // Playwright selector works, and the session asks for its listeners.
1697
- async listeners(args, all) {
1698
- const text = (args || '').trim();
1699
- const usage = 'listeners <selector> | listeners document | listeners window';
1700
- if (!text) throw new Error(`Usage: ${usage}`);
1701
- const which = text === 'document' || text === 'window' ? text : null;
1702
- const selector = which ? null : soleSelector(args, usage);
1703
- if (selector) await onElement(selector, () => state.page.locator(selector).first().evaluate(el => { window.__pwReplListenersOf = el; }, null, { timeout: 5000 }));
1704
- const cdp = await state.page.context().newCDPSession(state.page);
1705
- try {
1706
- // Chrome includes each handler's source only for an object in a named group.
1707
- const { result } = await cdp.send('Runtime.evaluate', { expression: which || 'window.__pwReplListenersOf', objectGroup: 'pw-repl-listeners' });
1708
- if (!result.objectId) throw new Error(`No element matches ${text}`);
1709
- const { listeners } = await cdp.send('DOMDebugger.getEventListeners', { objectId: result.objectId });
1710
- if (!listeners.length) { out.log(`No event listeners on ${text}`); return; }
1711
- const lines = listeners.map(l => {
1712
- const how = [l.useCapture && 'capture', l.once && 'once', l.passive && 'passive'].filter(Boolean).join(', ');
1713
- const handler = (l.handler?.description || '').replace(/\s+/g, ' ').slice(0, 100);
1714
- return `${l.type}${how ? ` (${how})` : ''}: ${handler} (line ${l.lineNumber + 1})`;
1715
- });
1716
- printOutput(lines.join('\n'), all);
1717
- } finally {
1718
- await cdp.send('Runtime.evaluate', { expression: 'delete window.__pwReplListenersOf' }).catch(() => {});
1719
- await cdp.send('Runtime.releaseObjectGroup', { objectGroup: 'pw-repl-listeners' }).catch(() => {});
1720
- await cdp.detach().catch(() => {});
1721
- }
1722
- },
1723
-
1724
- async count(args) {
1725
- const selector = soleSelector(args, 'count <selector>');
1726
- const els = await state.page.$$(selector);
1727
- out.log(`${els.length} element(s)`);
1728
- },
1729
-
1730
- async visible(args) {
1731
- const selector = soleSelector(args, 'visible <selector>');
1732
- const el = await state.page.$(selector);
1733
- if (!el) { out.log('Not found'); return; }
1734
- out.log(await el.isVisible() ? 'visible' : 'hidden');
1735
- },
1736
-
1737
- async links(_args, all) {
1738
- const links = await state.page.$$eval('a[href]', els =>
1739
- els.map(e => ({ text: e.textContent.trim(), href: e.href }))
1740
- .filter(l => l.text)
1741
- );
1742
- if (!links.length) { out.log('No links found'); return; }
1743
- printOutput(links, all);
1744
- },
1745
-
1746
- async inputs(_args, all) {
1747
- const inputs = await state.page.$$eval('input, select, textarea', els =>
1748
- els.map(e => ({
1749
- tag: e.tagName.toLowerCase(),
1750
- type: e.type || '',
1751
- name: e.name || '',
1752
- id: e.id || '',
1753
- placeholder: e.placeholder || '',
1754
- disabled: e.disabled,
1755
- autocomplete: e.autocomplete || ''
1756
- }))
1757
- );
1758
- if (!inputs.length) { out.log('No inputs found'); return; }
1759
- printOutput(inputs, all);
1760
- },
1761
-
1762
- async screenshot(args) {
1763
- const usage = 'Usage: screenshot [<ref>] [--full] [--delay|-d <seconds>] [name | --filename=<file>]\nNames may contain letters, digits, underscores, and hyphens.';
1764
- // The file first: a path may be quoted, with spaces in it.
1765
- let file = null;
1766
- const rest = (args || '').replace(/(?:^|\s)--filename(?:=|\s+)("(?:[^"\\]|\\.)*"|'[^']*'|\S+)/, (_, value) => { file = unquote(value); return ' '; });
1767
- const tokens = rest.trim().split(/\s+/).filter(Boolean);
1768
- let full = false;
1769
- let delay = 0;
1770
- let ref = null;
1771
- const names = [];
1772
- for (let i = 0; i < tokens.length; i++) {
1773
- const token = tokens[i];
1774
- // --full-page and --filename are playwright-cli's.
1775
- if (token === '--full' || token === '--full-page') { full = true; continue; }
1776
- if (token === '--filename' || token.startsWith('--filename=')) throw new Error(usage);
1777
- if (!ref && !names.length && REF.test(token)) { ref = token; continue; }
1778
- if (token === '--delay' || token === '-d') {
1779
- const value = tokens[++i];
1780
- // Capped low on purpose: a timed shot is human-in-the-loop, and a typo
1781
- // like `-d 300` would otherwise park the serialized command queue.
1782
- if (!value || !/^\d+$/.test(value) || Number(value) > SCREENSHOT_DELAY_MAX) throw new Error(`Usage: screenshot --delay <seconds> (maximum ${SCREENSHOT_DELAY_MAX})`);
1783
- delay = Number(value);
1784
- continue;
1785
- }
1786
- names.push(token);
1787
- }
1788
- if (names.length > 1 || (names[0] && !/^[a-zA-Z0-9_-]+$/.test(names[0])) || (file && names.length) || (ref && full)) throw new Error(usage);
1789
- const name = names[0] || null;
1790
- // Counted down out loud so a watcher can time a hover or menu state, and so
1791
- // the wait is distinguishable from a hung command in a tmux capture.
1792
- for (let remaining = delay; remaining > 0; remaining--) {
1793
- out.log(`${remaining}...`);
1794
- await state.page.waitForTimeout(1000);
1795
- }
1796
- // Named after the countdown so a default filename timestamps the capture.
1797
- // A file given is relative to the REPL's folder; pw-repl send makes it the sender's.
1798
- const filepath = file ? path.resolve(file) : nextScreenshotPath(name);
1799
- const type = /\.jpe?g$/i.test(filepath) ? 'jpeg' : 'png';
1800
- // Checked before the tab is brought to the front, which can move someone's view.
1801
- if (fs.existsSync(filepath)) throw new Error(`Screenshot already exists: ${filepath}`);
1802
- if (!fs.existsSync(path.dirname(filepath))) throw new Error(`No folder ${path.dirname(filepath)} to save ${path.basename(filepath)} in`);
1803
- // Chrome does not draw a tab that is not in front, and tabs open in the
1804
- // background, so the tab is brought to the front for the shot.
1805
- await state.page.bringToFront();
1806
- let image;
1807
- try {
1808
- if (ref) {
1809
- const selector = toSelector(ref);
1810
- image = await onElement(selector, () => state.page.locator(selector).screenshot({ type, timeout: 5000 }));
1811
- } else {
1812
- image = await state.page.screenshot({ fullPage: full, type });
1813
- }
1814
- } finally {
1815
- // Playwright's screenshot resets the screen size and pixel ratio it
1816
- // finds; an emulated phone's are set again.
1817
- const device = emulations.get(state.page)?.device;
1818
- if (device) {
1819
- const session = await keptSession(state.page);
1820
- await session.send('Emulation.clearDeviceMetricsOverride');
1821
- await deviceMetrics(session, device);
1822
- }
1823
- }
1824
- try {
1825
- fs.writeFileSync(filepath, image, { flag: 'wx', mode: 0o600 });
1826
- } catch (error) {
1827
- if (error.code === 'EEXIST') throw new Error(`Screenshot already exists: ${filepath}`);
1828
- throw error;
1829
- }
1830
- out.log(`Saved: ${filepath}`);
1831
- },
1832
-
1833
- async snapshot(args, all) {
1834
- let rest = (args || '').trim();
1835
- const full = /^--full(?:\s|$)/.test(rest);
1836
- if (full) rest = rest.slice(6).trim();
1837
- let needle = null;
1838
- const grep = /^--(grep|regex)(?:\s|$)/.exec(rest);
1839
- if (grep) {
1840
- needle = rest.slice(grep[0].length).trim().replace(/^(["'])(.*)\1$/, '$2').replace(/\\"/g, '"');
1841
- if (!needle) throw new Error(`Usage: snapshot [--full] --${grep[1]} <${grep[1] === 'grep' ? 'text' : 'pattern'}>`);
1842
- if (grep[1] === 'regex') needle = regexOf(needle);
1843
- rest = '';
1844
- }
1845
- // Playwright's labels are e5, or frame-prefixed like f1e5 in newer versions.
1846
- const selector = toSelector(rest);
1847
- const target = selector ? state.page.locator(selector).first() : state.page;
1848
- const raw = selector ? await onElement(selector, () => target.ariaSnapshot({ mode: 'ai', timeout: 5000 })) : await target.ariaSnapshot({ mode: 'ai', timeout: 5000 });
1849
- const text = full ? raw : compactSnapshot(raw);
1850
- if (needle !== null) {
1851
- const hits = grepSnapshot(text, needle);
1852
- if (!hits.length) { out.log(`No snapshot lines match ${needle}`); return; }
1853
- printOutput(hits.join('\n'), all);
1854
- return;
1855
- }
1856
- const lines = text.split('\n').length;
1857
- printOutput(lines > SNAPSHOT_HINT_LINES ? `${text}\n[${lines} lines; snapshot --grep <text> or snapshot <eN> shows less]` : text, all);
1858
- },
1859
-
1860
- async viewport(args) {
1861
- if (!args) { out.log(await viewportText()); return; }
1862
- const [w, h] = args.split('x').map(Number);
1863
- if (!w || !h) throw new Error('Usage: viewport <width>x<height>');
1864
- // Both set the screen size; whichever came last would win without saying so.
1865
- if (emulations.get(state.page)?.device) throw new Error('emulate mobile sets the size of the selected tab; emulate mobile off first');
1866
- await state.page.setViewportSize({ width: w, height: h });
1867
- out.log(`Viewport set to ${w}x${h}`);
1868
- },
1869
-
1870
- // Per tab, like network: the settings live in the tab's kept CDP session.
1871
- async emulate(args) {
1872
- const words = (args || '').trim().split(/\s+/).filter(Boolean);
1873
- if (!words.length) return showEmulation();
1874
- const usage = 'Usage: emulate [mobile [device] | dark | light | locale <tag> | timezone <zone>], emulate <what> off, emulate off';
1875
- const [what, ...rest] = words;
1876
- const value = rest.join(' ');
1877
- const current = emulations.get(state.page) || {};
1878
- if (what === 'off' && !rest.length) {
1879
- const was = emulatedKinds(current);
1880
- await setEmulation(state.page, {});
1881
- out.log(was.length ? `Stopped emulating in the selected tab: ${was.join(', ')}` : 'Nothing was emulated in the selected tab.');
1882
- return;
1883
- }
1884
- if (!EMULATE_KINDS.includes(what)) throw new Error(usage);
1885
- const key = { mobile: 'device', dark: 'scheme', light: 'scheme', locale: 'locale', timezone: 'timezone' }[what];
1886
- if (value === 'off') {
1887
- await setEmulation(state.page, { ...current, [key]: undefined });
1888
- out.log(`Stopped emulating ${key === 'scheme' ? 'the color scheme' : what} in the selected tab`);
1889
- // As when turned on: the page keeps what it read at load until it loads again.
1890
- 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.');
1891
- return;
1892
- }
1893
- let next;
1894
- if (what === 'mobile') {
1895
- next = deviceNamed(value || DEFAULT_DEVICE);
1896
- } else if (what === 'dark' || what === 'light') {
1897
- if (value) throw new Error(usage);
1898
- next = what;
1899
- } else if (!value || rest.length > 1) {
1900
- throw new Error(`Usage: emulate ${what} ${what === 'locale' ? '<tag>, e.g. fr-FR' : '<zone>, e.g. Asia/Tokyo'}`);
1901
- } else if (what === 'locale') {
1902
- try { next = Intl.getCanonicalLocales(value)[0]; } catch { throw new Error(`Not a locale: ${value}; e.g. fr-FR, de, pt-BR`); }
1903
- } else {
1904
- // Checked, but kept as given: Node's names can be older ones (Asia/Calcutta for Asia/Kolkata).
1905
- try { new Intl.DateTimeFormat('en-US', { timeZone: value }); } catch { throw new Error(`Not a timezone: ${value}; e.g. Asia/Tokyo, America/New_York, UTC`); }
1906
- next = value;
1907
- }
1908
- await setEmulation(state.page, { ...current, [key]: next });
1909
- const shown = what === 'mobile' ? `mobile (${deviceText(next)})` : what === 'dark' || what === 'light' ? `${what} mode` : `${what} ${next}`;
1910
- out.log(`Emulating ${shown} in the selected tab`);
1911
- // The page reads these when it loads; the rest applies at once. A blank tab has nothing to reload.
1912
- 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.');
1913
- },
1914
-
1915
- async wait(args) {
1916
- const usage = 'Usage: wait <selector> | wait text <text> | wait request <url-part|glob> | wait load, with optional [seconds]; --gone for a selector or text';
1917
- let tokens = (args || '').trim().split(/\s+/).filter(Boolean);
1918
- const gone = tokens.includes('--gone');
1919
- tokens = tokens.filter(t => t !== '--gone');
1920
- let seconds = WAIT_DEFAULT;
1921
- if (tokens.length > 1 && /^\d+$/.test(tokens[tokens.length - 1])) seconds = Number(tokens.pop());
1922
- if (!tokens.length || seconds < 1 || seconds > WAIT_MAX) throw new Error(`${usage} (1-${WAIT_MAX})`);
1923
- const timeout = seconds * 1000;
1924
- // Said plainly, not as Playwright's call log.
1925
- const plainly = message => error => { throw /Timeout \d+ms exceeded/.test(error.message) ? new Error(message) : error; };
1926
- const kind = tokens[0];
1927
- if (kind === 'load' && tokens.length === 1) {
1928
- if (gone) throw new Error(usage);
1929
- return waitForLoad(timeout);
1930
- }
1931
- if ((kind === 'text' || kind === 'request') && tokens.length > 1) {
1932
- const what = tokens.slice(1).join(' ').replace(/^(["'])(.*)\1$/, '$2').replace(/\\"/g, '"');
1933
- if (kind === 'request') {
1934
- if (gone) throw new Error(usage);
1935
- return waitForRequest(what, timeout);
1936
- }
1937
- if (gone) return waitGone(state.page.getByText(what), what, timeout);
1938
- await state.page.getByText(what).first().waitFor({ state: 'visible', timeout }).catch(plainly(`No visible text matches ${what} within ${seconds}s`));
1939
- out.log(`Visible: ${what}`);
1940
- return;
1941
- }
1942
- const selector = toSelector(unquote(tokens.join(' ')));
1943
- if (gone) return waitGone(state.page.locator(selector), tokens.join(' '), timeout);
1944
- await state.page.waitForSelector(selector, { state: 'visible', timeout }).catch(plainly(`No visible element matches ${tokens.join(' ')} within ${seconds}s`));
1945
- out.log(`Visible: ${tokens.join(' ')}`);
1946
- },
1947
-
1948
- async sleep(args) {
1949
- if (!args || !/^\d+$/.test(args) || Number(args) > 3600000) throw new Error('Usage: sleep <ms> (maximum 3600000)');
1950
- await state.page.waitForTimeout(Number(args));
1951
- // sleep 3 is easily meant as seconds.
1952
- out.log(Number(args) < 100 ? `Slept ${args}ms (sleep takes milliseconds: sleep ${args}000 is ${args}s)` : `Slept ${args}ms`);
1953
- },
1954
-
1955
- // A stopped upstream does not reach the browser as a failure: the dev proxy
1956
- // holds the request open instead of refusing it. Cutting the connection at
1957
- // the browser is what a visitor's wifi or VPN dropping looks like to the page.
1958
- async network(args) {
1959
- const words = (args || '').trim().toLowerCase().split(/\s+/).filter(Boolean);
1960
- const setting = networkChanged.get(state.page);
1961
- if (!words.length) {
1962
- out.log(`The selected tab's network is ${setting ? networkText(setting) : 'on'}.`);
1963
- if (setting) out.log(hints([['network on', 'restore it']]));
1964
- else out.log(hints([['network off', 'cut it, like dropped wifi'], ['network slow [<ms> [<kbps>]]', `slow it (default ${networkText(SLOW_DEFAULT).slice(6, -1)})`]]));
1965
- return;
1966
- }
1967
- const [how, ...rest] = words;
1968
- if ((how === 'on' || how === 'off') && !rest.length) {
1969
- await setNetwork(state.page, how === 'on' ? null : { offline: true });
1970
- out.log(`The selected tab's network is ${how}`);
1971
- return;
1972
- }
1973
- const usage = `Usage: network [on | off | slow [<latency-ms> [<kbps>]]] (latency up to ${SLOW_LATENCY_MAX}ms)`;
1974
- if (how !== 'slow' || rest.length > 2 || !rest.every(w => /^\d+$/.test(w))) throw new Error(usage);
1975
- const [latency, kbps] = rest.map(Number);
1976
- if (latency > SLOW_LATENCY_MAX || kbps === 0) throw new Error(usage);
1977
- const slow = rest.length ? { latency, down: kbps || SLOW_DEFAULT.down, up: kbps || SLOW_DEFAULT.up } : SLOW_DEFAULT;
1978
- await setNetwork(state.page, slow);
1979
- out.log(`The selected tab's network is ${networkText(slow)}`);
1980
- },
1981
-
1982
- // Fulfilled inside the browser, so the page's own code handles the fake
1983
- // exactly as a real response and the request never reaches the network.
1984
- async route(args) {
1985
- const trimmed = (args || '').trim();
1986
- if (!trimmed) return listRoutes();
1987
- // A glob may be quoted, as it is in a shell.
1988
- const off = /^off(?:\s+(\S+))?$/.exec(trimmed);
1989
- if (off) return removeRoutes(off[1] && unquote(off[1]));
1990
- 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';
1991
- const parsed = splitSelector(trimmed);
1992
- const match = /^(\S+)(?:\s+([\s\S]+))?$/.exec(parsed?.rest || '');
1993
- if (!match) throw new Error(usage);
1994
- const glob = parsed.word;
1995
- const [, how, rest] = match;
1996
- const route = routeKind(how, rest, usage);
1997
- const routes = routesFor(state.page);
1998
- const previous = routes.get(glob);
1999
- if (previous) await state.page.unroute(glob, previous.handler);
2000
- const handler = async r => {
2001
- const req = r.request();
2002
- const tag = () => { const id = requestEntries.get(req)?.id; return `${id ? `#${id} ` : ''}${req.method()} ${req.url()}`; };
2003
- try {
2004
- await route.handle(r, req, tag);
2005
- } catch (error) {
2006
- // Aborted, never continued: a request meant to be changed must not reach the page as it was.
2007
- await r.abort().catch(() => {});
2008
- out.notice(`Route failed: ${tag()} — ${error.message}; the request was aborted`);
2009
- }
2010
- };
2011
- await state.page.route(glob, handler);
2012
- routes.set(glob, { text: route.text, handler, owner: state.client });
2013
- out.log(`${previous ? 'Replaced' : 'Routed'}: ${glob} -> ${route.text}`);
2014
- },
2015
-
2016
- async requests(args) {
2017
- // --regex <pattern> takes the rest of the line.
2018
- let regex = null;
2019
- const text = (args || '').replace(/(?:^|\s)--regex(?:\s+([\s\S]*))?$/, (_, pattern) => {
2020
- if (!pattern?.trim()) throw new Error('Usage: requests [--all] [n] --regex <pattern>');
2021
- regex = regexOf(patternText(pattern.trim()));
2022
- return '';
2023
- });
2024
- const tokens = text.trim().split(/\s+/).filter(Boolean);
2025
- const everything = tokens.includes('--all');
2026
- if (everything) tokens.splice(tokens.indexOf('--all'), 1);
2027
- let count = RECENT_DEFAULT;
2028
- if (tokens.length && /^\d+$/.test(tokens[0])) count = Number(tokens.shift());
2029
- 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})`);
2030
- const filter = regex ? String(regex) : tokens[0];
2031
- const wanted = regex ? url => regex.test(url) : url => !filter || url.includes(filter);
2032
- const log = recentLogs.get(state.page) || [];
2033
- const shown = everything ? log : log.filter(e => !RECENT_HIDDEN_TYPES.has(e.type) && !e.url.startsWith('chrome-extension://'));
2034
- const matches = shown.filter(e => wanted(e.url)).slice(-count);
2035
- if (!matches.length) { out.log(filter ? `No requests matching ${filter}` : 'No requests on the selected tab'); return; }
2036
- for (const e of matches) {
2037
- const ms = e.ms === null ? '-' : `${e.ms}ms`;
2038
- out.log(`#${e.id} ${clock(e.t)} ${e.method} ${e.status} ${ms} ${e.url}`);
2039
- }
2040
- // The numbers skip what is hidden; say so, or they look like requests went missing.
2041
- const first = matches[0].id;
2042
- const last = matches[matches.length - 1].id;
2043
- // With a filter, the numbers skip what does not match too, so the count would mislead.
2044
- const hidden = everything || filter ? 0 : log.filter(e => e.id > first && e.id < last && !shown.includes(e)).length;
2045
- if (hidden) out.log(`(${hidden} hidden between these: images, fonts, stylesheets, media and extension requests; requests --all shows them)`);
2046
- },
2047
-
2048
- async body(args, all) {
2049
- const arg = (args || '').trim();
2050
- if (!arg) throw new Error('Usage: body <#> | body <url-part> (the number from requests, or the latest request whose URL contains url-part)');
2051
- const log = recentLogs.get(state.page) || [];
2052
- const match = /^#?(\d+)$/.exec(arg);
2053
- // The latest that has finished: one still pending has no body yet.
2054
- const entry = match ? log.find(e => e.id === Number(match[1])) : log.filter(e => e.url.includes(arg) && e.status !== 'pending').pop();
2055
- 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}`);
2056
- const id = entry.id;
2057
- // A fake is marked answered before the browser hands over its response.
2058
- for (let waited = 0; !entry.response && /(?:faked|patched)$/.test(entry.status) && waited < 2000; waited += 50) {
2059
- await new Promise(resolve => setTimeout(resolve, 50));
2060
- }
2061
- if (!entry.response) throw new Error(`#${id} has no response${entry.status === 'pending' ? ' yet' : ` (${entry.status})`}`);
2062
- let buffer = entry.kept;
2063
- if (!buffer) {
2064
- // Not withTimeout: its "timed out" error would disconnect the REPL, and
2065
- // reading a body cannot have changed anything.
2066
- let timer;
2067
- const late = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`did not arrive within ${BODY_TIMEOUT / 1000}s`)), BODY_TIMEOUT); });
2068
- try { buffer = await Promise.race([entry.response.body(), late]); }
2069
- // Playwright's reason ends in advice for its own API ("Read response.body() before..."), so only its first line is kept.
2070
- 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)`); }
2071
- finally { clearTimeout(timer); }
2072
- }
2073
- const type = entry.response.headers()['content-type'] || '';
2074
- out.log(`#${id} ${entry.method} ${entry.status} ${entry.url} (${type || 'no content type'}, ${buffer.length} bytes)`);
2075
- if (!buffer.length) return;
2076
- // With no content type, text is told by its bytes: an error page's "not found" often has none.
2077
- if (!TEXT_BODY.test(type) && (type || !looksLikeText(buffer))) { out.log('[binary body not shown]'); return; }
2078
- let text = buffer.toString('utf8');
2079
- if (/json/.test(type)) { try { text = JSON.stringify(JSON.parse(text), null, 2); } catch {} }
2080
- printOutput(text, all);
2081
- },
2082
-
2083
- async console(args, all) {
2084
- const tokens = (args || '').trim().split(/\s+/).filter(Boolean);
2085
- let count = RECENT_DEFAULT;
2086
- if (tokens.length && /^\d+$/.test(tokens[0])) count = Number(tokens.shift());
2087
- if (count < 1 || count > RECENT_MAX || tokens.length > 1) throw new Error(`Usage: console [n] [level | filter] (n from 1 to ${RECENT_MAX})`);
2088
- const filter = tokens[0];
2089
- const level = CONSOLE_LEVELS.indexOf(filter);
2090
- const shown = level === -1
2091
- ? e => !filter || e.type.includes(filter) || e.text.includes(filter)
2092
- : e => CONSOLE_LEVELS.indexOf(consoleLevel(e.type)) <= level;
2093
- const logs = consoleLogs.get(state.page) || [];
2094
- const matches = logs.filter(e => e.type !== 'load' && shown(e)).slice(-count);
2095
- if (!matches.length) { out.log(filter ? `No console messages matching ${filter}` : 'No console messages on the selected tab'); return; }
2096
- // Where the page loaded again, so a message from an earlier load does not read as the current one's.
2097
- // From the load the first message shown came from, if it is still kept.
2098
- let first = logs.indexOf(matches[0]);
2099
- while (first > 0 && logs[first].type !== 'load') first -= 1;
2100
- const lines = logs.filter((e, i) => i >= first && (e.type === 'load' || matches.includes(e)));
2101
- 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);
2102
- },
2103
-
2104
- async watch(args, all) {
2105
- const arg = (args || '').trim();
2106
- const watch = watches.get(state.page);
2107
- const [sub, ...flags] = arg.split(/\s+/);
2108
- const next = flags.indexOf('--next-tab');
2109
- if (sub === 'on' && next !== -1) {
2110
- const part = flags[next + 1] && !flags[next + 1].startsWith('--') ? flags[next + 1] : null;
2111
- const rest = flags.filter((f, i) => i !== next && !(part && i === next + 1));
2112
- if (!rest.every(f => f === '--changes' || f === '--live')) throw new Error('Usage: watch on [--changes] [--live] --next-tab [url-part]');
2113
- nextTab = { part, changes: rest.includes('--changes'), live: rest.includes('--live'), client: state.client };
2114
- out.log(`${waitingText()}: it is watched from its first page, and selected. watch off stops waiting.`);
2115
- return;
2116
- }
2117
- if (sub !== 'on' && arg !== 'off' && waitingForMe() && (!state.page || state.page.isClosed())) {
2118
- out.log(`${waitingText()} (watch on --next-tab); it is selected once it opens, and watch then shows its steps.`);
2119
- return;
2120
- }
2121
- if (sub === 'on' && flags.every(f => f === '--changes' || f === '--live')) {
2122
- 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');
2123
- const changes = flags.includes('--changes');
2124
- const live = flags.includes('--live');
2125
- await startWatching(state.page, changes, live);
2126
- watches.get(state.page).owner = state.client;
2127
- 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)`);
2128
- 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.');
2129
- return;
2130
- }
2131
- if (arg === 'off') {
2132
- if (nextTab) { nextTab = null; out.log('Stopped waiting to watch a new tab'); }
2133
- if (!state.page || state.page.isClosed()) return;
2134
- if (!watch?.on) { out.log('Not watching the selected tab.'); return; }
2135
- stopWatching(state.page, watch);
2136
- const steps = watch.events.length;
2137
- out.log(`Stopped watching the selected tab${steps ? `; ${steps} step${steps === 1 ? '' : 's'} recorded (watch shows them)` : ''}`);
2138
- return;
2139
- }
2140
- if (!arg) return showWatch(watch, all);
2141
- const usage = `Usage: watch on [--changes] [--live] | watch off | watch [n] | watch new (n from 1 to ${WATCH_MAX})`;
2142
- const onlyNew = arg === 'new';
2143
- if (arg && !onlyNew && !/^\d+$/.test(arg)) throw new Error(usage);
2144
- const count = onlyNew ? RECENT_DEFAULT : Number(arg);
2145
- if (count < 1 || count > WATCH_MAX) throw new Error(usage);
2146
- if (!watch || !watch.events.length) { out.log(watch?.on ? 'Watching; nothing has happened yet' : 'Not watching the selected tab; watch on starts it'); return; }
2147
- const causedBy = requestsByStep(watch);
2148
- const lines = [];
2149
- const printStep = (e, caused, note = '') => {
2150
- lines.push(...stepLines(e, caused, note, !(onlyNew && e.changesShown)));
2151
- if (onlyNew && e.changes) e.changesShown = true;
2152
- };
2153
- let events;
2154
- if (onlyNew) {
2155
- // Requests and changes can arrive after their step was read; they are shown under it again.
2156
- const lastShown = watch.events.find(e => e.seq === watch.shownSeq);
2157
- const late = lastShown ? causedBy(lastShown).filter(r => r.id > watch.shownRequestId) : [];
2158
- const lateChanges = lastShown?.changes && !lastShown.changesShown;
2159
- if (late.length || lateChanges) printStep(lastShown, late, ' (continued)');
2160
- events = watch.events.filter(e => e.seq > watch.shownSeq);
2161
- if (!events.length && !late.length && !lateChanges) lines.push('No new steps');
2162
- } else {
2163
- events = watch.events.slice(-count);
2164
- }
2165
- for (const e of events) printStep(e, causedBy(e));
2166
- // Only watch new keeps track of what has been read.
2167
- if (onlyNew) {
2168
- const last = watch.events[watch.events.length - 1];
2169
- watch.shownSeq = last.seq;
2170
- watch.shownRequestId = Math.max(watch.shownRequestId, ...causedBy(last).map(r => r.id));
2171
- }
2172
- if (!watch.on) lines.push('[watch is off]');
2173
- printOutput(lines.join('\n'), all);
2174
- },
2175
-
2176
- async eval(args, all) {
2177
- if (!args) throw new Error('Usage: eval <js-expression>');
2178
- // eval <function> <ref> calls the function with that element, as playwright-cli's eval does.
2179
- const onRef = /^([\s\S]+?)\s+((?:f\d+)?e\d+)$/.exec(args.trim());
2180
- if (onRef && isFunctionText(unquote(onRef[1]))) {
2181
- const selector = toSelector(onRef[2]);
2182
- const el = await onElement(selector, () => state.page.locator(selector).first().elementHandle({ timeout: 5000 }));
2183
- try {
2184
- // Made from its text by the debugger, as eval is, so a page's CSP cannot
2185
- // refuse it, and in the element's own frame, which may be an iframe.
2186
- const fn = await withTimeout((await el.ownerFrame()).evaluateHandle(unquote(onRef[1])), 'Page evaluation', COMMAND_TIMEOUT);
2187
- try {
2188
- printOutput(await el.evaluate((e, f) => f(e), fn), all);
2189
- } finally {
2190
- await fn.dispose().catch(() => {});
2191
- }
2192
- } finally {
2193
- await el.dispose().catch(() => {});
2194
- }
2195
- return;
2196
- }
2197
- // A function, as playwright-cli's eval takes, is called rather than returned.
2198
- // Only a single expression can be one; statements are evaluated as they are.
2199
- // await at the top level: one expression is run as an async function; statements as DevTools'
2200
- // console runs them (replMode), which gives back the last one's value.
2201
- const compiles = (Kind, body) => { try { new Kind(body); return true; } catch { return false; } };
2202
- const AsyncFunction = (async () => {}).constructor;
2203
- const awaits = /\bawait\b/.test(args) && !compiles(Function, `return (${args}\n);`);
2204
- const single = compiles(awaits ? AsyncFunction : Function, `return (${args}\n);`);
2205
- const call = `const value = (${args}\n); return typeof value === 'function' ? value() : value;`;
2206
- if (awaits && !single && compiles(AsyncFunction, args)) {
2207
- const session = await keptSession(state.page);
2208
- const { result, exceptionDetails } = await withTimeout(session.send('Runtime.evaluate', { expression: args, replMode: true, awaitPromise: true, returnByValue: true }), 'Page evaluation', COMMAND_TIMEOUT);
2209
- if (exceptionDetails) throw new Error(exceptionDetails.exception?.description || exceptionDetails.text);
2210
- printOutput(result.value !== undefined ? result.value : result.description ?? 'undefined', all);
2211
- return;
2212
- }
2213
- const expression = single ? `(${awaits ? 'async ' : ''}() => { ${call} })()` : args;
2214
- const value = await withTimeout(state.page.evaluate(expression), 'Page evaluation', COMMAND_TIMEOUT);
2215
- // An empty string is shown as one, not as no output at all.
2216
- printOutput(value === '' ? '""' : value, all);
2217
- },
2218
-
2219
- async cdp(args, all) {
2220
- const match = /^(\S+)\s+([\s\S]+)$/.exec(args || '');
2221
- if (!match) throw new Error('Usage: cdp <method> <JSON object>');
2222
- if (['Browser.close', 'Target.closeTarget'].includes(match[1])) throw new Error('Browser lifecycle commands are reserved; use quit or tab close.');
2223
- const params = JSON.parse(match[2]);
2224
- if (!params || Array.isArray(params) || typeof params !== 'object') throw new Error('Parameters must be a JSON object');
2225
- const operation = async () => {
2226
- const cdp = await state.page.context().newCDPSession(state.page);
2227
- try {
2228
- return await cdp.send(match[1], params);
2229
- } finally {
2230
- await cdp.detach().catch(() => {});
2231
- }
2232
- };
2233
- printOutput(await withTimeout(operation(), 'CDP operation', COMMAND_TIMEOUT), all);
2234
- },
2235
-
2236
- async cookies(_args, all) {
2237
- const cookies = await state.page.context().cookies();
2238
- if (!cookies.length) { out.log('No cookies'); return; }
2239
- printOutput(cookies.map(({ name, domain, path, expires, httpOnly, secure, sameSite }) =>
2240
- ({ name, domain, path, expires, httpOnly, secure, sameSite })), all);
2241
- },
2242
-
2243
- async storage(_args, all) {
2244
- const keys = await state.page.evaluate(() =>
2245
- Array.from({ length: localStorage.length }, (_, i) => localStorage.key(i))
2246
- );
2247
- if (!keys.length) { out.log('localStorage is empty'); return; }
2248
- printOutput(keys, all);
2249
- },
2250
-
2251
- async capture(args, all) {
2252
- const [sub, ...rest] = (args || '').trim().split(/\s+/).filter(Boolean);
2253
- if (sub === 'on') return startCapture(rest);
2254
- if (sub === 'off' && !rest.length) return stopCapture(all);
2255
- if (sub) throw new Error('Usage: capture on [requests|console] [seconds] | capture off');
2256
- if (cap) {
2257
- out.log(`Capturing ${cap.label} on ${cap.page.url()} since ${clock(cap.startedAt)} (${cap.events.length} so far)`);
2258
- out.log(hints([['capture off', 'stop and print what it recorded']]));
2259
- return;
2260
- }
2261
- if (lastCapture) {
2262
- out.log(`Not capturing. The last capture, of ${lastCapture.label} from ${clock(lastCapture.startedAt)}:`);
2263
- printCapture(lastCapture, all);
2264
- } else {
2265
- out.log('Not capturing.');
2266
- }
2267
- out.log(hints([['capture on [requests|console] [seconds]', 'record the selected tab\'s requests and console messages in time order']]));
2268
- },
2269
-
2270
- async dialog(args) {
2271
- out.log(await dialogCommand(args));
2272
- },
2273
-
2274
- async modes(args) {
2275
- const arg = (args || '').trim();
2276
- if (!arg) return listModes();
2277
- if (arg !== 'off' && arg !== 'off --mine') throw new Error('Usage: modes [off [--mine]]');
2278
- await allModesOff(arg === 'off --mine');
2279
- },
21
+ ...tabsCommands,
22
+ ...elementsCommands,
23
+ ...inspectCommands,
24
+ ...watchCommands,
25
+ ...requestlogCommands,
26
+ ...routesCommands,
27
+ ...networkCommands,
28
+ ...emulationCommands,
29
+ ...captureCommands,
30
+ ...dialogsCommands,
31
+ ...modesCommands,
2280
32
 
2281
33
  async help(args) {
2282
34
  const topic = (args || '').trim();
@@ -2318,19 +70,16 @@ const cliCommands = {
2318
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.');
2319
71
  }
2320
72
  await allModesOff(true);
2321
- 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);
2322
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.`);
2323
75
  },
2324
76
  };
2325
77
 
2326
- onShutdown(discardCapture);
2327
- onShutdown(resetEmulations);
2328
-
2329
- // Hooks every page needs from the moment the REPL sees it.
2330
- 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) {
2331
80
  ensureDialogHandler(p);
2332
- ensureRecentLog(p);
2333
- if (nextTab && !opening) watchNewTab(p);
81
+ ensureRecentLog(p, opened);
82
+ if (nextTabWanted() && !isOpening()) watchNewTab(p);
2334
83
  }
2335
84
 
2336
85
  // watch on --next-tab waits for a tab, and watch off stops waiting, with none selected;
@@ -2370,4 +119,4 @@ function complete(line) {
2370
119
  return [[], current];
2371
120
  }
2372
121
 
2373
- 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 };