pw-repl 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/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.4.2",
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",
package/skill/SKILL.md CHANGED
@@ -1,16 +1,30 @@
1
1
  ---
2
2
  name: pw-repl
3
- description: Inspect and drive a Chromium browser, possibly one a person is using, through the pw-repl (playwright-repl) REPL - tabs, page snapshots, clicks and typing, requests and their bodies, console messages, waiting for pages and elements, choosing files, faking, patching or delaying responses, cutting or slowing the network, emulating a phone, dark mode, a locale or a timezone, and recording what the person does. Use when asked to look at, debug or test something in a web page, in an existing Chromium with remote debugging or in one it starts itself.
4
- allowed-tools: Bash(pw-repl:*) Bash(npx pw-repl:*)
3
+ description: Inspect and drive a Chromium browser, possibly one a person is using, through the pw-repl (playwright-repl) REPL - tabs, page snapshots, clicks and typing, the mouse at a point (moves, buttons, wheel), requests and their bodies, console messages, waiting for pages and elements, choosing files, faking, patching or delaying responses, cutting or slowing the network, emulating a phone, dark mode, a locale or a timezone, and recording what the person does. Use when asked to look at, debug or test something in a web page, in an existing Chromium with remote debugging or in one it starts itself.
4
+ allowed-tools: Bash(pw-repl:*) Bash(npx pw-repl@latest:*)
5
5
  ---
6
6
 
7
7
  # pw-repl
8
8
 
9
+ <!-- pw-repl skill stamp -->
10
+
9
11
  pw-repl (playwright-repl) is a REPL that drives a running Chromium over CDP. The browser may be a
10
12
  shared session: a person can have their own tabs open in it and be using it while you work.
11
13
 
12
- `pw-repl` below is the command the `pw-repl` npm package installs. Without a global install,
13
- `npx pw-repl` works the same; from a clone, `<clone>/bin/pw-repl.js`.
14
+ `pw-repl` below is the command the `pw-repl` npm package installs. Without a global install, use
15
+ `npx pw-repl@latest` in its place, in that same form for every command, so each one runs the same
16
+ version; from a clone, `<clone>/bin/pw-repl.js`.
17
+
18
+ `pw-repl where` also names the skill of the pw-repl you run: `skill: e5f6a7b8 (pw-repl 0.4.3)`. When that
19
+ is not the skill named at the top of this file, tell the user before going on:
20
+
21
+ - if this file's pw-repl version is newer than that one, pw-repl is behind: say "⚠️ pw-repl is older
22
+ than this skill (pw-repl <that version>, skill from <this file's>); `npx pw-repl@latest` or
23
+ `npm install -g pw-repl` updates it."
24
+ - otherwise, the skill is: ask "⚠️ This pw-repl skill is out of date (from pw-repl <this file's
25
+ version>; <that version> has a newer one). Update it? Everything but its Custom rules section will be
26
+ replaced." On a yes, save what `pw-repl skill` prints over this file, with this file's Custom rules
27
+ section in place of the new one's.
14
28
 
15
29
  ## Arguments
16
30
 
@@ -20,15 +34,18 @@ the sections below are how to carry it out. With no args, get a REPL running and
20
34
 
21
35
  ## Start it
22
36
 
23
- `pw-repl where` says whether a REPL is running and how `send` reaches it. There are three ways to run
24
- one; each takes an optional start URL, which opens in a new tab.
37
+ `pw-repl where` says whether a REPL is running and how `send` reaches it, or that one is still starting
38
+ (retry shortly; it exits 64 until the REPL serves, as when none is running). Right after you start one,
39
+ for a moment before its process is up, it can still say none is there: retry for a few seconds before
40
+ taking that as a failed start. There are three ways to run one; each takes an optional start URL, which
41
+ opens in a new tab.
25
42
 
26
43
  - `pw-repl serve --background` runs it detached, with a command server on `/tmp/playwright-repl.sock`
27
44
  (owner-only) and its output in `/tmp/playwright-repl.log`. `pw-repl attach` shows everything it does
28
45
  and takes commands; `pw-repl stop` stops it. It returns once the REPL serves; Ctrl-C before then gives
29
46
  up on the start, stops the REPL, exits 130, and says so in the log.
30
47
  - `pw-repl serve` runs the same in a terminal, where the pane shows every command. Its prompt is
31
- `pw[serve]>`.
48
+ `pw[serve]>`. It stops at that prompt (`quit`, or Ctrl-C); `pw-repl stop` is for a background one.
32
49
  - Add `--launch` to `run` or `serve` (with or without `--background`) to have it start a Chromium of its
33
50
  own instead of connecting to one: headless unless `--headed`, in a temporary profile, on a free port of
34
51
  its own (never 9222). It stops with the REPL, Ctrl-C while it starts included, and `stop` returns once
@@ -79,11 +96,12 @@ pw-repl send tab
79
96
  pw-repl send -t 90 'screenshot -d 60' # wait longer than the 20s default
80
97
  ```
81
98
 
82
- Always send commands with `pw-repl send`; don't type into the pane yourself. The words after `send`
83
- are the command. For `fill`, `type`, `select`, `press` and `upload`, a word quoted in your shell stays
84
- one word (`pw-repl send fill "text=Your name" Ada`); other commands get the words as they are, joined by
85
- spaces (`pw-repl send eval "document.title + ' x'"`). A command that takes only a selector takes the whole
86
- line, spaces and all: `pw-repl send click "text=Your name"`. It works however the REPL was started:
99
+ Always send commands with `pw-repl send`; don't type into the pane yourself, except `quit` at the prompt
100
+ of a REPL of your own, which `send` refuses. The words after `send` are the command. For `fill`, `type`,
101
+ `select`, `press` and `upload`, a word quoted in your shell stays one word
102
+ (`pw-repl send fill "text=Your name" Ada`); other commands get the words as they are, joined by spaces
103
+ (`pw-repl send eval "document.title + ' x'"`). A command that takes only a selector takes the whole line,
104
+ spaces and all: `pw-repl send click "text=Your name"`. It works however the REPL was started:
87
105
 
88
106
  - `run` (in tmux): `send` types the command into the tmux pane and reads the result back off the screen.
89
107
  It types only when the pane's last line is a bare prompt, so nothing lands in a shell or in the middle
@@ -166,6 +184,11 @@ pw-repl send -c <name> close # your modes off; tab close
166
184
  lists what is on in every tab (the prompt shows the selected tab's, e.g. `(watch routes:1) pw>`);
167
185
  `modes off` turns off every mode in every tab.
168
186
  - Someone may be attached to the REPL's tmux session; killing the session ends it for them too.
187
+ - pw-repl does not record video. For one of a flow in a browser of its own, Microsoft's playwright-cli (a
188
+ separate tool, the npm package `@playwright/cli`: `video-start`, `video-stop`); for this browser as the
189
+ person sees it, a screen recorder (`ffmpeg -f x11grab`, macOS `screencapture -v`, OBS). A screen
190
+ recorder records their whole screen, not the tab: ask before starting one, or have them start it. A
191
+ headless browser is not on the screen, so it does not show.
169
192
 
170
193
  ## Environment
171
194