pw-repl 0.1.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.
@@ -0,0 +1,1370 @@
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+ const { state, withTimeout, onShutdown, shutdown } = require('./state');
4
+ const out = require('./output');
5
+ const HELP = require('./help');
6
+
7
+ const { printOutput, OUTPUT_LIMIT } = out;
8
+ const SCREENSHOT_DIR = process.env.PW_SCREENSHOT_DIR || '/tmp';
9
+ const COMMAND_TIMEOUT = 15000;
10
+ const MAX_CAPTURE_EVENTS = 10000;
11
+ const MAX_CAPTURE_TEXT = 400000;
12
+ const MAX_EVENT_TEXT = 4000;
13
+
14
+ let cap = null;
15
+ let lastCapture = null;
16
+
17
+ // Records the selected tab's requests and console messages together, in time
18
+ // order, until capture off, or for a set number of seconds.
19
+ async function startCapture(tokens) {
20
+ if (cap) { out.log('Already capturing; capture off stops it.'); return; }
21
+ 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');
22
+ const usage = 'Usage: capture on [requests|console] [seconds] (seconds from 1 to 3600)';
23
+ const kinds = tokens.filter(t => t === 'requests' || t === 'console');
24
+ const numbers = tokens.filter(t => /^\d+$/.test(t));
25
+ if (kinds.length > 1 || numbers.length > 1 || kinds.length + numbers.length !== tokens.length) throw new Error(usage);
26
+ const seconds = numbers.length ? Number(numbers[0]) : null;
27
+ if (seconds !== null && (seconds < 1 || seconds > 3600)) throw new Error(usage);
28
+ const label = kinds[0] || 'requests and console';
29
+ const startedAt = Date.now();
30
+ const events = [];
31
+ const handlers = [];
32
+ let dropped = 0;
33
+ let shortened = 0;
34
+ let bytes = 0;
35
+ const record = event => {
36
+ let text = String(event.text);
37
+ if (text.length > MAX_EVENT_TEXT) { text = `${text.slice(0, MAX_EVENT_TEXT)}…`; shortened += 1; }
38
+ const size = text.length;
39
+ if (events.length >= MAX_CAPTURE_EVENTS || bytes + size > MAX_CAPTURE_TEXT) { dropped += 1; return; }
40
+ bytes += size;
41
+ events.push({ ...event, text });
42
+ };
43
+ const page = state.page;
44
+ const listen = (event, handler) => { page.on(event, handler); handlers.push([event, handler]); };
45
+ if (label !== 'console') listen('request', req => record({ t: Date.now(), tag: 'request', text: `${req.method()} ${req.url()}` }));
46
+ if (label !== 'requests') {
47
+ listen('console', msg => record({ t: Date.now(), tag: `console:${msg.type()}`, text: msg.text() }));
48
+ listen('pageerror', error => record({ t: Date.now(), tag: 'pageerror', text: error.stack || error.message }));
49
+ }
50
+ // A capture must not outlive its tab: nothing could see or stop it.
51
+ listen('close', () => {
52
+ if (cap?.page !== page) return;
53
+ endCapture();
54
+ out.notice('The captured tab closed, which ended the capture; capture shows what it recorded.');
55
+ });
56
+ const capture = {
57
+ page, label, startedAt, events, handlers, wake: null,
58
+ get dropped() { return dropped; },
59
+ get shortened() { return shortened; },
60
+ };
61
+ cap = capture;
62
+ if (seconds) {
63
+ out.log(`Capturing ${label} for ${seconds}s...`);
64
+ // Also ends early if the capture does, e.g. because its tab closed.
65
+ await new Promise(resolve => {
66
+ const timer = setTimeout(resolve, seconds * 1000);
67
+ capture.wake = () => { clearTimeout(timer); resolve(); };
68
+ });
69
+ if (cap === capture) stopCapture();
70
+ else printCapture(lastCapture);
71
+ } else {
72
+ out.log(`Capturing ${label}; capture off stops it and prints what it recorded.`);
73
+ }
74
+ }
75
+
76
+ function endCapture() {
77
+ cap.handlers.forEach(([event, h]) => cap.page.off(event, h));
78
+ const { label, startedAt, events, dropped, shortened, wake } = cap;
79
+ cap = null;
80
+ lastCapture = { label, startedAt, events, dropped, shortened };
81
+ if (wake) wake();
82
+ }
83
+
84
+ function stopCapture(all = false) {
85
+ if (!cap) { out.log('Not capturing.'); return; }
86
+ endCapture();
87
+ printCapture(lastCapture, all);
88
+ }
89
+
90
+ // What a command run on its own prints after its state: the commands that
91
+ // come next, set apart from the state by a blank line.
92
+ function hints(pairs) {
93
+ const width = Math.max(...pairs.map(([command]) => command.length));
94
+ return `\n${pairs.map(([command, what]) => ` ${command.padEnd(width)} ${what}`).join('\n')}`;
95
+ }
96
+
97
+ const dialogPages = new WeakSet();
98
+
99
+ function ensureDialogHandler(p) {
100
+ if (dialogPages.has(p)) return;
101
+ dialogPages.add(p);
102
+ p.on('dialog', dialog => {
103
+ out.notice(`Dialog [${dialog.type()}]: ${String(dialog.message()).slice(0, OUTPUT_LIMIT)}`);
104
+ out.notice('Handle this dialog in the browser window; the triggering command is waiting.');
105
+ });
106
+ }
107
+
108
+ const SCREENSHOT_DELAY_MAX = 60;
109
+
110
+ function nextScreenshotPath(name) {
111
+ const filename = name ? `screenshot-${name}` : `screenshot-${Date.now()}`;
112
+ return path.join(SCREENSHOT_DIR, `${filename}.png`);
113
+ }
114
+
115
+ // Per page: emulation applies to the page its CDP session is attached to.
116
+ const offlineSessions = new WeakMap();
117
+ // Pages whose network is cut.
118
+ const networkCut = new WeakSet();
119
+
120
+ async function setNetwork(p, on) {
121
+ let session = offlineSessions.get(p);
122
+ if (!session) {
123
+ session = await p.context().newCDPSession(p);
124
+ await session.send('Network.enable');
125
+ offlineSessions.set(p, session);
126
+ }
127
+ await session.send('Network.emulateNetworkConditions', {
128
+ offline: !on,
129
+ latency: 0,
130
+ downloadThroughput: -1,
131
+ uploadThroughput: -1,
132
+ });
133
+ if (on) networkCut.delete(p); else networkCut.add(p);
134
+ }
135
+
136
+ // page -> Map(glob -> { status, body, handler }). Playwright routes are
137
+ // registered per page, so the listing is too.
138
+ const pageRoutes = new WeakMap();
139
+ const ROUTE_PREVIEW = 80;
140
+
141
+ function routesFor(p) {
142
+ if (!pageRoutes.has(p)) pageRoutes.set(p, new Map());
143
+ return pageRoutes.get(p);
144
+ }
145
+
146
+ function listRoutes() {
147
+ const routes = routesFor(state.page);
148
+ if (!routes.size) {
149
+ out.log('No fake responses on the selected tab.');
150
+ out.log(hints([['route <url-glob> <status> <json-body>', 'add one']]));
151
+ return;
152
+ }
153
+ out.log(`Fake responses on the selected tab (${routes.size}):`);
154
+ const width = Math.max(...[...routes.keys()].map(glob => glob.length));
155
+ for (const [glob, { status, body }] of routes) {
156
+ const preview = body.length > ROUTE_PREVIEW ? `${body.slice(0, ROUTE_PREVIEW)}…` : body;
157
+ out.log(` ${glob.padEnd(width)} ${status} ${preview}`);
158
+ }
159
+ out.log(hints([
160
+ ['route <url-glob> <status> <json-body>', 'add one, or replace the one for that glob'],
161
+ ['route off <url-glob> | route off --all', 'remove'],
162
+ ]));
163
+ }
164
+
165
+ async function removeRoutes(glob) {
166
+ if (!glob) throw new Error('Usage: route off <url-glob> | route off --all');
167
+ const routes = routesFor(state.page);
168
+ if (glob !== '--all' && !routes.has(glob)) throw new Error(`No route for ${glob} on the selected tab`);
169
+ const removed = await unroute(state.page, glob === '--all' ? [...routes.keys()] : [glob]);
170
+ for (const g of removed) out.log(`Removed: ${g}`);
171
+ if (!removed.length) out.log('No fake responses on the selected tab');
172
+ }
173
+
174
+ async function unroute(p, globs) {
175
+ const routes = routesFor(p);
176
+ for (const g of globs) {
177
+ await p.unroute(g, routes.get(g).handler);
178
+ routes.delete(g);
179
+ }
180
+ return globs;
181
+ }
182
+
183
+ // Always-on request and console logs, so what happened can be checked after
184
+ // someone has already clicked through without a capture running. Requests
185
+ // keep metadata plus a handle to the response; a body is read from the
186
+ // browser only when asked for, and only while the browser still has it.
187
+ const RECENT_MAX = 200;
188
+ const RECENT_DEFAULT = 20;
189
+ const BODY_TIMEOUT = 15000;
190
+ // Hidden unless asked for: they are rarely what is being checked and crowd out
191
+ // the API calls that are.
192
+ const RECENT_HIDDEN_TYPES = new Set(['image', 'font', 'stylesheet', 'media']);
193
+ const recentLogs = new WeakMap();
194
+ const consoleLogs = new WeakMap();
195
+ const fakedRequests = new WeakSet();
196
+ const requestEntries = new WeakMap();
197
+ // Tabs this REPL opened with tab new.
198
+ const openedTabs = new WeakSet();
199
+
200
+ function clipText(text) {
201
+ return text.length > MAX_EVENT_TEXT ? `${text.slice(0, MAX_EVENT_TEXT)}…` : text;
202
+ }
203
+
204
+ function keep(log, entry) {
205
+ log.push(entry);
206
+ if (log.length > RECENT_MAX) log.shift();
207
+ }
208
+
209
+ function ensureRecentLog(p) {
210
+ if (recentLogs.has(p)) return;
211
+ const log = [];
212
+ const logs = [];
213
+ let nextId = 1;
214
+ recentLogs.set(p, log);
215
+ consoleLogs.set(p, logs);
216
+ p.on('request', req => {
217
+ const entry = { id: nextId++, t: Date.now(), method: req.method(), url: req.url(), type: req.resourceType(), status: 'pending', ms: null, response: null };
218
+ requestEntries.set(req, entry);
219
+ keep(log, entry);
220
+ });
221
+ const finish = (req, status, response = null) => {
222
+ const entry = requestEntries.get(req);
223
+ if (!entry) return;
224
+ entry.status = status;
225
+ entry.ms = Date.now() - entry.t;
226
+ entry.response = response;
227
+ };
228
+ p.on('requestfinished', async req => {
229
+ let status = 'no response';
230
+ let res = null;
231
+ try {
232
+ res = await req.response();
233
+ if (res) status = String(res.status());
234
+ } catch {}
235
+ finish(req, fakedRequests.has(req) ? `${status} faked` : status, res);
236
+ });
237
+ p.on('requestfailed', req => finish(req, `failed: ${req.failure()?.errorText || 'unknown'}`));
238
+ p.on('console', msg => keep(logs, { t: Date.now(), type: msg.type(), text: clipText(msg.text()) }));
239
+ // Uncaught exceptions never reach the console event.
240
+ p.on('pageerror', error => keep(logs, { t: Date.now(), type: 'pageerror', text: clipText(error.stack || error.message) }));
241
+ }
242
+
243
+ // Unnamed generic nodes are layout wrappers (mostly divs): they add depth and
244
+ // nothing to read. Drop them, lift their children, and drop cursor hints.
245
+ function compactSnapshot(text) {
246
+ const dropped = [];
247
+ const lines = [];
248
+ for (const line of text.split('\n')) {
249
+ const indent = line.length - line.trimStart().length;
250
+ while (dropped.length && dropped[dropped.length - 1] >= indent) dropped.pop();
251
+ if (/^- generic(?: \[[^\]]+\])*:?$/.test(line.trim())) { dropped.push(indent); continue; }
252
+ lines.push(' '.repeat(Math.max(0, indent - 2 * dropped.length)) + line.trimStart().replace(/ \[cursor=pointer\]/g, ''));
253
+ }
254
+ return lines.join('\n');
255
+ }
256
+
257
+ // Opt-in record of what the person at the browser does, so an agent can see
258
+ // the steps and the requests each one caused. Values typed into fields are
259
+ // never recorded, and password fields not at all.
260
+ const watches = new WeakMap();
261
+ const WATCH_MAX = 200;
262
+ const WATCH_REQUESTS_SHOWN = 5;
263
+ // Scripts too: a dev server loads dozens per navigation, crowding out the API calls.
264
+ const WATCH_HIDDEN_TYPES = new Set([...RECENT_HIDDEN_TYPES, 'script']);
265
+ const TYPING_BACKDATE_MAX = 60000;
266
+ const PAGE_ACTIONS = new Set(['click', 'check', 'uncheck', 'select', 'fill', 'type', 'press', 'submit']);
267
+ // Playwright refuses a second binding with the same name, so a retry must not re-register it.
268
+ const watchBindings = new WeakMap();
269
+
270
+ // Runs in the page. Describes elements the way snapshot does: role and name.
271
+ const WATCH_SCRIPT = `(() => {
272
+ if (window.__pwReplWatching) return;
273
+ window.__pwReplWatching = true;
274
+ const INTERACTIVE = 'a,button,input,select,textarea,summary,label,[role],[onclick],[tabindex]';
275
+ const roleOf = el => {
276
+ const explicit = el.getAttribute('role');
277
+ if (explicit) return explicit;
278
+ const tag = el.tagName.toLowerCase();
279
+ const type = (el.getAttribute('type') || '').toLowerCase();
280
+ if (tag === 'a' && el.hasAttribute('href')) return 'link';
281
+ if (tag === 'button' || (tag === 'input' && ['button', 'submit', 'reset', 'image'].includes(type))) return 'button';
282
+ if (tag === 'input' && type === 'checkbox') return 'checkbox';
283
+ if (tag === 'input' && type === 'radio') return 'radio';
284
+ if (tag === 'input' || tag === 'textarea') return 'textbox';
285
+ if (tag === 'select') return 'combobox';
286
+ if (/^h[1-6]$/.test(tag)) return 'heading';
287
+ return tag;
288
+ };
289
+ // Text inside these may be what the person typed, so it is never used as a name.
290
+ const typedInto = el => el.matches('input,select,textarea,form') || el.isContentEditable || !!el.closest('[contenteditable]:not([contenteditable=false])');
291
+ const isPassword = el => el.matches('input[type=password]') || (el.control && el.control.matches('input[type=password]'));
292
+ // An element holding an editable area would be named partly by what was typed there.
293
+ const hasEditable = el => !!el.querySelector('[contenteditable]:not([contenteditable=false])');
294
+ const clip = text => (text || '').replace(/\\s+/g, ' ').trim().slice(0, 60);
295
+ const nameOf = el => {
296
+ const labelledBy = el.getAttribute('aria-labelledby');
297
+ const byId = labelledBy && labelledBy.split(/\\s+/).map(id => document.getElementById(id)?.innerText).join(' ');
298
+ const label = (el.id && document.querySelector('label[for="' + CSS.escape(el.id) + '"]')) || el.closest('label');
299
+ return clip(el.getAttribute('aria-label') || byId || (label && label !== el && label.innerText) || el.getAttribute('alt')
300
+ || el.getAttribute('placeholder') || el.getAttribute('title') || (typedInto(el) || hasEditable(el) ? '' : el.innerText));
301
+ };
302
+ const describe = el => { const name = nameOf(el); return roleOf(el) + (name ? ' ' + JSON.stringify(name) : ''); };
303
+ // el null: the page itself, e.g. Escape with nothing focused.
304
+ const send = (action, el, extra, since) => {
305
+ if (typeof window.__pwReplWatch === 'function') window.__pwReplWatch({ action, target: el ? describe(el) : 'page', extra: extra || '', since: since || 0 });
306
+ };
307
+ document.addEventListener('click', e => {
308
+ const el = e.target.closest ? (e.target.closest(INTERACTIVE) || e.target) : e.target;
309
+ if (!el.matches || el.matches('input[type=checkbox],input[type=radio],select') || isPassword(el)) return;
310
+ send('click', el);
311
+ }, true);
312
+ // Typing is recorded once it pauses, not on change, so a typeahead's requests
313
+ // land under the typing that caused them. A field typed into is not also
314
+ // recorded as a fill when it loses focus.
315
+ const TYPING_PAUSE = 600;
316
+ const pending = new Map();
317
+ const typed = new WeakSet();
318
+ // Sent with how long ago the typing began, so the step is placed before the
319
+ // requests the typing caused.
320
+ const flushTyping = el => {
321
+ if (!pending.has(el)) return;
322
+ const { timer, start } = pending.get(el);
323
+ clearTimeout(timer);
324
+ pending.delete(el);
325
+ typed.add(el);
326
+ send('type', el, '', Date.now() - start);
327
+ };
328
+ const editable = el => el.isContentEditable ? (el.closest('[contenteditable]:not([contenteditable=false])') || el) : null;
329
+ 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])');
330
+ document.addEventListener('input', e => {
331
+ const el = editable(e.target) || e.target;
332
+ if (!el.matches || isPassword(el) || !(textEntry(el) || el.isContentEditable)) return;
333
+ const start = pending.has(el) ? pending.get(el).start : Date.now();
334
+ clearTimeout(pending.get(el)?.timer);
335
+ pending.set(el, { start, timer: setTimeout(() => flushTyping(el), TYPING_PAUSE) });
336
+ }, true);
337
+ document.addEventListener('keydown', e => {
338
+ if (e.key !== 'Enter' && e.key !== 'Escape') return;
339
+ if (!e.target.matches) return;
340
+ const el = editable(e.target) || e.target.closest(INTERACTIVE);
341
+ if (el && isPassword(el)) return;
342
+ // Enter on a button or link is recorded as the click it causes.
343
+ if (e.key === 'Enter' && !(el && (textEntry(el) || el.isContentEditable))) return;
344
+ if (el) flushTyping(el);
345
+ send('press', el, e.key);
346
+ }, true);
347
+ document.addEventListener('change', e => {
348
+ const el = e.target;
349
+ if (!el.matches || isPassword(el)) return;
350
+ if (el.matches('input[type=checkbox],input[type=radio]')) return send(el.checked ? 'check' : 'uncheck', el);
351
+ if (el.matches('select')) return send('select', el, JSON.stringify(clip(el.selectedOptions[0]?.text)));
352
+ if (pending.has(el)) { flushTyping(el); typed.delete(el); return; }
353
+ if (typed.has(el)) { typed.delete(el); return; }
354
+ send('fill', el);
355
+ }, true);
356
+ document.addEventListener('submit', e => send('submit', e.target), true);
357
+ })()`;
358
+
359
+ // What changed on screen between two snapshots, in a few lines. Refs, links'
360
+ // URLs and focus are dropped, and so are the values of text fields, which may
361
+ // be what the person typed. A new or removed element is shown once, with the
362
+ // first few named things inside it, not line by line.
363
+ const CHANGES_SHOWN = 5;
364
+ const CHANGE_WIDTH = 100;
365
+ const CHANGE_NAMES_SHOWN = 3;
366
+ const FIELD_VALUE = /^(- (?:textbox|combobox|searchbox|spinbutton)\b[^:]*?)(?::.*)?$/;
367
+
368
+ function snapshotNodes(text) {
369
+ const nodes = [];
370
+ const stack = [];
371
+ for (const line of compactSnapshot(text).split('\n')) {
372
+ let body = line.trim().replace(/ \[ref=[^\]]+\]/g, '').replace(/ \[active\]/g, '');
373
+ if (!body || /^- \/url:/.test(body)) continue;
374
+ body = body.replace(FIELD_VALUE, '$1').replace(/:$/, '');
375
+ const indent = line.length - line.trimStart().length;
376
+ while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();
377
+ const node = { body, indent, parent: stack.length ? stack[stack.length - 1] : null, children: [] };
378
+ if (node.parent) node.parent.children.push(node);
379
+ nodes.push(node);
380
+ stack.push(node);
381
+ }
382
+ return nodes;
383
+ }
384
+
385
+ // Nodes of `nodes` with no match in `others`, compared by their text; each
386
+ // kept only if its parent is matched, so a new subtree counts once.
387
+ function unmatchedTops(nodes, others) {
388
+ const counts = new Map();
389
+ for (const o of others) counts.set(o.body, (counts.get(o.body) || 0) + 1);
390
+ const unmatched = new Set();
391
+ for (const n of nodes) {
392
+ const c = counts.get(n.body);
393
+ if (c) counts.set(n.body, c - 1); else unmatched.add(n);
394
+ }
395
+ return nodes.filter(n => unmatched.has(n) && !(n.parent && unmatched.has(n.parent)));
396
+ }
397
+
398
+ // role "name", without flags or text: how an element is told apart.
399
+ const identity = body => /^- ([^[:"]+?(?: "(?:[^"\\]|\\.)*")?)(?: \[|:|$)/.exec(body)?.[1] || body;
400
+
401
+ function describeSubtree(node) {
402
+ const named = [];
403
+ // A named element by role and name; an unnamed one by its text, if it has any.
404
+ const label = body => /^- [^:"]+ "(?:[^"\\]|\\.)*"/.exec(body)?.[0].slice(2) || /^- [^:"]+: (.+)$/.exec(body)?.[1];
405
+ // What is inside a named element is mostly its name again, so it is not listed.
406
+ const walk = n => {
407
+ for (const c of n.children) {
408
+ const l = label(c.body);
409
+ if (l && named.length < 50) named.push(l);
410
+ if (!/"/.test(l || '')) walk(c);
411
+ }
412
+ };
413
+ walk(node);
414
+ let text = node.body.replace(/^- /, '');
415
+ // The count goes first so clipping a long line cuts names, not the count.
416
+ if (named.length > CHANGE_NAMES_SHOWN) text += ` (${named.length} named inside)`;
417
+ if (named.length) text += `: ${named.slice(0, CHANGE_NAMES_SHOWN).join(', ')}${named.length > CHANGE_NAMES_SHOWN ? ', …' : ''}`;
418
+ return text;
419
+ }
420
+
421
+ function summarizeChanges(beforeText, afterText) {
422
+ const before = snapshotNodes(beforeText);
423
+ const after = snapshotNodes(afterText);
424
+ const added = unmatchedTops(after, before);
425
+ const removed = unmatchedTops(before, after);
426
+ const lines = [];
427
+ // Same element, different flags or text: one changed line, not a remove and an add.
428
+ for (const a of added) {
429
+ const r = removed.find(x => identity(x.body) === identity(a.body) && x.indent === a.indent);
430
+ if (r) { removed.splice(removed.indexOf(r), 1); lines.push(`~ ${a.body.replace(/^- /, '')}`); }
431
+ else lines.push(`+ ${describeSubtree(a)}`);
432
+ }
433
+ for (const r of removed) lines.push(`- ${describeSubtree(r)}`);
434
+ const clipped = lines.map(l => (l.length > CHANGE_WIDTH ? `${l.slice(0, CHANGE_WIDTH - 1)}…` : l));
435
+ if (clipped.length > CHANGES_SHOWN) return [...clipped.slice(0, CHANGES_SHOWN), `… ${clipped.length - CHANGES_SHOWN} more changes`];
436
+ return clipped;
437
+ }
438
+
439
+ // With watch on --changes: once the page settles after a step (no new step
440
+ // for a moment and none of its requests pending), snapshot it and attach the
441
+ // difference from the previous snapshot to that step. With --live, that is
442
+ // also when the step is printed, with its requests and changes.
443
+ const SETTLE_QUIET = 800;
444
+ const SETTLE_MAX = 3000;
445
+
446
+ // Text in an editable area (contenteditable) may be what the person typed,
447
+ // and the snapshot shows it as ordinary text, so it is blanked out: a line's
448
+ // text or name is dropped when it is part of an editable area's text.
449
+ async function snapshotText(p) {
450
+ const text = await p.ariaSnapshot({ mode: 'ai', timeout: SETTLE_MAX });
451
+ const edited = await p.evaluate(() => [...document.querySelectorAll('[contenteditable]:not([contenteditable=false])')]
452
+ .map(el => el.innerText.replace(/\s+/g, ' ').trim()).filter(Boolean));
453
+ return scrubEditable(text, edited);
454
+ }
455
+
456
+ function scrubEditable(text, edited) {
457
+ if (!edited.length) return text;
458
+ const typed = part => { const t = part.replace(/\s+/g, ' ').trim(); return !!t && edited.some(e => e.includes(t) || t.includes(e)); };
459
+ return text.split('\n').map(line => {
460
+ let out = line.replace(/ "((?:[^"\\]|\\.)*)"/, (whole, name) => (typed(name) ? '' : whole));
461
+ const value = /^(\s*- [^:]*?): (.+)$/.exec(out);
462
+ if (value && typed(value[2].replace(/^"(.*)"$/, '$1'))) out = value[1];
463
+ return out;
464
+ }).join('\n');
465
+ }
466
+
467
+ function scheduleSettle(p, watch) {
468
+ clearTimeout(watch.settleTimer);
469
+ if (!watch.changes && !watch.live) return;
470
+ watch.settleTimer = setTimeout(async () => {
471
+ const step = watch.events[watch.events.length - 1];
472
+ const deadline = Date.now() + SETTLE_MAX;
473
+ const busy = () => (recentLogs.get(p) || []).some(r => r.t >= step.t && r.status === 'pending' && !RECENT_HIDDEN_TYPES.has(r.type));
474
+ while (busy() && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 100));
475
+ // A newer step restarts the wait; its changes will include these.
476
+ if (watch.events[watch.events.length - 1] !== step || p.isClosed()) return;
477
+ if (watch.changes) {
478
+ let text = null;
479
+ try { text = await snapshotText(p); } catch {}
480
+ if (watch.events[watch.events.length - 1] !== step) return;
481
+ // Against another page, everything differs; the navigate step says enough.
482
+ if (text !== null && step.action !== 'navigate' && watch.snapshot !== null) step.changes = summarizeChanges(watch.snapshot, text);
483
+ if (text !== null) watch.snapshot = text;
484
+ }
485
+ printLive(p, watch);
486
+ }, SETTLE_QUIET);
487
+ }
488
+
489
+ // Prints the step --live has not printed yet, above the prompt.
490
+ function printLive(p, watch) {
491
+ const step = watch.liveStep;
492
+ if (!step) return;
493
+ watch.liveStep = null;
494
+ const lines = stepLines(step, requestsByStep(watch, p)(step));
495
+ // Named by its URL when it is not the selected tab, so a step can be placed.
496
+ const where = p === state.page ? '' : ` ${p.url().replace(/^[a-z]+:\/\//, '').slice(0, 60)}`;
497
+ const prefix = `[watch${where}] `;
498
+ out.aside(lines.map((line, i) => `${i ? ' '.repeat(prefix.length) : prefix}${line}`).join('\n'));
499
+ }
500
+
501
+ async function startWatching(p, changes = false, live = false) {
502
+ let watch = watches.get(p);
503
+ if (!watch) {
504
+ // shownSeq and shownRequestId mark how far watch new has read.
505
+ const created = { on: false, startedAt: 0, changes: false, live: false, liveStep: null, snapshot: null, settleTimer: null, events: [], nextSeq: 1, shownSeq: 0, shownRequestId: 0 };
506
+ // since backdates a step (typing is reported once it pauses), but never to
507
+ // before the previous step, so the page cannot reorder the record.
508
+ const record = (action, target, extra, since = 0) => {
509
+ if (!created.on) return;
510
+ const previous = created.events[created.events.length - 1];
511
+ const t = Math.max(Date.now() - Math.min(Math.max(Number(since) || 0, 0), TYPING_BACKDATE_MAX), previous ? previous.t : 0);
512
+ const step = { seq: created.nextSeq++, t, action, target: clipText(target), extra: clipText(extra) };
513
+ keep(created.events, step);
514
+ // A step that has not settled yet is printed now: this one ends it.
515
+ printLive(p, created);
516
+ if (created.live) created.liveStep = step;
517
+ scheduleSettle(p, created);
518
+ };
519
+ // The page can call the binding itself, so only the page-side actions are
520
+ // accepted from it, as plain strings; navigations come from here.
521
+ if (!watchBindings.has(p)) {
522
+ const binding = { record: null };
523
+ await p.exposeBinding('__pwReplWatch', (_source, event) => {
524
+ if (binding.record && event && PAGE_ACTIONS.has(event.action)) binding.record(event.action, String(event.target || ''), String(event.extra || ''), event.since);
525
+ });
526
+ watchBindings.set(p, binding);
527
+ }
528
+ watchBindings.get(p).record = record;
529
+ await p.addInitScript(WATCH_SCRIPT);
530
+ p.on('framenavigated', frame => { if (frame === p.mainFrame()) record('navigate', frame.url(), ''); });
531
+ // Only now: a failed first attempt must leave nothing half set up to retry against.
532
+ watches.set(p, created);
533
+ watch = created;
534
+ }
535
+ await p.evaluate(WATCH_SCRIPT);
536
+ watch.changes = changes;
537
+ watch.live = live;
538
+ if (!live) watch.liveStep = null;
539
+ // The first step's changes are measured from how the page looks now. A page
540
+ // too busy to snapshot is not a reason to fail: the step after one that
541
+ // does snapshot is measured instead.
542
+ watch.snapshot = null;
543
+ if (changes) {
544
+ try { watch.snapshot = await snapshotText(p); }
545
+ catch { out.log('Could not snapshot the page yet; changes are shown from a later step.'); }
546
+ }
547
+ if (!watch.on) watch.startedAt = Date.now();
548
+ watch.on = true;
549
+ }
550
+
551
+ // For each step of a watch, the requests made between it and the next step.
552
+ function requestsByStep(watch, p = state.page) {
553
+ const requests = (recentLogs.get(p) || []).filter(r => !WATCH_HIDDEN_TYPES.has(r.type) && !r.url.startsWith('chrome-extension://'));
554
+ return e => {
555
+ const next = watch.events[watch.events.indexOf(e) + 1];
556
+ return requests.filter(r => r.t >= e.t && (!next || r.t < next.t));
557
+ };
558
+ }
559
+
560
+ function stepLines(e, caused, note = '', withChanges = true) {
561
+ const lines = [`${clock(e.t)} ${e.action} ${e.target}${e.extra ? ` ${e.extra}` : ''}${note}`];
562
+ const indent = ' '.repeat(clock(e.t).length + 1);
563
+ for (const r of caused.slice(0, WATCH_REQUESTS_SHOWN)) lines.push(`${indent}#${r.id} ${r.method} ${r.status} ${r.url}`);
564
+ if (caused.length > WATCH_REQUESTS_SHOWN) lines.push(`${indent}… ${caused.length - WATCH_REQUESTS_SHOWN} more (requests)`);
565
+ if (e.changes && withChanges) for (const change of e.changes) lines.push(`${indent}${change}`);
566
+ return lines;
567
+ }
568
+
569
+ // watch on its own: whether it is on, the last steps, and what to run next.
570
+ function showWatch(watch, all) {
571
+ const steps = watch ? watch.events.length : 0;
572
+ const counted = `${steps} step${steps === 1 ? '' : 's'}`;
573
+ const lines = [];
574
+ const extras = [watch?.changes && 'changes', watch?.live && 'live'].filter(Boolean);
575
+ if (watch?.on) lines.push(`Watching the selected tab since ${clock(watch.startedAt)}${extras.length ? ` (${extras.join(', ')})` : ''}: ${steps ? counted : 'nothing has happened yet'}`);
576
+ else if (steps) lines.push(`Not watching the selected tab; ${counted} recorded before watch off:`);
577
+ else lines.push('Not watching the selected tab.');
578
+ if (steps) {
579
+ const causedBy = requestsByStep(watch);
580
+ const shown = watch.events.slice(-RECENT_DEFAULT);
581
+ for (const e of shown) lines.push(...stepLines(e, causedBy(e)));
582
+ if (steps > shown.length) lines.push(`(last ${shown.length} of ${steps})`);
583
+ }
584
+ const more = ['watch <n>', `the last n steps (up to ${WATCH_MAX})`];
585
+ if (watch?.on) lines.push(hints([more, ['watch new', 'only the steps not shown by watch new yet'], ['watch off', 'stop recording']]));
586
+ else if (steps) lines.push(hints([more, ['watch on [--changes] [--live]', 'record again']]));
587
+ 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']]));
588
+ printOutput(lines.join('\n'), all);
589
+ }
590
+
591
+ function stopWatching(p, watch) {
592
+ clearTimeout(watch.settleTimer);
593
+ printLive(p, watch);
594
+ watch.on = false;
595
+ watch.changes = false;
596
+ watch.live = false;
597
+ }
598
+
599
+ // The modes turned on in a tab, in the order the prompt shows them.
600
+ function activeModes(p) {
601
+ const modes = [];
602
+ if (watches.get(p)?.on) modes.push('watch');
603
+ if (networkCut.has(p)) modes.push('network:off');
604
+ const routes = pageRoutes.get(p)?.size;
605
+ if (routes) modes.push(`routes:${routes}`);
606
+ if (cap && cap.page === p) modes.push('capture');
607
+ return modes;
608
+ }
609
+
610
+ function listModes() {
611
+ const all = state.browser.contexts().flatMap(c => c.pages());
612
+ state.tabListing = all.slice();
613
+ const on = all.map((p, i) => [p, i, activeModes(p)]).filter(([, , modes]) => modes.length);
614
+ if (!on.length) {
615
+ out.log('No modes are on in any tab.');
616
+ out.log(hints([
617
+ ['watch on', 'record what the person at the browser does'],
618
+ ['capture on', 'record requests and console messages together'],
619
+ ['route <url-glob> <status> <json-body>', 'fake a response'],
620
+ ['network off', 'cut the tab\'s network'],
621
+ ]));
622
+ return;
623
+ }
624
+ const width = Math.max(...on.map(([p, i]) => `[${i}] ${p.url()}`.length));
625
+ for (const [p, i, modes] of on) out.log(`${p === state.page ? '*' : ' '} ${`[${i}] ${p.url()}`.padEnd(width)} (${modes.join(' ')})`);
626
+ out.log(hints([['modes off', 'turn them all off']]));
627
+ }
628
+
629
+ // Turns off everything the REPL turned on, in every tab: nothing it leaves
630
+ // behind keeps acting on someone's browser.
631
+ async function allModesOff() {
632
+ const all = state.browser.contexts().flatMap(c => c.pages());
633
+ let any = false;
634
+ let failed = 0;
635
+ // A tab that fails (e.g. it crashed) must not keep the others' modes on.
636
+ for (const p of all) {
637
+ const done = [];
638
+ let problem = null;
639
+ try {
640
+ const watch = watches.get(p);
641
+ if (watch?.on) { stopWatching(p, watch); done.push('watch off'); }
642
+ if (cap && cap.page === p) { endCapture(); done.push('capture off (capture shows it)'); }
643
+ const globs = [...(pageRoutes.get(p)?.keys() || [])];
644
+ if (globs.length) { await unroute(p, globs); done.push(`${globs.length} route${globs.length === 1 ? '' : 's'} removed`); }
645
+ if (networkCut.has(p)) { await setNetwork(p, true); done.push('network on'); }
646
+ } catch (error) {
647
+ problem = error.message;
648
+ }
649
+ if (done.length) { any = true; out.log(`${p.url()}: ${done.join(', ')}`); }
650
+ if (problem) { failed += 1; out.error(`${p.url()}: could not turn everything off: ${problem}`); }
651
+ }
652
+ if (failed) throw new Error(`Modes may still be on in ${failed} tab${failed === 1 ? '' : 's'}; modes lists them`);
653
+ if (!any) out.log('No modes were on.');
654
+ }
655
+
656
+ const WAIT_DEFAULT = 10;
657
+ const WAIT_MAX = 120;
658
+
659
+ // A pattern without * is a URL part; with * it is a glob (* within a path segment, ** across).
660
+ function urlMatcher(pattern) {
661
+ if (!pattern.includes('*')) return url => url.includes(pattern);
662
+ const source = pattern.split('**').map(chunk => chunk.split('*').map(text => text.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*')).join('.*');
663
+ const regex = new RegExp(`^${source}$`);
664
+ return url => regex.test(url);
665
+ }
666
+
667
+ function printRequest(entry) {
668
+ out.log(`#${entry.id} ${entry.method} ${entry.status} ${entry.url}`);
669
+ }
670
+
671
+ // Watches the recent log, which has every request from the moment it starts, so
672
+ // one that began or finished since the previous command (e.g. the click that
673
+ // caused it) is found rather than missed.
674
+ async function waitForRequest(pattern, timeout) {
675
+ const matches = urlMatcher(pattern);
676
+ const since = state.previousCommandAt || 0;
677
+ const deadline = Date.now() + timeout;
678
+ for (;;) {
679
+ const entry = (recentLogs.get(state.page) || []).find(e => e.t >= since && matches(e.url));
680
+ if (entry && entry.status !== 'pending') return printRequest(entry);
681
+ if (Date.now() >= deadline) throw new Error(`No response matching ${pattern} within ${timeout / 1000}s${entry ? ` (#${entry.id} is still pending)` : ''}`);
682
+ await new Promise(resolve => setTimeout(resolve, 50));
683
+ }
684
+ }
685
+
686
+ const SNAPSHOT_HINT_LINES = 60;
687
+
688
+ // Each matching line (any part of it: role, name, flags such as [disabled]),
689
+ // with the named elements it sits in, so a match can be placed without the
690
+ // rest of the tree.
691
+ function grepSnapshot(text, needle) {
692
+ const lower = needle.toLowerCase();
693
+ const stack = [];
694
+ const hits = [];
695
+ const label = line => line.trim().replace(/^- /, '').replace(/:$/, '').replace(/^'(.*)'$/, '$1');
696
+ for (const line of text.split('\n')) {
697
+ const indent = line.length - line.trimStart().length;
698
+ while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();
699
+ if (line.toLowerCase().includes(lower)) {
700
+ const path = stack.filter(a => !/^generic(?: \[|$)/.test(a.label)).map(a => a.label.replace(/ \[ref=[^\]]+\]/g, ''));
701
+ hits.push([...path, label(line)].join(' › '));
702
+ }
703
+ stack.push({ indent, label: label(line) });
704
+ }
705
+ return hits;
706
+ }
707
+
708
+ // Local time with its offset (18:16:15.721-06:00), so it reads against the
709
+ // user's clock and is still unambiguous.
710
+ function clock(t) {
711
+ const d = new Date(t);
712
+ const pad = (n, width = 2) => String(n).padStart(width, '0');
713
+ const offset = -d.getTimezoneOffset();
714
+ const sign = offset < 0 ? '-' : '+';
715
+ const zone = `${sign}${pad(Math.floor(Math.abs(offset) / 60))}:${pad(Math.abs(offset) % 60)}`;
716
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}${zone}`;
717
+ }
718
+
719
+ function printCapture(capture, all = false) {
720
+ const events = capture.events.map(event => ({
721
+ elapsed: Number(((event.t - capture.startedAt) / 1000).toFixed(3)),
722
+ tag: event.tag,
723
+ text: event.text,
724
+ }));
725
+ if (events.length) printOutput(events, all);
726
+ else out.log('Nothing captured');
727
+ const notes = [];
728
+ if (capture.dropped) notes.push(`${capture.dropped} event(s) omitted`);
729
+ if (capture.shortened) notes.push(`${capture.shortened} message(s) shortened`);
730
+ if (notes.length) out.log(`[capture limits: ${notes.join('; ')}]`);
731
+ }
732
+
733
+ function discardCapture() {
734
+ if (!cap) return;
735
+ cap.handlers.forEach(([event, handler]) => cap.page.off(event, handler));
736
+ cap = null;
737
+ }
738
+
739
+ // Every tab, URL first, with * on the selected one. The numbers are what tab <index> uses.
740
+ async function listTabs() {
741
+ const all = state.browser.contexts().flatMap(c => c.pages());
742
+ if (state.page && !all.includes(state.page)) state.page = null;
743
+ state.tabListing = all.slice();
744
+ if (!all.length) { out.log('No tabs. Use tab new.'); return; }
745
+ for (const [i, p] of all.entries()) {
746
+ let title;
747
+ try { title = await p.title(); } catch { title = '[title unavailable]'; }
748
+ const marker = p === state.page ? '*' : ' ';
749
+ // URL first on its own line: it is what distinguishes otherwise
750
+ // identically titled tabs, and titles wrap less badly when indented.
751
+ if (i) out.log('');
752
+ out.log(`${marker} [${i}] ${p.url()}`);
753
+ out.log(` ${JSON.stringify(title)}`);
754
+ }
755
+ }
756
+
757
+ const commands = {
758
+ async tab(args) {
759
+ const trimmed = (args || '').trim();
760
+ if (!trimmed) {
761
+ await listTabs();
762
+ if (!state.page) out.log('\nNo tab is selected.');
763
+ out.log(hints([
764
+ ['tab <index|url-part>', 'select a tab'],
765
+ ['tab new [url]', 'open a tab of your own and select it'],
766
+ ['tab close [url-part]', 'close the selected tab, or the one whose URL contains url-part'],
767
+ ]));
768
+ return;
769
+ }
770
+ const usage = 'Usage: tab [<index> | <url-part> | new [url] | close [<url-part>]]';
771
+ const parts = trimmed.split(/\s+/);
772
+ const subcommand = parts.shift();
773
+ const all = state.browser.contexts().flatMap(c => c.pages());
774
+ // Matching on the URL, not an index, so a stale listing cannot point at someone else's tab.
775
+ // Remembers where the REPL was, so closing a tab can go back there, but
776
+ // only to a tab this REPL opened: the previous one may be someone else's.
777
+ const select = p => {
778
+ if (p !== state.page) state.previousPage = state.page;
779
+ state.page = p;
780
+ ensureDialogHandler(p);
781
+ };
782
+ const byUrl = part => {
783
+ const matches = all.filter(p => p.url().includes(part));
784
+ if (matches.length === 1) return matches[0];
785
+ if (!matches.length) throw new Error(`No tab URL contains "${part}"`);
786
+ throw new Error(`${matches.length} tabs match "${part}"; use a longer part:\n${matches.map(p => ` ${p.url()}`).join('\n')}`);
787
+ };
788
+ if (/^\d+$/.test(subcommand)) {
789
+ if (parts.length) throw new Error(usage);
790
+ const target = state.tabListing[Number(subcommand)];
791
+ if (!target || !all.includes(target)) throw new Error('That tab is unavailable. Run tab again.');
792
+ select(target);
793
+ return commands.info();
794
+ }
795
+ if (subcommand === 'new') {
796
+ const ctx = state.browser.contexts()[0];
797
+ const opened = await ctx.newPage();
798
+ openedTabs.add(opened);
799
+ select(opened);
800
+ const url = parts.join(' ');
801
+ if (url) await commands.goto(url);
802
+ else out.log('New tab created and selected');
803
+ return listTabs();
804
+ }
805
+ if (subcommand === 'close') {
806
+ const target = parts.length ? byUrl(parts.join(' ')) : state.page;
807
+ if (!target || target.isClosed() || !all.includes(target)) throw new Error('Selected tab is unavailable. Run tab again.');
808
+ if (all.length <= 1) throw new Error('Refusing to close the last tab');
809
+ const url = target.url();
810
+ const wasSelected = target === state.page;
811
+ await target.close();
812
+ if (wasSelected) {
813
+ const back = state.previousPage;
814
+ state.page = back && !back.isClosed() && openedTabs.has(back) ? back : null;
815
+ state.previousPage = null;
816
+ }
817
+ out.log(`Closed ${url}${wasSelected && !state.page ? '; no tab is selected now' : ''}`);
818
+ return listTabs();
819
+ }
820
+ select(byUrl(trimmed));
821
+ return commands.info();
822
+ },
823
+
824
+ async goto(args) {
825
+ if (!args) throw new Error('Usage: goto <url>');
826
+ let url = args;
827
+ if (!/^[a-z][a-z\d+.-]*:\/\//i.test(url) && !/^(about|data|file|javascript):/i.test(url)) url = 'https://' + url;
828
+ await state.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
829
+ out.log(`${state.page.url()} — ${await state.page.title()}`);
830
+ },
831
+
832
+ async back() {
833
+ await state.page.goBack({ waitUntil: 'domcontentloaded', timeout: 10000 });
834
+ out.log(`Back to: ${state.page.url()}`);
835
+ },
836
+
837
+ async forward() {
838
+ await state.page.goForward({ waitUntil: 'domcontentloaded', timeout: 10000 });
839
+ out.log(`Forward to: ${state.page.url()}`);
840
+ },
841
+
842
+ async reload() {
843
+ await state.page.reload({ waitUntil: 'domcontentloaded', timeout: 15000 });
844
+ out.log(`Reloaded: ${state.page.url()}`);
845
+ },
846
+
847
+ async info() {
848
+ out.log(` URL: ${state.page.url()}`);
849
+ out.log(` Title: ${await state.page.title()}`);
850
+ const vp = state.page.viewportSize();
851
+ if (vp) out.log(` Viewport: ${vp.width}x${vp.height}`);
852
+ },
853
+
854
+ async click(args) {
855
+ if (!args) throw new Error('Usage: click <selector>');
856
+ await state.page.click(args, { timeout: 5000 });
857
+ out.log(`Clicked: ${args}`);
858
+ },
859
+
860
+ async dblclick(args) {
861
+ if (!args) throw new Error('Usage: dblclick <selector>');
862
+ await state.page.dblclick(args, { timeout: 5000 });
863
+ out.log(`Double-clicked: ${args}`);
864
+ },
865
+
866
+ async hover(args) {
867
+ if (!args) throw new Error('Usage: hover <selector>');
868
+ await state.page.hover(args, { timeout: 5000 });
869
+ out.log(`Hovered: ${args}`);
870
+ },
871
+
872
+ async fill(args) {
873
+ if (!args || !args.includes('=>')) {
874
+ throw new Error('Usage: fill <selector> => <value>');
875
+ }
876
+ const [selector, ...rest] = args.split('=>');
877
+ await state.page.fill(selector.trim(), rest.join('=>').trim(), { timeout: 5000 });
878
+ out.log(`Filled: ${selector.trim()}`);
879
+ },
880
+
881
+ async type(args) {
882
+ if (!args || !args.includes('=>')) {
883
+ throw new Error('Usage: type <selector> => <text>');
884
+ }
885
+ const [selector, ...rest] = args.split('=>');
886
+ await state.page.type(selector.trim(), rest.join('=>').trim(), { timeout: 5000 });
887
+ out.log(`Typed into: ${selector.trim()}`);
888
+ },
889
+
890
+ async press(args) {
891
+ if (!args) throw new Error('Usage: press <key> or press <selector> => <key>');
892
+ if (args.includes('=>')) {
893
+ const [selector, key] = args.split('=>').map(s => s.trim());
894
+ await state.page.press(selector, key, { timeout: 5000 });
895
+ out.log(`Pressed ${key} on ${selector}`);
896
+ } else {
897
+ await state.page.keyboard.press(args.trim());
898
+ out.log(`Pressed: ${args.trim()}`);
899
+ }
900
+ },
901
+
902
+
903
+ async select(args) {
904
+ if (!args || !args.includes('=>')) {
905
+ throw new Error('Usage: select <selector> => <value>');
906
+ }
907
+ const [selector, value] = args.split('=>').map(s => s.trim());
908
+ await state.page.selectOption(selector, value, { timeout: 5000 });
909
+ out.log(`Selected "${value}" in ${selector}`);
910
+ },
911
+
912
+ async check(args) {
913
+ if (!args) throw new Error('Usage: check <selector>');
914
+ await state.page.check(args, { timeout: 5000 });
915
+ out.log(`Checked: ${args}`);
916
+ },
917
+
918
+ async uncheck(args) {
919
+ if (!args) throw new Error('Usage: uncheck <selector>');
920
+ await state.page.uncheck(args, { timeout: 5000 });
921
+ out.log(`Unchecked: ${args}`);
922
+ },
923
+
924
+ async text(args, all) {
925
+ if (!args) throw new Error('Usage: text <selector>');
926
+ printOutput(await state.page.innerText(args, { timeout: 5000 }), all);
927
+ },
928
+
929
+ async html(args, all) {
930
+ if (!args) throw new Error('Usage: html <selector>');
931
+ printOutput(await state.page.$eval(args, el => el.outerHTML), all);
932
+ },
933
+
934
+ async attrs(args, all) {
935
+ if (!args) throw new Error('Usage: attrs <selector>');
936
+ const result = await state.page.$eval(args, el => {
937
+ const out = {};
938
+ for (const attr of el.attributes) out[attr.name] = attr.value;
939
+ return out;
940
+ });
941
+ printOutput(result, all);
942
+ },
943
+
944
+ async count(args) {
945
+ if (!args) throw new Error('Usage: count <selector>');
946
+ const els = await state.page.$$(args);
947
+ out.log(`${els.length} element(s)`);
948
+ },
949
+
950
+ async visible(args) {
951
+ if (!args) throw new Error('Usage: visible <selector>');
952
+ const el = await state.page.$(args);
953
+ if (!el) { out.log('Not found'); return; }
954
+ out.log(await el.isVisible() ? 'visible' : 'hidden');
955
+ },
956
+
957
+ async links(_args, all) {
958
+ const links = await state.page.$$eval('a[href]', els =>
959
+ els.map(e => ({ text: e.textContent.trim(), href: e.href }))
960
+ .filter(l => l.text)
961
+ );
962
+ if (!links.length) { out.log('No links found'); return; }
963
+ printOutput(links, all);
964
+ },
965
+
966
+ async inputs(_args, all) {
967
+ const inputs = await state.page.$$eval('input, select, textarea', els =>
968
+ els.map(e => ({
969
+ tag: e.tagName.toLowerCase(),
970
+ type: e.type || '',
971
+ name: e.name || '',
972
+ id: e.id || '',
973
+ placeholder: e.placeholder || '',
974
+ disabled: e.disabled,
975
+ autocomplete: e.autocomplete || ''
976
+ }))
977
+ );
978
+ if (!inputs.length) { out.log('No inputs found'); return; }
979
+ printOutput(inputs, all);
980
+ },
981
+
982
+ async screenshot(args) {
983
+ const usage = 'Usage: screenshot [--full] [--delay|-d <seconds>] [name]\nNames may contain letters, digits, underscores, and hyphens.';
984
+ const tokens = (args || '').trim().split(/\s+/).filter(Boolean);
985
+ let full = false;
986
+ let delay = 0;
987
+ const names = [];
988
+ for (let i = 0; i < tokens.length; i++) {
989
+ const token = tokens[i];
990
+ if (token === '--full') { full = true; continue; }
991
+ if (token === '--delay' || token === '-d') {
992
+ const value = tokens[++i];
993
+ // Capped low on purpose: a timed shot is human-in-the-loop, and a typo
994
+ // like `-d 300` would otherwise park the serialized command queue.
995
+ if (!value || !/^\d+$/.test(value) || Number(value) > SCREENSHOT_DELAY_MAX) throw new Error(`Usage: screenshot --delay <seconds> (maximum ${SCREENSHOT_DELAY_MAX})`);
996
+ delay = Number(value);
997
+ continue;
998
+ }
999
+ names.push(token);
1000
+ }
1001
+ if (names.length > 1 || (names[0] && !/^[a-zA-Z0-9_-]+$/.test(names[0]))) throw new Error(usage);
1002
+ const name = names[0] || null;
1003
+ // Counted down out loud so a watcher can time a hover or menu state, and so
1004
+ // the wait is distinguishable from a hung command in a tmux capture.
1005
+ for (let remaining = delay; remaining > 0; remaining--) {
1006
+ out.log(`${remaining}...`);
1007
+ await state.page.waitForTimeout(1000);
1008
+ }
1009
+ // Named after the countdown so a default filename timestamps the capture.
1010
+ const filepath = nextScreenshotPath(name);
1011
+ const image = await state.page.screenshot({ fullPage: full });
1012
+ try {
1013
+ fs.writeFileSync(filepath, image, { flag: 'wx', mode: 0o600 });
1014
+ } catch (error) {
1015
+ if (error.code === 'EEXIST') throw new Error(`Screenshot already exists: ${filepath}`);
1016
+ throw error;
1017
+ }
1018
+ out.log(`Saved: ${filepath}`);
1019
+ },
1020
+
1021
+ async snapshot(args, all) {
1022
+ let rest = (args || '').trim();
1023
+ const full = /^--full(?:\s|$)/.test(rest);
1024
+ if (full) rest = rest.slice(6).trim();
1025
+ let needle = null;
1026
+ if (/^--grep(?:\s|$)/.test(rest)) {
1027
+ needle = rest.slice(6).trim().replace(/^(["'])(.*)\1$/, '$2').replace(/\\"/g, '"');
1028
+ if (!needle) throw new Error('Usage: snapshot [--full] --grep <text>');
1029
+ rest = '';
1030
+ }
1031
+ // Playwright's labels are e5, or frame-prefixed like f1e5 in newer versions.
1032
+ const selector = /^(?:f\d+)?e\d+$/.test(rest) ? `aria-ref=${rest}` : rest;
1033
+ const target = selector ? state.page.locator(selector).first() : state.page;
1034
+ const raw = await target.ariaSnapshot({ mode: 'ai', timeout: 5000 });
1035
+ const text = full ? raw : compactSnapshot(raw);
1036
+ if (needle !== null) {
1037
+ const hits = grepSnapshot(text, needle);
1038
+ if (!hits.length) { out.log(`No snapshot lines match ${needle}`); return; }
1039
+ printOutput(hits.join('\n'), all);
1040
+ return;
1041
+ }
1042
+ const lines = text.split('\n').length;
1043
+ printOutput(lines > SNAPSHOT_HINT_LINES ? `${text}\n[${lines} lines; snapshot --grep <text> or snapshot <eN> shows less]` : text, all);
1044
+ },
1045
+
1046
+ async viewport(args) {
1047
+ if (!args) {
1048
+ const vp = state.page.viewportSize();
1049
+ out.log(vp ? `${vp.width}x${vp.height}` : 'No viewport set');
1050
+ return;
1051
+ }
1052
+ const [w, h] = args.split('x').map(Number);
1053
+ if (!w || !h) throw new Error('Usage: viewport <width>x<height>');
1054
+ await state.page.setViewportSize({ width: w, height: h });
1055
+ out.log(`Viewport set to ${w}x${h}`);
1056
+ },
1057
+
1058
+ async wait(args) {
1059
+ const usage = 'Usage: wait <selector> | wait text <text> | wait request <url-part|glob>, each with optional [seconds]';
1060
+ let tokens = (args || '').trim().split(/\s+/).filter(Boolean);
1061
+ let seconds = WAIT_DEFAULT;
1062
+ if (tokens.length > 1 && /^\d+$/.test(tokens[tokens.length - 1])) seconds = Number(tokens.pop());
1063
+ if (!tokens.length || seconds < 1 || seconds > WAIT_MAX) throw new Error(`${usage} (1-${WAIT_MAX})`);
1064
+ const timeout = seconds * 1000;
1065
+ const kind = tokens[0];
1066
+ if ((kind === 'text' || kind === 'request') && tokens.length > 1) {
1067
+ const what = tokens.slice(1).join(' ').replace(/^(["'])(.*)\1$/, '$2').replace(/\\"/g, '"');
1068
+ if (kind === 'text') {
1069
+ await state.page.getByText(what).first().waitFor({ state: 'visible', timeout });
1070
+ out.log(`Visible: ${what}`);
1071
+ return;
1072
+ }
1073
+ return waitForRequest(what, timeout);
1074
+ }
1075
+ const selector = tokens.join(' ');
1076
+ await state.page.waitForSelector(selector, { state: 'attached', timeout });
1077
+ out.log(`Found: ${selector}`);
1078
+ },
1079
+
1080
+ async sleep(args) {
1081
+ if (!args || !/^\d+$/.test(args) || Number(args) > 3600000) throw new Error('Usage: sleep <ms> (maximum 3600000)');
1082
+ await state.page.waitForTimeout(Number(args));
1083
+ out.log(`Slept ${args}ms`);
1084
+ },
1085
+
1086
+ // A stopped upstream does not reach the browser as a failure: the dev proxy
1087
+ // holds the request open instead of refusing it. Cutting the connection at
1088
+ // the browser is what a visitor's wifi or VPN dropping looks like to the page.
1089
+ // The CDP session is kept alive because emulation resets when it detaches.
1090
+ async network(args) {
1091
+ const arg = (args || '').trim().toLowerCase();
1092
+ if (!arg) {
1093
+ if (networkCut.has(state.page)) {
1094
+ out.log('The selected tab\'s network is off (offline).');
1095
+ out.log(hints([['network on', 'restore it']]));
1096
+ } else {
1097
+ out.log('The selected tab\'s network is on.');
1098
+ out.log(hints([['network off', 'cut it, like dropped wifi']]));
1099
+ }
1100
+ return;
1101
+ }
1102
+ if (arg !== 'on' && arg !== 'off') throw new Error('Usage: network [on|off]');
1103
+ await setNetwork(state.page, arg === 'on');
1104
+ out.log(`The selected tab's network is ${arg}`);
1105
+ },
1106
+
1107
+ // Fulfilled inside the browser, so the page's own code handles the fake
1108
+ // exactly as a real response and the request never reaches the network.
1109
+ async route(args) {
1110
+ const trimmed = (args || '').trim();
1111
+ if (!trimmed) return listRoutes();
1112
+ const off = /^off(?:\s+(\S+))?$/.exec(trimmed);
1113
+ if (off) return removeRoutes(off[1]);
1114
+ const match = /^(\S+)\s+(\d{3})\s+([\s\S]+)$/.exec(trimmed);
1115
+ if (!match) throw new Error('Usage: route <url-glob> <status> <json-body> | route off <url-glob>|--all');
1116
+ const [, glob, statusText, body] = match;
1117
+ const status = Number(statusText);
1118
+ if (status < 200 || status > 599) throw new Error('Status must be from 200 to 599');
1119
+ try { JSON.parse(body); } catch (error) { throw new Error(`Body is not valid JSON: ${error.message}`); }
1120
+ const routes = routesFor(state.page);
1121
+ const previous = routes.get(glob);
1122
+ if (previous) await state.page.unroute(glob, previous.handler);
1123
+ const handler = async r => {
1124
+ const req = r.request();
1125
+ fakedRequests.add(req);
1126
+ try {
1127
+ await r.fulfill({ status, contentType: 'application/json', body });
1128
+ // Recorded now: the browser reports the request finished only later.
1129
+ const entry = requestEntries.get(req);
1130
+ if (entry) { entry.status = `${status} faked`; entry.ms = Date.now() - entry.t; }
1131
+ const id = entry?.id;
1132
+ out.notice(`Faked: ${id ? `#${id} ` : ''}${req.method()} ${req.url()} -> ${status}`);
1133
+ } catch (error) {
1134
+ // Aborted, never continued: a request meant to be faked must not reach the network.
1135
+ await r.abort().catch(() => {});
1136
+ const id = requestEntries.get(req)?.id;
1137
+ out.notice(`Fake failed: ${id ? `#${id} ` : ''}${req.method()} ${req.url()} — ${error.message}; the request was aborted`);
1138
+ }
1139
+ };
1140
+ await state.page.route(glob, handler);
1141
+ routes.set(glob, { status, body, handler });
1142
+ out.log(`${previous ? 'Replaced' : 'Routed'}: ${glob} -> ${status}`);
1143
+ },
1144
+
1145
+ async requests(args) {
1146
+ const tokens = (args || '').trim().split(/\s+/).filter(Boolean);
1147
+ const everything = tokens.includes('--all');
1148
+ if (everything) tokens.splice(tokens.indexOf('--all'), 1);
1149
+ let count = RECENT_DEFAULT;
1150
+ if (tokens.length && /^\d+$/.test(tokens[0])) count = Number(tokens.shift());
1151
+ if (count < 1 || count > RECENT_MAX || tokens.length > 1) throw new Error(`Usage: requests [--all] [n] [url-filter] (n from 1 to ${RECENT_MAX})`);
1152
+ const filter = tokens[0];
1153
+ const log = recentLogs.get(state.page) || [];
1154
+ const shown = everything ? log : log.filter(e => !RECENT_HIDDEN_TYPES.has(e.type) && !e.url.startsWith('chrome-extension://'));
1155
+ const matches = shown.filter(e => !filter || e.url.includes(filter)).slice(-count);
1156
+ if (!matches.length) { out.log(filter ? `No requests matching ${filter}` : 'No requests on the selected tab'); return; }
1157
+ for (const e of matches) {
1158
+ const ms = e.ms === null ? '-' : `${e.ms}ms`;
1159
+ out.log(`#${e.id} ${clock(e.t)} ${e.method} ${e.status} ${ms} ${e.url}`);
1160
+ }
1161
+ },
1162
+
1163
+ async body(args, all) {
1164
+ const match = /^#?(\d+)$/.exec((args || '').trim());
1165
+ if (!match) throw new Error('Usage: body <#> (the number from requests)');
1166
+ const id = Number(match[1]);
1167
+ const entry = (recentLogs.get(state.page) || []).find(e => e.id === id);
1168
+ if (!entry) throw new Error(`No request #${id} on the selected tab; requests lists them`);
1169
+ // A fake is marked answered before the browser hands over its response.
1170
+ for (let waited = 0; !entry.response && /faked$/.test(entry.status) && waited < 2000; waited += 50) {
1171
+ await new Promise(resolve => setTimeout(resolve, 50));
1172
+ }
1173
+ if (!entry.response) throw new Error(`#${id} has no response${entry.status === 'pending' ? ' yet' : ` (${entry.status})`}`);
1174
+ // Not withTimeout: its "timed out" error would disconnect the REPL, and
1175
+ // reading a body cannot have changed anything.
1176
+ let timer;
1177
+ const late = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`did not arrive within ${BODY_TIMEOUT / 1000}s`)), BODY_TIMEOUT); });
1178
+ let buffer;
1179
+ try { buffer = await Promise.race([entry.response.body(), late]); }
1180
+ catch (error) { throw new Error(`The body of #${id} is not available: ${error.message}`); }
1181
+ finally { clearTimeout(timer); }
1182
+ const type = entry.response.headers()['content-type'] || '';
1183
+ out.log(`#${id} ${entry.method} ${entry.status} ${entry.url} (${type || 'no content type'}, ${buffer.length} bytes)`);
1184
+ if (!buffer.length) return;
1185
+ if (!/json|^text\/|javascript|xml|html|x-www-form-urlencoded/.test(type)) { out.log('[binary body not shown]'); return; }
1186
+ let text = buffer.toString('utf8');
1187
+ if (/json/.test(type)) { try { text = JSON.stringify(JSON.parse(text), null, 2); } catch {} }
1188
+ printOutput(text, all);
1189
+ },
1190
+
1191
+ async console(args, all) {
1192
+ const tokens = (args || '').trim().split(/\s+/).filter(Boolean);
1193
+ let count = RECENT_DEFAULT;
1194
+ if (tokens.length && /^\d+$/.test(tokens[0])) count = Number(tokens.shift());
1195
+ if (count < 1 || count > RECENT_MAX || tokens.length > 1) throw new Error(`Usage: console [n] [filter] (n from 1 to ${RECENT_MAX})`);
1196
+ const filter = tokens[0];
1197
+ const matches = (consoleLogs.get(state.page) || [])
1198
+ .filter(e => !filter || e.type.includes(filter) || e.text.includes(filter))
1199
+ .slice(-count);
1200
+ if (!matches.length) { out.log(filter ? `No console messages matching ${filter}` : 'No console messages on the selected tab'); return; }
1201
+ printOutput(matches.map(e => `${clock(e.t)} [${e.type}] ${e.text}`).join('\n'), all);
1202
+ },
1203
+
1204
+ async watch(args, all) {
1205
+ const arg = (args || '').trim();
1206
+ const watch = watches.get(state.page);
1207
+ const [sub, ...flags] = arg.split(/\s+/);
1208
+ if (sub === 'on' && flags.every(f => f === '--changes' || f === '--live')) {
1209
+ const changes = flags.includes('--changes');
1210
+ const live = flags.includes('--live');
1211
+ await startWatching(state.page, changes, live);
1212
+ 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)`);
1213
+ 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.');
1214
+ return;
1215
+ }
1216
+ if (arg === 'off') {
1217
+ if (!watch?.on) { out.log('Not watching the selected tab.'); return; }
1218
+ stopWatching(state.page, watch);
1219
+ const steps = watch.events.length;
1220
+ out.log(`Stopped watching the selected tab${steps ? `; ${steps} step${steps === 1 ? '' : 's'} recorded (watch shows them)` : ''}`);
1221
+ return;
1222
+ }
1223
+ if (!arg) return showWatch(watch, all);
1224
+ const usage = `Usage: watch on [--changes] [--live] | watch off | watch [n] | watch new (n from 1 to ${WATCH_MAX})`;
1225
+ const onlyNew = arg === 'new';
1226
+ if (arg && !onlyNew && !/^\d+$/.test(arg)) throw new Error(usage);
1227
+ const count = onlyNew ? RECENT_DEFAULT : Number(arg);
1228
+ if (count < 1 || count > WATCH_MAX) throw new Error(usage);
1229
+ if (!watch || !watch.events.length) { out.log(watch?.on ? 'Watching; nothing has happened yet' : 'Not watching the selected tab; watch on starts it'); return; }
1230
+ const causedBy = requestsByStep(watch);
1231
+ const lines = [];
1232
+ const printStep = (e, caused, note = '') => {
1233
+ lines.push(...stepLines(e, caused, note, !(onlyNew && e.changesShown)));
1234
+ if (onlyNew && e.changes) e.changesShown = true;
1235
+ };
1236
+ let events;
1237
+ if (onlyNew) {
1238
+ // Requests and changes can arrive after their step was read; they are shown under it again.
1239
+ const lastShown = watch.events.find(e => e.seq === watch.shownSeq);
1240
+ const late = lastShown ? causedBy(lastShown).filter(r => r.id > watch.shownRequestId) : [];
1241
+ const lateChanges = lastShown?.changes && !lastShown.changesShown;
1242
+ if (late.length || lateChanges) printStep(lastShown, late, ' (continued)');
1243
+ events = watch.events.filter(e => e.seq > watch.shownSeq);
1244
+ if (!events.length && !late.length && !lateChanges) lines.push('No new steps');
1245
+ } else {
1246
+ events = watch.events.slice(-count);
1247
+ }
1248
+ for (const e of events) printStep(e, causedBy(e));
1249
+ // Only watch new keeps track of what has been read.
1250
+ if (onlyNew) {
1251
+ const last = watch.events[watch.events.length - 1];
1252
+ watch.shownSeq = last.seq;
1253
+ watch.shownRequestId = Math.max(watch.shownRequestId, ...causedBy(last).map(r => r.id));
1254
+ }
1255
+ if (!watch.on) lines.push('[watch is off]');
1256
+ printOutput(lines.join('\n'), all);
1257
+ },
1258
+
1259
+ async eval(args, all) {
1260
+ if (!args) throw new Error('Usage: eval <js-expression>');
1261
+ printOutput(await withTimeout(state.page.evaluate(args), 'Page evaluation', COMMAND_TIMEOUT), all);
1262
+ },
1263
+
1264
+ async cdp(args, all) {
1265
+ const match = /^(\S+)\s+([\s\S]+)$/.exec(args || '');
1266
+ if (!match) throw new Error('Usage: cdp <method> <JSON object>');
1267
+ if (['Browser.close', 'Target.closeTarget'].includes(match[1])) throw new Error('Browser lifecycle commands are reserved; use quit or tab close.');
1268
+ const params = JSON.parse(match[2]);
1269
+ if (!params || Array.isArray(params) || typeof params !== 'object') throw new Error('Parameters must be a JSON object');
1270
+ const operation = async () => {
1271
+ const cdp = await state.page.context().newCDPSession(state.page);
1272
+ try {
1273
+ return await cdp.send(match[1], params);
1274
+ } finally {
1275
+ await cdp.detach().catch(() => {});
1276
+ }
1277
+ };
1278
+ printOutput(await withTimeout(operation(), 'CDP operation', COMMAND_TIMEOUT), all);
1279
+ },
1280
+
1281
+ async cookies(_args, all) {
1282
+ const cookies = await state.page.context().cookies();
1283
+ if (!cookies.length) { out.log('No cookies'); return; }
1284
+ printOutput(cookies.map(({ name, domain, path, expires, httpOnly, secure, sameSite }) =>
1285
+ ({ name, domain, path, expires, httpOnly, secure, sameSite })), all);
1286
+ },
1287
+
1288
+ async storage(_args, all) {
1289
+ const keys = await state.page.evaluate(() =>
1290
+ Array.from({ length: localStorage.length }, (_, i) => localStorage.key(i))
1291
+ );
1292
+ if (!keys.length) { out.log('localStorage is empty'); return; }
1293
+ printOutput(keys, all);
1294
+ },
1295
+
1296
+ async capture(args, all) {
1297
+ const [sub, ...rest] = (args || '').trim().split(/\s+/).filter(Boolean);
1298
+ if (sub === 'on') return startCapture(rest);
1299
+ if (sub === 'off' && !rest.length) return stopCapture(all);
1300
+ if (sub) throw new Error('Usage: capture on [requests|console] [seconds] | capture off');
1301
+ if (cap) {
1302
+ out.log(`Capturing ${cap.label} on ${cap.page.url()} since ${clock(cap.startedAt)} (${cap.events.length} so far)`);
1303
+ out.log(hints([['capture off', 'stop and print what it recorded']]));
1304
+ return;
1305
+ }
1306
+ if (lastCapture) {
1307
+ out.log(`Not capturing. The last capture, of ${lastCapture.label} from ${clock(lastCapture.startedAt)}:`);
1308
+ printCapture(lastCapture, all);
1309
+ } else {
1310
+ out.log('Not capturing.');
1311
+ }
1312
+ out.log(hints([['capture on [requests|console] [seconds]', 'record the selected tab\'s requests and console messages in time order']]));
1313
+ },
1314
+
1315
+ async modes(args) {
1316
+ const arg = (args || '').trim();
1317
+ if (!arg) return listModes();
1318
+ if (arg !== 'off') throw new Error('Usage: modes [off]');
1319
+ await allModesOff();
1320
+ },
1321
+
1322
+ async help(args) {
1323
+ const topic = (args || '').trim();
1324
+ const text = HELP.render(topic);
1325
+ if (text === null) throw new Error(`No help for ${topic}. Topics: ${Object.keys(HELP.TOPICS).join(', ')}`);
1326
+ out.log(text);
1327
+ },
1328
+
1329
+ async quit() {
1330
+ out.log('Disconnecting...');
1331
+ await shutdown();
1332
+ }
1333
+ };
1334
+
1335
+ onShutdown(discardCapture);
1336
+
1337
+ // Hooks every page needs from the moment the REPL sees it.
1338
+ function watchPage(p) {
1339
+ ensureDialogHandler(p);
1340
+ ensureRecentLog(p);
1341
+ }
1342
+
1343
+ // Tab completion for the prompt: command names first, then the fixed words
1344
+ // a few commands take.
1345
+ function complete(line) {
1346
+ const words = line.split(/\s+/);
1347
+ const current = words[words.length - 1];
1348
+ const match = options => {
1349
+ options = [...new Set(options)];
1350
+ const hits = options.filter(o => o.startsWith(current));
1351
+ return [hits.length ? hits : options, current];
1352
+ };
1353
+ if (words.length === 1) return match(Object.keys(commands).sort());
1354
+ const command = words[0];
1355
+ if (words.length === 2) {
1356
+ if (command === 'help') return match(['--all', ...Object.keys(HELP.TOPICS), ...Object.keys(HELP.COMMANDS).sort()]);
1357
+ if (command === 'tab') return match(['new', 'close']);
1358
+ if (command === 'network') return match(['on', 'off']);
1359
+ if (command === 'modes') return match(['off']);
1360
+ if (command === 'watch') return match(['on', 'off', 'new', '--all']);
1361
+ if (command === 'capture') return match(['on', 'off']);
1362
+ if (command === 'route') return match(['off']);
1363
+ }
1364
+ if (words.length >= 3 && command === 'watch' && words[1] === 'on') return match(['--changes', '--live'].filter(f => !words.slice(2, -1).includes(f)));
1365
+ if (words.length === 3 && command === 'capture' && words[1] === 'on') return match(['requests', 'console']);
1366
+ if (words.length === 3 && command === 'route' && words[1] === 'off') return match(['--all', ...(state.page ? routesFor(state.page).keys() : [])]);
1367
+ return [[], current];
1368
+ }
1369
+
1370
+ module.exports = { commands, listTabs, activeModes, watchPage, complete, compactSnapshot, grepSnapshot, summarizeChanges, scrubEditable, clock };