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/inspect.js ADDED
@@ -0,0 +1,460 @@
1
+ // Reading the page: text, attributes, snapshots, screenshots, eval and CDP, cookies and storage, and waits.
2
+ const path = require('path');
3
+ const fs = require('fs');
4
+ const { state, withTimeout } = require('./state');
5
+ const out = require('./output');
6
+ const { printOutput } = out;
7
+ const { devices } = require('playwright-core');
8
+ const { toSelector, REF, unquote } = require('./syntax');
9
+ const { regexOf, COMMAND_TIMEOUT } = require('./util');
10
+ const { tabState } = require('./tabstate');
11
+ const { keptSession } = require('./cdp');
12
+ const { onElement, soleSelector } = require('./elements');
13
+ const { waitForLoad, waitForRequest } = require('./requestlog');
14
+ const { deviceMetrics } = require('./emulation');
15
+
16
+ const SCREENSHOT_DELAY_MAX = 60;
17
+
18
+ function nextScreenshotPath(name) {
19
+ const filename = name ? `screenshot-${name}` : `screenshot-${Date.now()}`;
20
+ // A REPL on a socket of its own keeps its screenshots next to it, as its log is.
21
+ return path.join(process.env.PW_SCREENSHOT_DIR || state.ownDir || '/tmp', `${filename}.png`);
22
+ }
23
+
24
+ // A screenshot of an emulated phone, taken by Chrome as it draws it. Playwright's own sizes the page
25
+ // again for the shot, and a page with no viewport meta tag, laid out wider than the phone and shown
26
+ // shrunk, comes out drawn small in a corner of a mostly blank image. One image pixel per CSS pixel.
27
+ async function deviceShot(device, { ref, full, type }) {
28
+ const session = await keptSession(state.page);
29
+ let clip = null;
30
+ if (ref) {
31
+ const selector = toSelector(ref);
32
+ const box = await onElement(selector, async () => {
33
+ const locator = state.page.locator(selector);
34
+ await locator.scrollIntoViewIfNeeded({ timeout: 5000 });
35
+ return locator.evaluate(el => { const r = el.getBoundingClientRect(); return { x: r.x + scrollX, y: r.y + scrollY, width: r.width, height: r.height }; });
36
+ });
37
+ clip = box;
38
+ }
39
+ // Measured after scrolling to the element, if any.
40
+ const { cssVisualViewport: view, cssContentSize: content } = await session.send('Page.getLayoutMetrics');
41
+ // At the size the phone shows it: a page laid out wider than the phone is shown shrunk (view.scale).
42
+ const scale = view.scale / devices[device].deviceScaleFactor;
43
+ if (clip) clip = { ...clip, scale };
44
+ if (full) clip = { x: 0, y: 0, width: content.width, height: content.height, scale };
45
+ if (!clip) clip = { x: view.pageX, y: view.pageY, width: view.clientWidth, height: view.clientHeight, scale };
46
+ // Beyond the visible area, Chrome lays the page out again at its whole size for the shot, which
47
+ // someone looking at it would see move: only for --full, and an element that does not fit.
48
+ const inView = clip.x >= view.pageX && clip.y >= view.pageY && clip.x + clip.width <= view.pageX + view.clientWidth && clip.y + clip.height <= view.pageY + view.clientHeight;
49
+ const { data } = await session.send('Page.captureScreenshot', { format: type, clip, captureBeyondViewport: full || (!!ref && !inView) });
50
+ return Buffer.from(data, 'base64');
51
+ }
52
+
53
+ // Unnamed generic nodes are layout wrappers (mostly divs): they add depth and
54
+ // nothing to read. Drop them, lift their children, and drop cursor hints.
55
+ function compactSnapshot(text) {
56
+ const dropped = [];
57
+ const lines = [];
58
+ for (const line of text.split('\n')) {
59
+ const indent = line.length - line.trimStart().length;
60
+ while (dropped.length && dropped[dropped.length - 1] >= indent) dropped.pop();
61
+ if (/^- generic(?: \[[^\]]+\])*:?$/.test(line.trim())) { dropped.push(indent); continue; }
62
+ lines.push(' '.repeat(Math.max(0, indent - 2 * dropped.length)) + line.trimStart().replace(/ \[cursor=pointer\]/g, ''));
63
+ }
64
+ return lines.join('\n');
65
+ }
66
+
67
+ const WAIT_DEFAULT = 10;
68
+ const WAIT_MAX = 120;
69
+
70
+ // Gone once no match is visible, whether it was removed or hidden.
71
+ async function waitGone(locator, what, timeout) {
72
+ try {
73
+ await locator.filter({ visible: true }).first().waitFor({ state: 'detached', timeout });
74
+ } catch (error) {
75
+ if (/Timeout \d+ms exceeded/.test(error.message)) throw new Error(`Still visible after ${timeout / 1000}s: ${what}`);
76
+ throw error;
77
+ }
78
+ out.log(`Gone: ${what}`);
79
+ }
80
+
81
+ const SNAPSHOT_HINT_LINES = 60;
82
+
83
+ // Each matching line (any part of it: role, name, flags such as [disabled]),
84
+ // with the named elements it sits in, so a match can be placed without the
85
+ // rest of the tree.
86
+ // needle: text to find in any case, or a RegExp.
87
+ function grepSnapshot(text, needle) {
88
+ const lower = typeof needle === 'string' ? needle.toLowerCase() : null;
89
+ const matches = line => (lower === null ? needle.test(line) : line.toLowerCase().includes(lower));
90
+ const stack = [];
91
+ const hits = [];
92
+ const label = line => line.trim().replace(/^- /, '').replace(/:$/, '').replace(/^'(.*)'$/, '$1');
93
+ for (const line of text.split('\n')) {
94
+ const indent = line.length - line.trimStart().length;
95
+ while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();
96
+ if (matches(line)) {
97
+ const around = stack.filter(a => !/^generic(?: \[|$)/.test(a.label));
98
+ // A hit with no ref of its own (text) keeps the ref of what holds it, so
99
+ // snapshot <ref> can show what sits beside it: the value next to a label.
100
+ const own = /\[ref=/.test(line);
101
+ const path = around.map((a, i) => (!own && i === around.length - 1 ? a.label : a.label.replace(/ \[ref=[^\]]+\]/g, '')));
102
+ hits.push([...path, label(line)].join(' › '));
103
+ }
104
+ stack.push({ indent, label: label(line) });
105
+ }
106
+ return hits;
107
+ }
108
+
109
+ // A function written out (el => ..., function (el) {...}), as one expression.
110
+ function isFunctionText(text) {
111
+ // Wrapped in parentheses, as (el => el.id) is often written: only when the first one closes at the end.
112
+ const wrapped = /^\s*\(([\s\S]*)\)\s*$/.exec(text);
113
+ if (wrapped && closesAtEnd(text.trim()) && isFunctionText(wrapped[1])) return true;
114
+ if (!/^\s*(?:async\s+)?(?:function\b|\([^)]*\)\s*=>|[\w$]+\s*=>)/.test(text)) return false;
115
+ try { new Function(`return (${text}\n);`); return true; } catch { return false; }
116
+ }
117
+
118
+ // Whether the opening parenthesis is matched by the last character, not earlier: (a)(b) is a call.
119
+ function closesAtEnd(text) {
120
+ let depth = 0;
121
+ for (let i = 0; i < text.length; i += 1) {
122
+ if (text[i] === '(') depth += 1;
123
+ else if (text[i] === ')') { depth -= 1; if (depth === 0) return i === text.length - 1; }
124
+ }
125
+ return false;
126
+ }
127
+
128
+ const commands = {
129
+ async text(args, all) {
130
+ const selector = soleSelector(args, 'text <selector>');
131
+ printOutput(await onElement(selector, () => state.page.innerText(selector, { timeout: 5000 })), all);
132
+ },
133
+
134
+ async html(args, all) {
135
+ const selector = soleSelector(args, 'html <selector>');
136
+ printOutput(await onElement(selector, () => state.page.$eval(selector, el => el.outerHTML)), all);
137
+ },
138
+
139
+ async attrs(args, all) {
140
+ const selector = soleSelector(args, 'attrs <selector>');
141
+ const result = await onElement(selector, () => state.page.$eval(selector, el => {
142
+ const out = {};
143
+ for (const attr of el.attributes) out[attr.name] = attr.value;
144
+ return out;
145
+ }));
146
+ printOutput(result, all);
147
+ },
148
+
149
+ // The page's own event listeners on an element, which only DevTools can
150
+ // list: the element is handed to a CDP session through the page, so any
151
+ // Playwright selector works, and the session asks for its listeners.
152
+ async listeners(args, all) {
153
+ const text = (args || '').trim();
154
+ const usage = 'listeners <selector> | listeners document | listeners window';
155
+ if (!text) throw new Error(`Usage: ${usage}`);
156
+ const which = text === 'document' || text === 'window' ? text : null;
157
+ const selector = which ? null : soleSelector(args, usage);
158
+ if (selector) await onElement(selector, () => state.page.locator(selector).first().evaluate(el => { window.__pwReplListenersOf = el; }, null, { timeout: 5000 }));
159
+ const cdp = await state.page.context().newCDPSession(state.page);
160
+ try {
161
+ // Chrome includes each handler's source only for an object in a named group.
162
+ const { result } = await cdp.send('Runtime.evaluate', { expression: which || 'window.__pwReplListenersOf', objectGroup: 'pw-repl-listeners' });
163
+ if (!result.objectId) throw new Error(`No element matches ${text}`);
164
+ const { listeners: found } = await cdp.send('DOMDebugger.getEventListeners', { objectId: result.objectId });
165
+ // Playwright adds its own to window once it has acted on the page; they are not the page's.
166
+ // Told by the script they come from, the one its hit-target check is in.
167
+ const own = new Set(found.filter(l => /^__playwright_/.test(l.type) || /_hitTargetInterceptor/.test(l.handler?.description || '')).map(l => l.scriptId));
168
+ const listeners = found.filter(l => !own.has(l.scriptId));
169
+ const note = found.length > listeners.length ? ` (${found.length - listeners.length} of Playwright's own left out)` : '';
170
+ if (!listeners.length) { out.log(`No event listeners on ${text}${note}`); return; }
171
+ const lines = listeners.map(l => {
172
+ const how = [l.useCapture && 'capture', l.once && 'once', l.passive && 'passive'].filter(Boolean).join(', ');
173
+ const source = l.handler?.description || '';
174
+ // Whole, as written, with its lines indented; or its start on one line, where ↵ marks each
175
+ // line break, so a // comment is seen to end there.
176
+ const text = all
177
+ ? source.split('\n').map((line, i) => (i ? ` ${line}` : line)).join('\n')
178
+ : source.replace(/[ \t]*\r?\n\s*/g, ' ↵ ').replace(/[ \t]+/g, ' ');
179
+ const handler = all || text.length <= 100 ? text : `${text.slice(0, 100)}…`;
180
+ return `${l.type}${how ? ` (${how})` : ''}: ${handler} (line ${l.lineNumber + 1})`;
181
+ });
182
+ printOutput(lines.join('\n'), all);
183
+ if (note) out.log(note.trim());
184
+ } finally {
185
+ await cdp.send('Runtime.evaluate', { expression: 'delete window.__pwReplListenersOf' }).catch(() => {});
186
+ await cdp.send('Runtime.releaseObjectGroup', { objectGroup: 'pw-repl-listeners' }).catch(() => {});
187
+ await cdp.detach().catch(() => {});
188
+ }
189
+ },
190
+
191
+ async count(args) {
192
+ const selector = soleSelector(args, 'count <selector>');
193
+ const els = await state.page.$$(selector);
194
+ out.log(`${els.length} element(s)`);
195
+ },
196
+
197
+ async visible(args) {
198
+ const selector = soleSelector(args, 'visible <selector>');
199
+ const el = await state.page.$(selector);
200
+ if (!el) { out.log('Not found'); return; }
201
+ out.log(await el.isVisible() ? 'visible' : 'hidden');
202
+ },
203
+
204
+ async links(_args, all) {
205
+ const links = await state.page.$$eval('a[href]', els =>
206
+ els.map(e => ({ text: e.textContent.trim(), href: e.href }))
207
+ .filter(l => l.text)
208
+ );
209
+ if (!links.length) { out.log('No links found'); return; }
210
+ printOutput(links, all);
211
+ },
212
+
213
+ async inputs(_args, all) {
214
+ const inputs = await state.page.$$eval('input, select, textarea', els =>
215
+ els.map(e => ({
216
+ tag: e.tagName.toLowerCase(),
217
+ type: e.type || '',
218
+ name: e.name || '',
219
+ id: e.id || '',
220
+ placeholder: e.placeholder || '',
221
+ disabled: e.disabled,
222
+ autocomplete: e.autocomplete || ''
223
+ }))
224
+ );
225
+ if (!inputs.length) { out.log('No inputs found'); return; }
226
+ printOutput(inputs, all);
227
+ },
228
+
229
+ async screenshot(args) {
230
+ const usage = 'Usage: screenshot [<ref>] [--full] [--delay|-d <seconds>] [name | --filename=<file>]\nNames may contain letters, digits, underscores, and hyphens.';
231
+ // The file first: a path may be quoted, with spaces in it.
232
+ let file = null;
233
+ const rest = (args || '').replace(/(?:^|\s)--filename(?:=|\s+)("(?:[^"\\]|\\.)*"|'[^']*'|\S+)/, (_, value) => { file = unquote(value); return ' '; });
234
+ const tokens = rest.trim().split(/\s+/).filter(Boolean);
235
+ let full = false;
236
+ let delay = 0;
237
+ let ref = null;
238
+ const names = [];
239
+ for (let i = 0; i < tokens.length; i++) {
240
+ const token = tokens[i];
241
+ // --full-page and --filename are playwright-cli's.
242
+ if (token === '--full' || token === '--full-page') { full = true; continue; }
243
+ if (token === '--filename' || token.startsWith('--filename=')) throw new Error(usage);
244
+ if (!ref && !names.length && REF.test(token)) { ref = token; continue; }
245
+ if (token === '--delay' || token === '-d') {
246
+ const value = tokens[++i];
247
+ // Capped low on purpose: a timed shot is human-in-the-loop, and a typo
248
+ // like `-d 300` would otherwise park the serialized command queue.
249
+ if (!value || !/^\d+$/.test(value) || Number(value) > SCREENSHOT_DELAY_MAX) throw new Error(`Usage: screenshot --delay <seconds> (maximum ${SCREENSHOT_DELAY_MAX})`);
250
+ delay = Number(value);
251
+ continue;
252
+ }
253
+ names.push(token);
254
+ }
255
+ if (names.length > 1 || (names[0] && !/^[a-zA-Z0-9_-]+$/.test(names[0])) || (file && names.length) || (ref && full)) throw new Error(usage);
256
+ const name = names[0] || null;
257
+ // Counted down out loud so a watcher can time a hover or menu state, and so
258
+ // the wait is distinguishable from a hung command in a tmux capture.
259
+ for (let remaining = delay; remaining > 0; remaining--) {
260
+ out.log(`${remaining}...`);
261
+ await state.page.waitForTimeout(1000);
262
+ }
263
+ // Named after the countdown so a default filename timestamps the capture.
264
+ // A file given is relative to the REPL's folder; pw-repl send makes it the sender's.
265
+ const filepath = file ? path.resolve(file) : nextScreenshotPath(name);
266
+ const type = /\.jpe?g$/i.test(filepath) ? 'jpeg' : 'png';
267
+ // Checked before the tab is brought to the front, which can move someone's view.
268
+ if (fs.existsSync(filepath)) throw new Error(`Screenshot already exists: ${filepath}`);
269
+ if (!fs.existsSync(path.dirname(filepath))) throw new Error(`No folder ${path.dirname(filepath)} to save ${path.basename(filepath)} in`);
270
+ // Chrome does not draw a tab that is not in front, and tabs open in the
271
+ // background, so the tab is brought to the front for the shot.
272
+ await state.page.bringToFront();
273
+ let image;
274
+ const device = tabState(state.page).emulation?.device;
275
+ try {
276
+ if (device) {
277
+ image = await deviceShot(device, { ref, full, type });
278
+ } else if (ref) {
279
+ const selector = toSelector(ref);
280
+ image = await onElement(selector, () => state.page.locator(selector).screenshot({ type, timeout: 5000 }));
281
+ } else {
282
+ image = await state.page.screenshot({ fullPage: full, type });
283
+ }
284
+ } finally {
285
+ // Playwright's screenshot resets the screen size and pixel ratio it
286
+ // finds; an emulated phone's are set again.
287
+ if (device) {
288
+ const session = await keptSession(state.page);
289
+ await session.send('Emulation.clearDeviceMetricsOverride');
290
+ await deviceMetrics(session, device);
291
+ }
292
+ }
293
+ try {
294
+ fs.writeFileSync(filepath, image, { flag: 'wx', mode: 0o600 });
295
+ } catch (error) {
296
+ if (error.code === 'EEXIST') throw new Error(`Screenshot already exists: ${filepath}`);
297
+ throw error;
298
+ }
299
+ out.log(`Saved: ${filepath}`);
300
+ },
301
+
302
+ async snapshot(args, all) {
303
+ let rest = (args || '').trim();
304
+ const full = /^--full(?:\s|$)/.test(rest);
305
+ if (full) rest = rest.slice(6).trim();
306
+ let needle = null;
307
+ const grep = /^--(grep|regex)(?:\s|$)/.exec(rest);
308
+ if (grep) {
309
+ needle = rest.slice(grep[0].length).trim().replace(/^(["'])(.*)\1$/, '$2').replace(/\\"/g, '"');
310
+ if (!needle) throw new Error(`Usage: snapshot [--full] --${grep[1]} <${grep[1] === 'grep' ? 'text' : 'pattern'}>`);
311
+ if (grep[1] === 'regex') needle = regexOf(needle);
312
+ rest = '';
313
+ }
314
+ // Playwright's labels are e5, or frame-prefixed like f1e5 in newer versions.
315
+ const selector = toSelector(rest);
316
+ let raw;
317
+ // A snapshot of one element gives the page new refs, and those of the last whole-page snapshot
318
+ // stop working; so a ref's part is cut from a whole-page snapshot, which keeps them.
319
+ if (REF.test(rest)) {
320
+ const lines = (await state.page.ariaSnapshot({ mode: 'ai', timeout: 5000 })).split('\n');
321
+ const at = lines.findIndex(line => line.includes(`[ref=${rest}]`));
322
+ if (at === -1) throw new Error(`No element matches ${rest}\n${rest} is a snapshot ref; if the page changed since that snapshot, take a new one.`);
323
+ const indent = line => line.length - line.trimStart().length;
324
+ const end = lines.findIndex((line, i) => i > at && indent(line) <= indent(lines[at]));
325
+ raw = lines.slice(at, end === -1 ? undefined : end).map(line => line.slice(indent(lines[at]))).join('\n');
326
+ } else if (selector) {
327
+ raw = await onElement(selector, () => state.page.locator(selector).first().ariaSnapshot({ mode: 'ai', timeout: 5000 }));
328
+ } else {
329
+ raw = await state.page.ariaSnapshot({ mode: 'ai', timeout: 5000 });
330
+ }
331
+ const text = full ? raw : compactSnapshot(raw);
332
+ if (needle !== null) {
333
+ const hits = grepSnapshot(text, needle);
334
+ if (!hits.length) { out.log(`No snapshot lines match ${needle}`); return; }
335
+ printOutput(hits.join('\n'), all);
336
+ return;
337
+ }
338
+ const lines = text.split('\n').length;
339
+ printOutput(lines > SNAPSHOT_HINT_LINES ? `${text}\n[${lines} lines; snapshot --grep <text> or snapshot <eN> shows less]` : text, all);
340
+ },
341
+
342
+ async wait(args) {
343
+ const usage = 'Usage: wait <selector> | wait text <text> | wait request <url-part|glob> | wait load, with optional [seconds]; --gone for a selector or text';
344
+ let tokens = (args || '').trim().split(/\s+/).filter(Boolean);
345
+ const gone = tokens.includes('--gone');
346
+ tokens = tokens.filter(t => t !== '--gone');
347
+ let seconds = WAIT_DEFAULT;
348
+ // A number last is the seconds, unless it is all there is to wait for: wait text 990.
349
+ const onlyWhat = (tokens[0] === 'text' || tokens[0] === 'request') && tokens.length === 2;
350
+ if (tokens.length > 1 && !onlyWhat && /^\d+$/.test(tokens[tokens.length - 1])) seconds = Number(tokens.pop());
351
+ if (!tokens.length || seconds < 1 || seconds > WAIT_MAX) throw new Error(`${usage} (1-${WAIT_MAX})`);
352
+ const timeout = seconds * 1000;
353
+ // Said plainly, not as Playwright's call log.
354
+ const plainly = message => error => { throw /Timeout \d+ms exceeded/.test(error.message) ? new Error(message) : error; };
355
+ const kind = tokens[0];
356
+ if (kind === 'load' && tokens.length === 1) {
357
+ if (gone) throw new Error(usage);
358
+ return waitForLoad(timeout);
359
+ }
360
+ if ((kind === 'text' || kind === 'request') && tokens.length > 1) {
361
+ const what = tokens.slice(1).join(' ').replace(/^(["'])(.*)\1$/, '$2').replace(/\\"/g, '"');
362
+ if (kind === 'request') {
363
+ if (gone) throw new Error(usage);
364
+ return waitForRequest(what, timeout);
365
+ }
366
+ if (gone) return waitGone(state.page.getByText(what), what, timeout);
367
+ await state.page.getByText(what).first().waitFor({ state: 'visible', timeout }).catch(plainly(`No visible text matches ${what} within ${seconds}s`));
368
+ out.log(`Visible: ${what}`);
369
+ return;
370
+ }
371
+ const selector = toSelector(unquote(tokens.join(' ')));
372
+ if (gone) return waitGone(state.page.locator(selector), tokens.join(' '), timeout);
373
+ await state.page.waitForSelector(selector, { state: 'visible', timeout }).catch(plainly(`No visible element matches ${tokens.join(' ')} within ${seconds}s`));
374
+ out.log(`Visible: ${tokens.join(' ')}`);
375
+ },
376
+
377
+ async sleep(args) {
378
+ if (!args || !/^\d+$/.test(args) || Number(args) > 3600000) throw new Error('Usage: sleep <ms> (maximum 3600000)');
379
+ await state.page.waitForTimeout(Number(args));
380
+ // sleep 3 is easily meant as seconds.
381
+ out.log(Number(args) < 100 ? `Slept ${args}ms (sleep takes milliseconds: sleep ${args}000 is ${args}s)` : `Slept ${args}ms`);
382
+ },
383
+
384
+ async eval(args, all) {
385
+ if (!args) throw new Error('Usage: eval <js-expression>');
386
+ // eval <function> <ref> calls the function with that element, as playwright-cli's eval does.
387
+ const onRef = /^([\s\S]+?)\s+((?:f\d+)?e\d+)$/.exec(args.trim());
388
+ if (onRef && isFunctionText(unquote(onRef[1]))) {
389
+ const selector = toSelector(onRef[2]);
390
+ const el = await onElement(selector, () => state.page.locator(selector).first().elementHandle({ timeout: 5000 }));
391
+ try {
392
+ // Made from its text by the debugger, as eval is, so a page's CSP cannot
393
+ // refuse it, and in the element's own frame, which may be an iframe.
394
+ const fn = await withTimeout((await el.ownerFrame()).evaluateHandle(unquote(onRef[1])), 'Page evaluation', COMMAND_TIMEOUT);
395
+ try {
396
+ printOutput(await el.evaluate((e, f) => f(e), fn), all);
397
+ } finally {
398
+ await fn.dispose().catch(() => {});
399
+ }
400
+ } finally {
401
+ await el.dispose().catch(() => {});
402
+ }
403
+ return;
404
+ }
405
+ // A function, as playwright-cli's eval takes, is called rather than returned.
406
+ // Only a single expression can be one; statements are evaluated as they are.
407
+ // await at the top level: one expression is run as an async function; statements as DevTools'
408
+ // console runs them (replMode), which gives back the last one's value.
409
+ const compiles = (Kind, body) => { try { new Kind(body); return true; } catch { return false; } };
410
+ const AsyncFunction = (async () => {}).constructor;
411
+ const awaits = /\bawait\b/.test(args) && !compiles(Function, `return (${args}\n);`);
412
+ const single = compiles(awaits ? AsyncFunction : Function, `return (${args}\n);`);
413
+ const call = `const value = (${args}\n); return typeof value === 'function' ? value() : value;`;
414
+ if (awaits && !single && compiles(AsyncFunction, args)) {
415
+ const session = await keptSession(state.page);
416
+ const { result, exceptionDetails } = await withTimeout(session.send('Runtime.evaluate', { expression: args, replMode: true, awaitPromise: true, returnByValue: true }), 'Page evaluation', COMMAND_TIMEOUT);
417
+ if (exceptionDetails) throw new Error(exceptionDetails.exception?.description || exceptionDetails.text);
418
+ printOutput(result.value !== undefined ? result.value : result.description ?? 'undefined', all);
419
+ return;
420
+ }
421
+ const expression = single ? `(${awaits ? 'async ' : ''}() => { ${call} })()` : args;
422
+ const value = await withTimeout(state.page.evaluate(expression), 'Page evaluation', COMMAND_TIMEOUT);
423
+ // An empty string is shown as one, not as no output at all.
424
+ printOutput(value === '' ? '""' : value, all);
425
+ },
426
+
427
+ async cdp(args, all) {
428
+ const match = /^(\S+)\s+([\s\S]+)$/.exec(args || '');
429
+ if (!match) throw new Error('Usage: cdp <method> <JSON object>');
430
+ if (['Browser.close', 'Target.closeTarget'].includes(match[1])) throw new Error('Browser lifecycle commands are reserved; use quit or tab close.');
431
+ const params = JSON.parse(match[2]);
432
+ if (!params || Array.isArray(params) || typeof params !== 'object') throw new Error('Parameters must be a JSON object');
433
+ const operation = async () => {
434
+ const cdp = await state.page.context().newCDPSession(state.page);
435
+ try {
436
+ return await cdp.send(match[1], params);
437
+ } finally {
438
+ await cdp.detach().catch(() => {});
439
+ }
440
+ };
441
+ printOutput(await withTimeout(operation(), 'CDP operation', COMMAND_TIMEOUT), all);
442
+ },
443
+
444
+ async cookies(_args, all) {
445
+ const cookies = await state.page.context().cookies();
446
+ if (!cookies.length) { out.log('No cookies'); return; }
447
+ printOutput(cookies.map(({ name, domain, path, expires, httpOnly, secure, sameSite }) =>
448
+ ({ name, domain, path, expires, httpOnly, secure, sameSite })), all);
449
+ },
450
+
451
+ async storage(_args, all) {
452
+ const keys = await state.page.evaluate(() =>
453
+ Array.from({ length: localStorage.length }, (_, i) => localStorage.key(i))
454
+ );
455
+ if (!keys.length) { out.log('localStorage is empty'); return; }
456
+ printOutput(keys, all);
457
+ },
458
+ };
459
+
460
+ module.exports = { commands, compactSnapshot, grepSnapshot };
package/lib/modes.js ADDED
@@ -0,0 +1,115 @@
1
+ // The modes on in each tab, who turned each on, and turning them off.
2
+ const { state } = require('./state');
3
+ const out = require('./output');
4
+ const { hints } = require('./util');
5
+ const { tabState } = require('./tabstate');
6
+ const { unroute } = require('./routes');
7
+ const { setNetwork } = require('./network');
8
+ const { emulatedKinds, setEmulation } = require('./emulation');
9
+ const { currentCapture, endCapture } = require('./capture');
10
+ const { nextTabWanted, waitingText, stopWaitingForNextTab, stopWatching } = require('./watch');
11
+
12
+ // The modes turned on in a tab, in the order the prompt shows them.
13
+ function activeModes(p) {
14
+ const modes = [];
15
+ if (tabState(p).watch?.on) modes.push('watch');
16
+ const network = tabState(p).network;
17
+ if (network) modes.push(network.offline ? 'network:off' : 'network:slow');
18
+ const emulated = emulatedKinds(tabState(p).emulation);
19
+ if (emulated.length) modes.push(`emulate:${emulated.join(',')}`);
20
+ const routes = tabState(p).routes?.size;
21
+ if (routes) modes.push(`routes:${routes}`);
22
+ if (currentCapture()?.page === p) modes.push('capture');
23
+ return modes;
24
+ }
25
+
26
+ function listModes() {
27
+ const all = state.browser.contexts().flatMap(c => c.pages());
28
+ state.tabListing = all.slice();
29
+ const on = all.map((p, i) => [p, i, activeModes(p)]).filter(([, , modes]) => modes.length);
30
+ const want = nextTabWanted();
31
+ if (want) out.log(`${waitingText()} (watch off stops waiting)${want.client ? `, for ${want.client}` : ''}${on.length ? '\n' : ''}`);
32
+ if (!on.length && want) return;
33
+ if (!on.length) {
34
+ out.log('No modes are on in any tab.');
35
+ out.log(hints([
36
+ ['watch on', 'record what the person at the browser does'],
37
+ ['capture on', 'record requests and console messages together'],
38
+ ['route <url-glob> <status> <json-body>', 'fake a response'],
39
+ ['network off', 'cut the tab\'s network'],
40
+ ['emulate mobile', 'show the tab as a phone would'],
41
+ ]));
42
+ return;
43
+ }
44
+ const width = Math.max(...on.map(([p, i]) => `[${i}] ${p.url()}`.length));
45
+ for (const [p, i, modes] of on) {
46
+ out.log(`${p === state.page ? '*' : ' '} ${`[${i}] ${p.url()}`.padEnd(width)} (${modes.join(' ')})`);
47
+ const owners = modeOwners(p);
48
+ if (owners.some(([owner]) => owner)) out.log(` on by ${ownersText(owners)}`);
49
+ }
50
+ out.log(hints([['modes off', 'turn them all off']]));
51
+ }
52
+
53
+ // Who turned on each mode in a tab: [client, what], client null for the prompt or an unnamed sender.
54
+ function modeOwners(p) {
55
+ const owners = [];
56
+ const watch = tabState(p).watch;
57
+ if (watch?.on) owners.push([watch.owner ?? null, 'watch']);
58
+ const network = tabState(p).network;
59
+ if (network) owners.push([network.owner ?? null, network.offline ? 'network:off' : 'network:slow']);
60
+ const emulated = tabState(p).emulation;
61
+ if (emulated) owners.push([emulated.owner ?? null, 'emulate']);
62
+ for (const [glob, route] of tabState(p).routes || []) owners.push([route.owner ?? null, `route ${glob}`]);
63
+ const capture = currentCapture();
64
+ if (capture?.page === p) owners.push([capture.owner ?? null, 'capture']);
65
+ return owners;
66
+ }
67
+
68
+ function ownersText(owners) {
69
+ const byOwner = new Map();
70
+ for (const [owner, what] of owners) byOwner.set(owner, [...(byOwner.get(owner) || []), what]);
71
+ return [...byOwner].map(([owner, whats]) => `${owner || 'unnamed'}: ${whats.join(', ')}`).join('; ');
72
+ }
73
+
74
+ // Turns off everything the REPL turned on, in every tab: nothing it leaves
75
+ // behind keeps acting on someone's browser. mine: only what the running client turned on.
76
+ async function allModesOff(mine = false) {
77
+ const all = state.browser.contexts().flatMap(c => c.pages());
78
+ const ours = owner => !mine || (owner ?? null) === state.client;
79
+ let any = false;
80
+ let failed = 0;
81
+ const want = nextTabWanted();
82
+ if (want && ours(want.client)) { stopWaitingForNextTab(); any = true; out.log('Stopped waiting to watch a new tab'); }
83
+ // A tab that fails (e.g. it crashed) must not keep the others' modes on.
84
+ for (const p of all) {
85
+ const done = [];
86
+ let problem = null;
87
+ try {
88
+ const watch = tabState(p).watch;
89
+ if (watch?.on && ours(watch.owner)) { stopWatching(p, watch); done.push('watch off'); }
90
+ const capture = currentCapture();
91
+ if (capture?.page === p && ours(capture.owner)) { endCapture(); done.push('capture off (capture shows it)'); }
92
+ const globs = [...(tabState(p).routes || [])].filter(([, route]) => ours(route.owner)).map(([glob]) => glob);
93
+ if (globs.length) { await unroute(p, globs); done.push(`${globs.length} route${globs.length === 1 ? '' : 's'} removed`); }
94
+ if ('network' in tabState(p) && ours(tabState(p).network.owner)) { await setNetwork(p, null); done.push('network on'); }
95
+ if ('emulation' in tabState(p) && ours(tabState(p).emulation.owner)) { await setEmulation(p, {}); done.push('emulate off'); }
96
+ } catch (error) {
97
+ problem = error.message;
98
+ }
99
+ if (done.length) { any = true; out.log(`${p.url()}: ${done.join(', ')}`); }
100
+ if (problem) { failed += 1; out.error(`${p.url()}: could not turn everything off: ${problem}`); }
101
+ }
102
+ if (failed) throw new Error(`Modes may still be on in ${failed} tab${failed === 1 ? '' : 's'}; modes lists them`);
103
+ if (!any) out.log(mine ? 'None of your modes were on.' : 'No modes were on.');
104
+ }
105
+
106
+ const commands = {
107
+ async modes(args) {
108
+ const arg = (args || '').trim();
109
+ if (!arg) return listModes();
110
+ if (arg !== 'off' && arg !== 'off --mine') throw new Error('Usage: modes [off [--mine]]');
111
+ await allModesOff(arg === 'off --mine');
112
+ },
113
+ };
114
+
115
+ module.exports = { commands, activeModes, allModesOff };
package/lib/network.js ADDED
@@ -0,0 +1,59 @@
1
+ // Cutting or slowing a tab's network.
2
+ const { state } = require('./state');
3
+ const out = require('./output');
4
+ const { hints } = require('./util');
5
+ const { tabState } = require('./tabstate');
6
+ const { keptSession } = require('./cdp');
7
+
8
+ // DevTools' "Slow 4G".
9
+ const SLOW_DEFAULT = { latency: 563, down: 1440, up: 675 };
10
+ const SLOW_LATENCY_MAX = 10000;
11
+
12
+ // setting null restores the network.
13
+ async function setNetwork(p, setting) {
14
+ const session = await keptSession(p);
15
+ if (!('networkEnabled' in tabState(p))) { await session.send('Network.enable'); tabState(p).networkEnabled = true; }
16
+ await session.send('Network.emulateNetworkConditions', {
17
+ offline: !!setting?.offline,
18
+ latency: setting?.latency || 0,
19
+ // kbps to bytes per second; -1 is no limit.
20
+ downloadThroughput: setting?.down ? setting.down * 125 : -1,
21
+ uploadThroughput: setting?.up ? setting.up * 125 : -1,
22
+ });
23
+ if (setting) tabState(p).network = { ...setting, owner: state.client }; else delete tabState(p).network;
24
+ }
25
+
26
+ function networkText(setting) {
27
+ return setting.offline ? 'off (offline)' : `slow (${setting.latency}ms latency, ${setting.down} kbps down, ${setting.up} kbps up)`;
28
+ }
29
+
30
+ const commands = {
31
+ // A stopped upstream does not reach the browser as a failure: the dev proxy
32
+ // holds the request open instead of refusing it. Cutting the connection at
33
+ // the browser is what a visitor's wifi or VPN dropping looks like to the page.
34
+ async network(args) {
35
+ const words = (args || '').trim().toLowerCase().split(/\s+/).filter(Boolean);
36
+ const setting = tabState(state.page).network;
37
+ if (!words.length) {
38
+ out.log(`The selected tab's network is ${setting ? networkText(setting) : 'on'}.`);
39
+ if (setting) out.log(hints([['network on', 'restore it']]));
40
+ else out.log(hints([['network off', 'cut it, like dropped wifi'], ['network slow [<ms> [<kbps>]]', `slow it (default ${networkText(SLOW_DEFAULT).slice(6, -1)})`]]));
41
+ return;
42
+ }
43
+ const [how, ...rest] = words;
44
+ if ((how === 'on' || how === 'off') && !rest.length) {
45
+ await setNetwork(state.page, how === 'on' ? null : { offline: true });
46
+ out.log(`The selected tab's network is ${how}`);
47
+ return;
48
+ }
49
+ const usage = `Usage: network [on | off | slow [<latency-ms> [<kbps>]]] (latency up to ${SLOW_LATENCY_MAX}ms)`;
50
+ if (how !== 'slow' || rest.length > 2 || !rest.every(w => /^\d+$/.test(w))) throw new Error(usage);
51
+ const [latency, kbps] = rest.map(Number);
52
+ if (latency > SLOW_LATENCY_MAX || kbps === 0) throw new Error(usage);
53
+ const slow = rest.length ? { latency, down: kbps || SLOW_DEFAULT.down, up: kbps || SLOW_DEFAULT.up } : SLOW_DEFAULT;
54
+ await setNetwork(state.page, slow);
55
+ out.log(`The selected tab's network is ${networkText(slow)}`);
56
+ },
57
+ };
58
+
59
+ module.exports = { commands, setNetwork };
package/lib/output.js CHANGED
@@ -43,8 +43,18 @@ function onIdlePrint(printer) {
43
43
  idlePrinter = printer;
44
44
  }
45
45
 
46
- function notice(text) {
46
+ // The running command's selected tab, as state.js tells it (it requires this file, not the other way).
47
+ let selectedTab = () => null;
48
+
49
+ function onSelectedTab(fn) {
50
+ selectedTab = fn;
51
+ }
52
+
53
+ // A notice from a tab (page) is part of a command's answer only when that command's client has
54
+ // the tab selected: another client's tab is not what it asked about. The pane shows it either way.
55
+ function notice(text, page = null) {
47
56
  if (!sink && idlePrinter && idlePrinter.idle()) idlePrinter.print(String(text));
57
+ else if (sink && page && page !== selectedTab()) console.log(text);
48
58
  else log(text);
49
59
  }
50
60
 
@@ -67,4 +77,4 @@ function printOutput(value, all = false) {
67
77
  }
68
78
  }
69
79
 
70
- module.exports = { OUTPUT_LIMIT, log, error, collect, take, notice, aside, onIdlePrint, printOutput };
80
+ module.exports = { OUTPUT_LIMIT, log, error, collect, take, notice, aside, onIdlePrint, onSelectedTab, printOutput };