pw-repl 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/watch.js ADDED
@@ -0,0 +1,530 @@
1
+ // Watching what someone does in a tab: each step, the requests it caused and what it changed.
2
+ const { state, clientRecord } = require('./state');
3
+ const out = require('./output');
4
+ const { printOutput } = out;
5
+ const { clipText, clock, hints, NO_TAB } = require('./util');
6
+ const { tabState } = require('./tabstate');
7
+ const { keptSession, keepDrawing } = require('./cdp');
8
+ const { RECENT_HIDDEN_TYPES, keepBodiesDurable, keep, WATCH_HIDDEN_TYPES, RECENT_DEFAULT } = require('./requestlog');
9
+ const { compactSnapshot } = require('./inspect');
10
+
11
+ // Opt-in record of what the person at the browser does, so an agent can see
12
+ // the steps and the requests each one caused. Values typed into fields are
13
+ // never recorded, and password fields not at all.
14
+ const WATCH_MAX = 200;
15
+ const WATCH_REQUESTS_SHOWN = 5;
16
+
17
+ const TYPING_BACKDATE_MAX = 60000;
18
+ const PAGE_ACTIONS = new Set(['click', 'check', 'uncheck', 'select', 'fill', 'type', 'press', 'submit']);
19
+ const WATCH_BINDING = '__pwReplWatch';
20
+
21
+ // Runs in the page. Describes elements the way snapshot does: role and name.
22
+ const WATCH_SCRIPT = `(() => {
23
+ if (window.__pwReplWatching) return;
24
+ window.__pwReplWatching = true;
25
+ const INTERACTIVE = 'a,button,input,select,textarea,summary,label,[role],[onclick],[tabindex]';
26
+ const roleOf = el => {
27
+ const explicit = el.getAttribute('role');
28
+ if (explicit) return explicit;
29
+ const tag = el.tagName.toLowerCase();
30
+ const type = (el.getAttribute('type') || '').toLowerCase();
31
+ if (tag === 'a' && el.hasAttribute('href')) return 'link';
32
+ if (tag === 'button' || (tag === 'input' && ['button', 'submit', 'reset', 'image'].includes(type))) return 'button';
33
+ if (tag === 'input' && type === 'checkbox') return 'checkbox';
34
+ if (tag === 'input' && type === 'radio') return 'radio';
35
+ if (tag === 'input' || tag === 'textarea') return 'textbox';
36
+ if (tag === 'select') return 'combobox';
37
+ if (/^h[1-6]$/.test(tag)) return 'heading';
38
+ return tag;
39
+ };
40
+ // Text inside these may be what the person typed, so it is never used as a name.
41
+ const typedInto = el => el.matches('input,select,textarea,form') || el.isContentEditable || !!el.closest('[contenteditable]:not([contenteditable=false])');
42
+ const isPassword = el => el.matches('input[type=password]') || (el.control && el.control.matches('input[type=password]'));
43
+ // An element holding an editable area would be named partly by what was typed there.
44
+ const hasEditable = el => !!el.querySelector('[contenteditable]:not([contenteditable=false])');
45
+ const clip = text => (text || '').replace(/\\s+/g, ' ').trim().slice(0, 60);
46
+ // A label's own words, without the options or values of the controls inside it.
47
+ const labelText = label => {
48
+ const copy = label.cloneNode(true);
49
+ copy.querySelectorAll('select,textarea,input,button').forEach(n => n.remove());
50
+ return copy.textContent;
51
+ };
52
+ const nameOf = el => {
53
+ const labelledBy = el.getAttribute('aria-labelledby');
54
+ const byId = labelledBy && labelledBy.split(/\\s+/).map(id => document.getElementById(id)?.innerText).join(' ');
55
+ const label = (el.id && document.querySelector('label[for="' + CSS.escape(el.id) + '"]')) || el.closest('label');
56
+ return clip(el.getAttribute('aria-label') || byId || (label && label !== el && labelText(label)) || el.getAttribute('alt')
57
+ || el.getAttribute('placeholder') || el.getAttribute('title') || (typedInto(el) || hasEditable(el) ? '' : el.innerText));
58
+ };
59
+ const describe = el => { const name = nameOf(el); return roleOf(el) + (name ? ' ' + JSON.stringify(name) : ''); };
60
+ // el null: the page itself, e.g. Escape with nothing focused.
61
+ const send = (action, el, extra, since) => {
62
+ // Typing still waiting for its pause is recorded first, so the steps stay in order.
63
+ if (action !== 'type') for (const field of [...pending.keys()]) flushTyping(field);
64
+ if (typeof window.__pwReplWatch === 'function') window.__pwReplWatch(JSON.stringify({ action, target: el ? describe(el) : 'page', extra: extra || '', since: since || 0 }));
65
+ };
66
+ document.addEventListener('click', e => {
67
+ const el = e.target.closest ? (e.target.closest(INTERACTIVE) || e.target) : e.target;
68
+ if (!el.matches || el.matches('input[type=checkbox],input[type=radio],select') || isPassword(el)) return;
69
+ send('click', el);
70
+ }, true);
71
+ // Typing is recorded once it pauses, not on change, so a typeahead's requests
72
+ // land under the typing that caused them. A field typed into is not also
73
+ // recorded as a fill when it loses focus.
74
+ const TYPING_PAUSE = 600;
75
+ const pending = new Map();
76
+ const typed = new WeakSet();
77
+ // Sent with how long ago the typing began, so the step is placed before the
78
+ // requests the typing caused.
79
+ const flushTyping = el => {
80
+ if (!pending.has(el)) return;
81
+ const { timer, start } = pending.get(el);
82
+ clearTimeout(timer);
83
+ pending.delete(el);
84
+ typed.add(el);
85
+ send('type', el, '', Date.now() - start);
86
+ };
87
+ const editable = el => el.isContentEditable ? (el.closest('[contenteditable]:not([contenteditable=false])') || el) : null;
88
+ 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])');
89
+ document.addEventListener('input', e => {
90
+ const el = editable(e.target) || e.target;
91
+ if (!el.matches || isPassword(el) || !(textEntry(el) || el.isContentEditable)) return;
92
+ const start = pending.has(el) ? pending.get(el).start : Date.now();
93
+ clearTimeout(pending.get(el)?.timer);
94
+ pending.set(el, { start, timer: setTimeout(() => flushTyping(el), TYPING_PAUSE) });
95
+ }, true);
96
+ document.addEventListener('keydown', e => {
97
+ if (e.key !== 'Enter' && e.key !== 'Escape') return;
98
+ if (!e.target.matches) return;
99
+ const el = editable(e.target) || e.target.closest(INTERACTIVE);
100
+ if (el && isPassword(el)) return;
101
+ // Enter on a button or link is recorded as the click it causes.
102
+ if (e.key === 'Enter' && !(el && (textEntry(el) || el.isContentEditable))) return;
103
+ if (el) flushTyping(el);
104
+ send('press', el, e.key);
105
+ }, true);
106
+ document.addEventListener('change', e => {
107
+ const el = e.target;
108
+ if (!el.matches || isPassword(el)) return;
109
+ if (el.matches('input[type=checkbox],input[type=radio]')) return send(el.checked ? 'check' : 'uncheck', el);
110
+ if (el.matches('select')) return send('select', el, JSON.stringify(clip(el.selectedOptions[0]?.text)));
111
+ if (pending.has(el)) { flushTyping(el); typed.delete(el); return; }
112
+ if (typed.has(el)) { typed.delete(el); return; }
113
+ send('fill', el);
114
+ }, true);
115
+ document.addEventListener('submit', e => send('submit', e.target), true);
116
+ })()`;
117
+
118
+ // What changed on screen between two snapshots, in a few lines. Refs, links'
119
+ // URLs and focus are dropped, and so are the values of text fields, which may
120
+ // be what the person typed. A new or removed element is shown once, with the
121
+ // first few named things inside it, not line by line.
122
+ const CHANGES_SHOWN = 5;
123
+ const CHANGE_WIDTH = 100;
124
+ const CHANGE_NAMES_SHOWN = 3;
125
+ const FIELD_VALUE = /^(- (?:textbox|combobox|searchbox|spinbutton)\b[^:]*?)(?::.*)?$/;
126
+
127
+ function snapshotNodes(text) {
128
+ const nodes = [];
129
+ const stack = [];
130
+ for (const line of compactSnapshot(text).split('\n')) {
131
+ let body = line.trim().replace(/ \[ref=[^\]]+\]/g, '').replace(/ \[active\]/g, '');
132
+ if (!body || /^- \/url:/.test(body)) continue;
133
+ body = body.replace(FIELD_VALUE, '$1').replace(/:$/, '');
134
+ const indent = line.length - line.trimStart().length;
135
+ while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();
136
+ const node = { body, indent, parent: stack.length ? stack[stack.length - 1] : null, children: [] };
137
+ if (node.parent) node.parent.children.push(node);
138
+ nodes.push(node);
139
+ stack.push(node);
140
+ }
141
+ return nodes;
142
+ }
143
+
144
+ // Nodes of `nodes` with no match in `others`, compared by their text; each
145
+ // kept only if its parent is matched, so a new subtree counts once.
146
+ function unmatchedTops(nodes, others) {
147
+ const counts = new Map();
148
+ for (const o of others) counts.set(o.body, (counts.get(o.body) || 0) + 1);
149
+ const unmatched = new Set();
150
+ for (const n of nodes) {
151
+ const c = counts.get(n.body);
152
+ if (c) counts.set(n.body, c - 1); else unmatched.add(n);
153
+ }
154
+ return nodes.filter(n => unmatched.has(n) && !(n.parent && unmatched.has(n.parent)));
155
+ }
156
+
157
+ // role "name", without flags or text: how an element is told apart.
158
+ const identity = body => /^- ([^[:"]+?(?: "(?:[^"\\]|\\.)*")?)(?: \[|:|$)/.exec(body)?.[1] || body;
159
+
160
+ function describeSubtree(node) {
161
+ const named = [];
162
+ // A named element by role and name; an unnamed one by its text, if it has any.
163
+ const label = body => /^- [^:"]+ "(?:[^"\\]|\\.)*"/.exec(body)?.[0].slice(2) || /^- [^:"]+: (.+)$/.exec(body)?.[1];
164
+ // What is inside a named element is mostly its name again, so it is not listed.
165
+ const walk = n => {
166
+ for (const c of n.children) {
167
+ const l = label(c.body);
168
+ if (l && named.length < 50) named.push(l);
169
+ if (!/"/.test(l || '')) walk(c);
170
+ }
171
+ };
172
+ walk(node);
173
+ let text = node.body.replace(/^- /, '');
174
+ // The count goes first so clipping a long line cuts names, not the count.
175
+ if (named.length > CHANGE_NAMES_SHOWN) text += ` (${named.length} named inside)`;
176
+ if (named.length) text += `: ${named.slice(0, CHANGE_NAMES_SHOWN).join(', ')}${named.length > CHANGE_NAMES_SHOWN ? ', …' : ''}`;
177
+ return text;
178
+ }
179
+
180
+ function summarizeChanges(beforeText, afterText) {
181
+ const before = snapshotNodes(beforeText);
182
+ const after = snapshotNodes(afterText);
183
+ const added = unmatchedTops(after, before);
184
+ const removed = unmatchedTops(before, after);
185
+ const lines = [];
186
+ // Same element, different flags or text: one changed line, not a remove and an add.
187
+ for (const a of added) {
188
+ const r = removed.find(x => identity(x.body) === identity(a.body) && x.indent === a.indent);
189
+ if (r) { removed.splice(removed.indexOf(r), 1); lines.push(`~ ${a.body.replace(/^- /, '')}`); }
190
+ else lines.push(`+ ${describeSubtree(a)}`);
191
+ }
192
+ for (const r of removed) lines.push(`- ${describeSubtree(r)}`);
193
+ const clipped = lines.map(l => (l.length > CHANGE_WIDTH ? `${l.slice(0, CHANGE_WIDTH - 1)}…` : l));
194
+ if (clipped.length > CHANGES_SHOWN) return [...clipped.slice(0, CHANGES_SHOWN), `… ${clipped.length - CHANGES_SHOWN} more changes`];
195
+ return clipped;
196
+ }
197
+
198
+ // With watch on --changes: once the page settles after a step (no new step
199
+ // for a moment and none of its requests pending), snapshot it and attach the
200
+ // difference from the previous snapshot to that step. With --live, that is
201
+ // also when the step is printed, with its requests and changes.
202
+ const SETTLE_QUIET = 800;
203
+ const SETTLE_MAX = 3000;
204
+
205
+ // Text in an editable area (contenteditable) may be what the person typed,
206
+ // and the snapshot shows it as ordinary text, so it is blanked out: a line's
207
+ // text or name is dropped when it is part of an editable area's text.
208
+ async function snapshotText(p) {
209
+ const text = await p.ariaSnapshot({ mode: 'ai', timeout: SETTLE_MAX });
210
+ const edited = await p.evaluate(() => [...document.querySelectorAll('[contenteditable]:not([contenteditable=false])')]
211
+ .map(el => el.innerText.replace(/\s+/g, ' ').trim()).filter(Boolean));
212
+ return scrubEditable(text, edited);
213
+ }
214
+
215
+ function scrubEditable(text, edited) {
216
+ if (!edited.length) return text;
217
+ const typed = part => { const t = part.replace(/\s+/g, ' ').trim(); return !!t && edited.some(e => e.includes(t) || t.includes(e)); };
218
+ return text.split('\n').map(line => {
219
+ let out = line.replace(/ "((?:[^"\\]|\\.)*)"/, (whole, name) => (typed(name) ? '' : whole));
220
+ const value = /^(\s*- [^:]*?): (.+)$/.exec(out);
221
+ if (value && typed(value[2].replace(/^"(.*)"$/, '$1'))) out = value[1];
222
+ return out;
223
+ }).join('\n');
224
+ }
225
+
226
+ function scheduleSettle(p, watch) {
227
+ clearTimeout(watch.settleTimer);
228
+ if (!watch.changes && !watch.live) return;
229
+ watch.settleTimer = setTimeout(async () => {
230
+ const step = watch.events[watch.events.length - 1];
231
+ const deadline = Date.now() + SETTLE_MAX;
232
+ const busy = () => (tabState(p).log || []).some(r => r.t >= step.t && r.status === 'pending' && !RECENT_HIDDEN_TYPES.has(r.type));
233
+ while (busy() && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 100));
234
+ // A newer step restarts the wait; its changes will include these.
235
+ if (watch.events[watch.events.length - 1] !== step || p.isClosed()) return;
236
+ if (watch.changes) {
237
+ let text = null;
238
+ try { text = await snapshotText(p); } catch {}
239
+ if (watch.events[watch.events.length - 1] !== step) return;
240
+ // Against another page, everything differs; the navigate step says enough.
241
+ if (text !== null && step.action !== 'navigate' && watch.snapshot !== null) step.changes = summarizeChanges(watch.snapshot, text);
242
+ if (text !== null) watch.snapshot = text;
243
+ }
244
+ printLive(p, watch);
245
+ }, SETTLE_QUIET);
246
+ }
247
+
248
+ // Prints the step --live has not printed yet, above the prompt.
249
+ function printLive(p, watch) {
250
+ const step = watch.liveStep;
251
+ if (!step) return;
252
+ watch.liveStep = null;
253
+ const lines = stepLines(step, requestsByStep(watch, p)(step));
254
+ // Named by its URL when it is not the selected tab, so a step can be placed.
255
+ const where = p === state.page ? '' : ` ${p.url().replace(/^[a-z]+:\/\//, '').slice(0, 60)}`;
256
+ const prefix = `[watch${where}] `;
257
+ out.aside(lines.map((line, i) => `${i ? ' '.repeat(prefix.length) : prefix}${line}`).join('\n'));
258
+ }
259
+
260
+ async function startWatching(p, changes = false, live = false) {
261
+ await keepBodiesDurable(p);
262
+ let watch = tabState(p).watch;
263
+ if (!watch) {
264
+ // shownSeq and shownRequestId mark how far watch new has read.
265
+ const created = { on: false, startedAt: 0, changes: false, live: false, liveStep: null, snapshot: null, settleTimer: null, events: [], nextSeq: 1, shownSeq: 0, shownRequestId: 0 };
266
+ // since backdates a step (typing is reported once it pauses), but never to
267
+ // before the previous step, so the page cannot reorder the record.
268
+ const record = (action, target, extra, since = 0) => {
269
+ if (!created.on) return;
270
+ const previous = created.events[created.events.length - 1];
271
+ const t = Math.max(Date.now() - Math.min(Math.max(Number(since) || 0, 0), TYPING_BACKDATE_MAX), previous ? previous.t : 0);
272
+ const step = { seq: created.nextSeq++, t, action, target: clipText(target), extra: clipText(extra) };
273
+ keep(created.events, step);
274
+ // A step that has not settled yet is printed now: this one ends it.
275
+ printLive(p, created);
276
+ if (created.live) created.liveStep = step;
277
+ scheduleSettle(p, created);
278
+ };
279
+ // The page can call the binding itself, so only the page-side actions are
280
+ // accepted from it, as plain strings; navigations come from here. A CDP
281
+ // binding of its own, not Playwright's exposeBinding: every Playwright
282
+ // client shares that one, so another tool attached to the browser would
283
+ // answer each call with a "not exposed" error thrown into the page.
284
+ if (!('watchBinding' in tabState(p))) {
285
+ const binding = { record: null };
286
+ const session = await keptSession(p);
287
+ session.on('Runtime.bindingCalled', ({ name, payload }) => {
288
+ if (name !== WATCH_BINDING || !binding.record) return;
289
+ let event;
290
+ try { event = JSON.parse(payload); } catch { return; }
291
+ if (event && PAGE_ACTIONS.has(event.action)) binding.record(event.action, String(event.target || ''), String(event.extra || ''), event.since);
292
+ });
293
+ // Calls are reported only to a session with Runtime on.
294
+ await session.send('Runtime.enable');
295
+ await session.send('Runtime.addBinding', { name: WATCH_BINDING });
296
+ tabState(p).watchBinding = binding;
297
+ }
298
+ tabState(p).watchBinding.record = record;
299
+ await p.addInitScript(WATCH_SCRIPT);
300
+ p.on('framenavigated', frame => { if (frame === p.mainFrame()) record('navigate', frame.url(), ''); });
301
+ // Only now: a failed first attempt must leave nothing half set up to retry against.
302
+ tabState(p).watch = created;
303
+ watch = created;
304
+ } else if (!watch.on) {
305
+ // A watch on after watch off is a new recording: the old steps would read as part of it.
306
+ watch.events.length = 0;
307
+ watch.shownSeq = watch.nextSeq - 1;
308
+ watch.shownRequestId = Math.max(0, ...(tabState(p).log || []).map(r => r.id));
309
+ watch.liveStep = null;
310
+ }
311
+ await p.evaluate(WATCH_SCRIPT);
312
+ watch.changes = changes;
313
+ watch.live = live;
314
+ if (!live) watch.liveStep = null;
315
+ // The first step's changes are measured from how the page looks now. A page
316
+ // too busy to snapshot is not a reason to fail: the step after one that
317
+ // does snapshot is measured instead.
318
+ watch.snapshot = null;
319
+ if (changes) {
320
+ try { watch.snapshot = await snapshotText(p); }
321
+ catch { out.log('Could not snapshot the page yet; changes are shown from a later step.'); }
322
+ }
323
+ if (!watch.on) watch.startedAt = Date.now();
324
+ watch.on = true;
325
+ }
326
+
327
+ // Set by watch on --next-tab: the next tab to open (by anyone), or the next
328
+ // whose URL contains part, is watched from its first page and selected. Each
329
+ // new tab is watched from the moment it opens, since its first page may load
330
+ // at once; one that turns out not to be it is left as it was.
331
+ let nextTab = null;
332
+
333
+ // What watch on --next-tab waits for ({ part, changes, live, client }), or null.
334
+ function nextTabWanted() {
335
+ return nextTab;
336
+ }
337
+
338
+ function stopWaitingForNextTab() {
339
+ nextTab = null;
340
+ }
341
+
342
+ // The sender's own --next-tab is still waiting: the tab will be selected for it.
343
+ function waitingForMe() {
344
+ return !!nextTab && nextTab.client === state.client;
345
+ }
346
+ const BROWSER_PAGE = /^(?:about|chrome|chrome-search|devtools|edge):/;
347
+
348
+ // Who is waiting: the client that asked, or the prompt (a sender without a name shares it).
349
+ function waiter() {
350
+ return nextTab.client || 'the prompt';
351
+ }
352
+
353
+ function waitingText() {
354
+ return `Waiting to watch the next tab that opens${nextTab.part ? ` with a URL containing ${nextTab.part}` : ''}`;
355
+ }
356
+
357
+ function watchNewTab(p) {
358
+ const want = nextTab;
359
+ tabState(p).watchStarting = true;
360
+ keepBodiesDurable(p);
361
+ startWatching(p, want.changes, want.live).then(() => {
362
+ delete tabState(p).watchStarting;
363
+ tabState(p).watch.owner = want.client;
364
+ const onNavigate = frame => {
365
+ if (frame !== p.mainFrame() || BROWSER_PAGE.test(frame.url())) return;
366
+ const url = frame.url();
367
+ p.off('framenavigated', onNavigate);
368
+ const watch = tabState(p).watch;
369
+ if (nextTab !== want || (want.part && !url.includes(want.part))) {
370
+ stopWatching(p, watch);
371
+ watch.events.length = 0;
372
+ return;
373
+ }
374
+ nextTab = null;
375
+ // Its record starts at its own page, not the new-tab page before it.
376
+ const first = watch.events.findIndex(e => e.action === 'navigate' && e.target === clipText(url));
377
+ if (first > 0) watch.events.splice(0, first);
378
+ // A page that loaded before the watch was ready: its step is added, at its request's time.
379
+ if (first < 0) {
380
+ const request = (tabState(p).log || []).filter(e => e.navigation && e.url === url).pop();
381
+ watch.events.unshift({ seq: watch.nextSeq++, t: request ? request.t : Date.now(), action: 'navigate', target: clipText(url), extra: '' });
382
+ }
383
+ // Selected for the client that asked, whoever's command runs now.
384
+ const record = clientRecord(want.client);
385
+ if (record.page !== p) { record.previousPage = record.page; record.page = p; }
386
+ keepDrawing(p);
387
+ out.notice(`Watching the new tab ${url}, now the selected tab (watch on --next-tab)`, p);
388
+ };
389
+ p.on('framenavigated', onNavigate);
390
+ // Opened on its page already, as a link to a new tab does.
391
+ if (p.url() && !BROWSER_PAGE.test(p.url())) onNavigate(p.mainFrame());
392
+ }).catch(error => {
393
+ delete tabState(p).watchStarting;
394
+ out.notice(`Could not watch the new tab ${p.url()}: ${error.message}`, p);
395
+ });
396
+ }
397
+
398
+ // For each step of a watch, the requests made between it and the next step.
399
+ function requestsByStep(watch, p = state.page) {
400
+ const requests = (tabState(p).log || []).filter(r => !WATCH_HIDDEN_TYPES.has(r.type) && !r.url.startsWith('chrome-extension://'));
401
+ // A page's document is requested before its navigate step, which is recorded once the page commits:
402
+ // it is listed under that step (the next navigate to its URL), not under the step before.
403
+ const documentStep = new Map();
404
+ for (const r of requests) {
405
+ const step = r.navigation && watch.events.find(e => e.action === 'navigate' && e.t >= r.t && e.target === clipText(r.url));
406
+ if (step) documentStep.set(r, step);
407
+ }
408
+ return e => {
409
+ const next = watch.events[watch.events.indexOf(e) + 1];
410
+ return requests.filter(r => (documentStep.has(r) ? documentStep.get(r) === e : r.t >= e.t && (!next || r.t < next.t)));
411
+ };
412
+ }
413
+
414
+ function stepLines(e, caused, note = '', withChanges = true) {
415
+ const lines = [`${clock(e.t)} ${e.action} ${e.target}${e.extra ? ` ${e.extra}` : ''}${note}`];
416
+ const indent = ' '.repeat(clock(e.t).length + 1);
417
+ for (const r of caused.slice(0, WATCH_REQUESTS_SHOWN)) lines.push(`${indent}#${r.id} ${r.method} ${r.status} ${r.url}`);
418
+ if (caused.length > WATCH_REQUESTS_SHOWN) lines.push(`${indent}… ${caused.length - WATCH_REQUESTS_SHOWN} more (requests)`);
419
+ if (e.changes && withChanges) for (const change of e.changes) lines.push(`${indent}${change}`);
420
+ return lines;
421
+ }
422
+
423
+ // watch on its own: whether it is on, the last steps, and what to run next.
424
+ function showWatch(watch, all) {
425
+ const steps = watch ? watch.events.length : 0;
426
+ const counted = `${steps} step${steps === 1 ? '' : 's'}`;
427
+ const lines = [];
428
+ const extras = [watch?.changes && 'changes', watch?.live && 'live'].filter(Boolean);
429
+ if (watch?.on) lines.push(`Watching the selected tab since ${clock(watch.startedAt)}${extras.length ? ` (${extras.join(', ')})` : ''}: ${steps ? counted : 'nothing has happened yet'}`);
430
+ else if (steps) lines.push(`Not watching the selected tab; ${counted} recorded before watch off:`);
431
+ else lines.push('Not watching the selected tab.');
432
+ if (steps) {
433
+ const causedBy = requestsByStep(watch);
434
+ const shown = watch.events.slice(-RECENT_DEFAULT);
435
+ for (const e of shown) lines.push(...stepLines(e, causedBy(e)));
436
+ if (steps > shown.length) lines.push(`(last ${shown.length} of ${steps})`);
437
+ }
438
+ const more = ['watch <n>', `the last n steps (up to ${WATCH_MAX})`];
439
+ if (watch?.on) lines.push(hints([more, ['watch new', 'only the steps not shown by watch new yet'], ['watch off', 'stop recording']]));
440
+ else if (steps) lines.push(hints([more, ['watch on [--changes] [--live]', 'record again']]));
441
+ 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']]));
442
+ printOutput(lines.join('\n'), all);
443
+ }
444
+
445
+ function stopWatching(p, watch) {
446
+ clearTimeout(watch.settleTimer);
447
+ printLive(p, watch);
448
+ watch.on = false;
449
+ watch.changes = false;
450
+ watch.live = false;
451
+ }
452
+
453
+ const commands = {
454
+ async watch(args, all) {
455
+ const arg = (args || '').trim();
456
+ const watch = tabState(state.page).watch;
457
+ const [sub, ...flags] = arg.split(/\s+/);
458
+ const next = flags.indexOf('--next-tab');
459
+ if (sub === 'on' && next !== -1) {
460
+ const part = flags[next + 1] && !flags[next + 1].startsWith('--') ? flags[next + 1] : null;
461
+ const rest = flags.filter((f, i) => i !== next && !(part && i === next + 1));
462
+ if (!rest.every(f => f === '--changes' || f === '--live')) throw new Error('Usage: watch on [--changes] [--live] --next-tab [url-part]');
463
+ // One wait at a time: a second client's would silently take the first's place.
464
+ if (nextTab && nextTab.client !== state.client) throw new Error(`${waitingText()} already, for ${waiter()}: one at a time. modes shows it; that client's watch off, or modes off, stops it`);
465
+ nextTab = { part, changes: rest.includes('--changes'), live: rest.includes('--live'), client: state.client };
466
+ out.log(`${waitingText()}: it is watched from its first page, and selected. watch off stops waiting.`);
467
+ return;
468
+ }
469
+ if (sub !== 'on' && arg !== 'off' && waitingForMe() && (!state.page || state.page.isClosed())) {
470
+ out.log(`${waitingText()} (watch on --next-tab); it is selected once it opens, and watch then shows its steps.`);
471
+ return;
472
+ }
473
+ if (sub === 'on' && flags.every(f => f === '--changes' || f === '--live')) {
474
+ if (!state.page || state.page.isClosed()) throw new Error(NO_TAB);
475
+ const changes = flags.includes('--changes');
476
+ const live = flags.includes('--live');
477
+ await startWatching(state.page, changes, live);
478
+ tabState(state.page).watch.owner = state.client;
479
+ 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)`);
480
+ 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.');
481
+ return;
482
+ }
483
+ if (arg === 'off') {
484
+ if (nextTab && nextTab.client === state.client) { nextTab = null; out.log('Stopped waiting to watch a new tab'); }
485
+ else if (nextTab) out.log(`${waitingText()}, for ${waiter()}, is left on: watch off stops only your own`);
486
+ if (!state.page || state.page.isClosed()) return;
487
+ if (!watch?.on) { out.log('Not watching the selected tab.'); return; }
488
+ stopWatching(state.page, watch);
489
+ const steps = watch.events.length;
490
+ out.log(`Stopped watching the selected tab${steps ? `; ${steps} step${steps === 1 ? '' : 's'} recorded (watch shows them)` : ''}`);
491
+ return;
492
+ }
493
+ if (!arg) return showWatch(watch, all);
494
+ const usage = `Usage: watch on [--changes] [--live] | watch off | watch [n] | watch new (n from 1 to ${WATCH_MAX})`;
495
+ const onlyNew = arg === 'new';
496
+ if (arg && !onlyNew && !/^\d+$/.test(arg)) throw new Error(usage);
497
+ const count = onlyNew ? RECENT_DEFAULT : Number(arg);
498
+ if (count < 1 || count > WATCH_MAX) throw new Error(usage);
499
+ if (!watch || !watch.events.length) { out.log(watch?.on ? 'Watching; nothing has happened yet' : 'Not watching the selected tab; watch on starts it'); return; }
500
+ const causedBy = requestsByStep(watch);
501
+ const lines = [];
502
+ const printStep = (e, caused, note = '') => {
503
+ lines.push(...stepLines(e, caused, note, !(onlyNew && e.changesShown)));
504
+ if (onlyNew && e.changes) e.changesShown = true;
505
+ };
506
+ let events;
507
+ if (onlyNew) {
508
+ // Requests and changes can arrive after their step was read; they are shown under it again.
509
+ const lastShown = watch.events.find(e => e.seq === watch.shownSeq);
510
+ const late = lastShown ? causedBy(lastShown).filter(r => r.id > watch.shownRequestId) : [];
511
+ const lateChanges = lastShown?.changes && !lastShown.changesShown;
512
+ if (late.length || lateChanges) printStep(lastShown, late, ' (continued)');
513
+ events = watch.events.filter(e => e.seq > watch.shownSeq);
514
+ if (!events.length && !late.length && !lateChanges) lines.push('No new steps');
515
+ } else {
516
+ events = watch.events.slice(-count);
517
+ }
518
+ for (const e of events) printStep(e, causedBy(e));
519
+ // Only watch new keeps track of what has been read.
520
+ if (onlyNew) {
521
+ const last = watch.events[watch.events.length - 1];
522
+ watch.shownSeq = last.seq;
523
+ watch.shownRequestId = Math.max(watch.shownRequestId, ...causedBy(last).map(r => r.id));
524
+ }
525
+ if (!watch.on) lines.push('[watch is off]');
526
+ printOutput(lines.join('\n'), all);
527
+ },
528
+ };
529
+
530
+ module.exports = { commands, summarizeChanges, scrubEditable, nextTabWanted, stopWaitingForNextTab, waitingForMe, waitingText, watchNewTab, stopWatching };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pw-repl",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Text REPL for driving an existing Chromium through Playwright over CDP",
5
5
  "keywords": [
6
6
  "playwright",