pw-repl 0.10.0 → 0.11.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 CHANGED
@@ -2,19 +2,24 @@
2
2
  const { state, clientRecord } = require('./state');
3
3
  const out = require('./output');
4
4
  const { printOutput } = out;
5
+ const fs = require('fs');
6
+ const path = require('path');
5
7
  const { clipText, clock, hints, NO_TAB } = require('./util');
6
8
  const { tabState } = require('./tabstate');
7
9
  const { keptSession, keepDrawing } = require('./cdp');
8
10
  const { RECENT_HIDDEN_TYPES, keepBodiesDurable, keep, WATCH_HIDDEN_TYPES, RECENT_DEFAULT } = require('./requestlog');
9
11
  const { compactSnapshot } = require('./inspect');
10
12
 
11
- // Opt-in record of what the person at the browser does, so an agent can see
13
+ // Opt-in record of what the user at the browser does, so an agent can see
12
14
  // the steps and the requests each one caused. Values typed into fields are
13
15
  // never recorded, and password fields not at all.
14
16
  const WATCH_MAX = 200;
15
17
  const WATCH_REQUESTS_SHOWN = 5;
16
18
 
17
19
  const TYPING_BACKDATE_MAX = 60000;
20
+ const WATCH_FLAGS = new Set(['--changes', '--live', '--no-values']);
21
+ const PW_SETTINGS = ['PW_CDP_URL', 'PW_CHROME', 'PW_CLIENT', 'PW_ENDPOINT', 'PW_FFMPEG', 'PW_REPL_PID_FILE', 'PW_SCREENSHOT_DIR',
22
+ 'PW_SOCKET', 'PW_START_URL', 'PW_TEST_CHROME', 'PW_TMUX_SESSION'];
18
23
  const PAGE_ACTIONS = new Set(['click', 'check', 'uncheck', 'select', 'fill', 'type', 'press', 'submit']);
19
24
  const WATCH_BINDING = '__pwReplWatch';
20
25
 
@@ -37,7 +42,7 @@ const WATCH_SCRIPT = `(() => {
37
42
  if (/^h[1-6]$/.test(tag)) return 'heading';
38
43
  return tag;
39
44
  };
40
- // Text inside these may be what the person typed, so it is never used as a name.
45
+ // Text inside these may be what the user typed, so it is never used as a name.
41
46
  const typedInto = el => el.matches('input,select,textarea,form') || el.isContentEditable || !!el.closest('[contenteditable]:not([contenteditable=false])');
42
47
  const isPassword = el => el.matches('input[type=password]') || (el.control && el.control.matches('input[type=password]'));
43
48
  // An element holding an editable area would be named partly by what was typed there.
@@ -57,15 +62,104 @@ const WATCH_SCRIPT = `(() => {
57
62
  || el.getAttribute('placeholder') || el.getAttribute('title') || (typedInto(el) || hasEditable(el) ? '' : el.innerText));
58
63
  };
59
64
  const describe = el => { const name = nameOf(el); return roleOf(el) + (name ? ' ' + JSON.stringify(name) : ''); };
65
+ // A field that was a password field while watched stays one when a "show password" button makes it
66
+ // text; and some take secrets under another type. Their values never leave the page.
67
+ const secrets = new WeakSet();
68
+ const SECRET_AUTOCOMPLETE = /\\b(?:current-password|new-password|one-time-code|cc-number|cc-csc)\\b/;
69
+ const notePasswords = () => { for (const el of document.querySelectorAll('input[type=password]')) secrets.add(el); };
70
+ notePasswords();
71
+ document.addEventListener('focusin', notePasswords, true);
72
+ const isSecret = el => !!el && (secrets.has(el) || (el.matches && el.matches('input[type=password]')) || SECRET_AUTOCOMPLETE.test((el.getAttribute && el.getAttribute('autocomplete')) || ''));
73
+ const valueOf = el => (el.isContentEditable ? el.innerText : el.value);
74
+ // A selector that finds this element and no other, as Playwright reads it, for watch save. Playwright's
75
+ // CSS goes into open shadow roots, so matches are counted in them too; inside one, the element's own
76
+ // selector there follows its host's, which a descendant combinator crosses into.
77
+ const shadowRoots = scope => {
78
+ const found = [scope];
79
+ for (let i = 0; i < found.length; i++) for (const el of found[i].querySelectorAll('*')) if (el.shadowRoot) found.push(el.shadowRoot);
80
+ return found;
81
+ };
82
+ const textOf = el => (el.textContent || '').replace(/\\s+/g, ' ').trim();
83
+ const cssString = text => '"' + text.replace(/\\\\/g, '\\\\\\\\').replace(/"/g, '\\\\"') + '"';
84
+ const selectorIn = (el, scope) => {
85
+ const roots = shadowRoots(scope);
86
+ const count = sel => { let n = 0; for (const r of roots) n += r.querySelectorAll(sel).length; return n; };
87
+ const tag = el.localName;
88
+ if (el.id && count('#' + CSS.escape(el.id)) === 1) return '#' + CSS.escape(el.id);
89
+ const testid = el.getAttribute('data-testid');
90
+ if (testid && count('[data-testid=' + cssString(testid) + ']') === 1) return '[data-testid=' + cssString(testid) + ']';
91
+ const name = el.getAttribute('name');
92
+ if (name && /^(?:input|select|textarea)$/.test(tag) && count(tag + '[name=' + cssString(name) + ']') === 1) return tag + '[name=' + cssString(name) + ']';
93
+ const text = textOf(el);
94
+ if (text && text.length <= 60 && !el.querySelector('input,select,textarea')) {
95
+ let same = 0;
96
+ for (const r of roots) for (const other of r.querySelectorAll(tag)) if (textOf(other) === text) same++;
97
+ if (same === 1) return tag + ':text-is(' + cssString(text) + ')';
98
+ }
99
+ // Its place: the path down from the nearest ancestor with an id of its own, or from the top.
100
+ const steps = [];
101
+ for (let at = el; at && at.parentNode; at = at.parentNode) {
102
+ if (at !== el && at.id && count('#' + CSS.escape(at.id)) === 1) { steps.unshift('#' + CSS.escape(at.id)); break; }
103
+ if (at.localName === 'body') { steps.unshift('body'); break; }
104
+ const same = [...at.parentNode.children].filter(c => c.localName === at.localName);
105
+ steps.unshift(same.length > 1 ? at.localName + ':nth-of-type(' + (same.indexOf(at) + 1) + ')' : at.localName);
106
+ if (!(at.parentNode instanceof Element)) break;
107
+ }
108
+ return steps.join(' > ');
109
+ };
110
+ const partsOf = el => {
111
+ const root = el.getRootNode();
112
+ if (root === document) return [selectorIn(el, document)];
113
+ if (!(root instanceof ShadowRoot) || root.mode !== 'open') return null;
114
+ const host = partsOf(root.host);
115
+ return host && [...host, selectorIn(el, root)];
116
+ };
117
+ // Each part matched in the element's own tree, the one before it by an ancestor, its host's included,
118
+ // as Playwright reads a descendant combinator. A path inside a shadow root is anchored to nothing
119
+ // there, so it could match deeper in the host: the whole selector is counted before it is used.
120
+ const composedParent = el => el.parentElement || (el.parentNode instanceof ShadowRoot ? el.parentNode.host : null);
121
+ const TEXT_IS = /^([a-z][\\w-]*):text-is\\("((?:[^"\\\\]|\\\\.)*)"\\)$/;
122
+ const matchesPart = (el, part) => {
123
+ const text = part.match(TEXT_IS);
124
+ return text ? el.localName === text[1] && textOf(el) === text[2].replace(/\\\\(.)/g, '$1') : el.matches(part);
125
+ };
126
+ const matchesParts = (el, parts) => {
127
+ if (!matchesPart(el, parts[parts.length - 1])) return false;
128
+ if (parts.length === 1) return true;
129
+ const rest = parts.slice(0, -1);
130
+ for (let at = composedParent(el); at; at = composedParent(at)) if (matchesParts(at, rest)) return true;
131
+ return false;
132
+ };
133
+ const selectorOf = el => {
134
+ if (window !== window.top) return null;
135
+ const parts = partsOf(el);
136
+ if (!parts) return null;
137
+ let n = 0;
138
+ for (const r of shadowRoots(document)) for (const other of r.querySelectorAll('*')) if (matchesParts(other, parts) && ++n > 1) return null;
139
+ return n === 1 ? parts.join(' ') : null;
140
+ };
60
141
  // el null: the page itself, e.g. Escape with nothing focused.
61
142
  const send = (action, el, extra, since) => {
62
143
  // Typing still waiting for its pause is recorded first, so the steps stay in order.
63
144
  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 }));
145
+ if (typeof window.__pwReplWatch !== 'function') return;
146
+ let selector = null;
147
+ try { selector = el ? selectorOf(el) : null; } catch {}
148
+ const typed = (action === 'type' || action === 'fill') && el;
149
+ const secret = typed && isSecret(el);
150
+ const option = action === 'select' ? el.value : null;
151
+ window.__pwReplWatch(JSON.stringify({ action, target: el ? describe(el) : 'page', extra: extra || '', since: since || 0, selector, secret, value: typed && !secret ? valueOf(el) : null, option }));
65
152
  };
153
+ // The element itself, not its component's host, when it is inside an open shadow root.
154
+ const targetOf = e => (e.composedPath && e.composedPath()[0]) || e.target;
155
+ // Enter in a form's field submits it with a click on its submit button, which is the Enter's doing:
156
+ // recorded too, it would submit the form again when the steps run again.
157
+ let enteredForm = null;
66
158
  document.addEventListener('click', e => {
67
- const el = e.target.closest ? (e.target.closest(INTERACTIVE) || e.target) : e.target;
159
+ const t = targetOf(e);
160
+ const el = t.closest ? (t.closest(INTERACTIVE) || t) : t;
68
161
  if (!el.matches || el.matches('input[type=checkbox],input[type=radio],select') || isPassword(el)) return;
162
+ if (e.detail === 0 && enteredForm && el.form === enteredForm) return;
69
163
  send('click', el);
70
164
  }, true);
71
165
  // Typing is recorded once it pauses, not on change, so a typeahead's requests
@@ -87,25 +181,30 @@ const WATCH_SCRIPT = `(() => {
87
181
  const editable = el => el.isContentEditable ? (el.closest('[contenteditable]:not([contenteditable=false])') || el) : null;
88
182
  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
183
  document.addEventListener('input', e => {
90
- const el = editable(e.target) || e.target;
91
- if (!el.matches || isPassword(el) || !(textEntry(el) || el.isContentEditable)) return;
184
+ const el = editable(targetOf(e)) || targetOf(e);
185
+ if (!el.matches || !(textEntry(el) || el.isContentEditable)) return;
186
+ // Typing here ends typing anywhere else, recorded first: one that ended with no change event (the
187
+ // value it had before) would otherwise wait out its pause, and come after the fields typed since.
188
+ for (const field of [...pending.keys()]) if (field !== el) flushTyping(field);
92
189
  const start = pending.has(el) ? pending.get(el).start : Date.now();
93
190
  clearTimeout(pending.get(el)?.timer);
94
191
  pending.set(el, { start, timer: setTimeout(() => flushTyping(el), TYPING_PAUSE) });
95
192
  }, true);
96
193
  document.addEventListener('keydown', e => {
97
194
  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;
195
+ const t = targetOf(e);
196
+ if (!t.matches) return;
197
+ const el = editable(t) || t.closest(INTERACTIVE);
101
198
  // Enter on a button or link is recorded as the click it causes.
102
199
  if (e.key === 'Enter' && !(el && (textEntry(el) || el.isContentEditable))) return;
103
200
  if (el) flushTyping(el);
104
201
  send('press', el, e.key);
202
+ // The click comes in the same task as the key, if at all.
203
+ if (e.key === 'Enter' && el && el.form) { enteredForm = el.form; setTimeout(() => { enteredForm = null; }); }
105
204
  }, true);
106
205
  document.addEventListener('change', e => {
107
- const el = e.target;
108
- if (!el.matches || isPassword(el)) return;
206
+ const el = targetOf(e);
207
+ if (!el.matches) return;
109
208
  if (el.matches('input[type=checkbox],input[type=radio]')) return send(el.checked ? 'check' : 'uncheck', el);
110
209
  if (el.matches('select')) return send('select', el, JSON.stringify(clip(el.selectedOptions[0]?.text)));
111
210
  if (pending.has(el)) { flushTyping(el); typed.delete(el); return; }
@@ -117,7 +216,7 @@ const WATCH_SCRIPT = `(() => {
117
216
 
118
217
  // What changed on screen between two snapshots, in a few lines. Refs, links'
119
218
  // 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
219
+ // be what the user typed. A new or removed element is shown once, with the
121
220
  // first few named things inside it, not line by line.
122
221
  const CHANGES_SHOWN = 5;
123
222
  const CHANGE_WIDTH = 100;
@@ -202,7 +301,7 @@ function summarizeChanges(beforeText, afterText) {
202
301
  const SETTLE_QUIET = 800;
203
302
  const SETTLE_MAX = 3000;
204
303
 
205
- // Text in an editable area (contenteditable) may be what the person typed,
304
+ // Text in an editable area (contenteditable) may be what the user typed,
206
305
  // and the snapshot shows it as ordinary text, so it is blanked out: a line's
207
306
  // text or name is dropped when it is part of an editable area's text.
208
307
  async function snapshotText(p) {
@@ -257,7 +356,7 @@ function printLive(p, watch) {
257
356
  out.aside(lines.map((line, i) => `${i ? ' '.repeat(prefix.length) : prefix}${line}`).join('\n'));
258
357
  }
259
358
 
260
- async function startWatching(p, changes = false, live = false) {
359
+ async function startWatching(p, changes = false, live = false, values = true) {
261
360
  await keepBodiesDurable(p);
262
361
  let watch = tabState(p).watch;
263
362
  if (!watch) {
@@ -265,11 +364,14 @@ async function startWatching(p, changes = false, live = false) {
265
364
  const created = { on: false, startedAt: 0, changes: false, live: false, liveStep: null, snapshot: null, settleTimer: null, events: [], nextSeq: 1, shownSeq: 0, shownRequestId: 0 };
266
365
  // since backdates a step (typing is reported once it pauses), but never to
267
366
  // before the previous step, so the page cannot reorder the record.
268
- const record = (action, target, extra, since = 0) => {
367
+ // more: what watch save needs, the element's selector and a typed value (kept whole), or a navigation's URL.
368
+ const record = (action, target, extra, since = 0, more = {}) => {
269
369
  if (!created.on) return;
270
370
  const previous = created.events[created.events.length - 1];
271
371
  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) };
372
+ const step = { seq: created.nextSeq++, t, action, target: clipText(target), extra: clipText(extra), selector: more.selector || null, secret: !!more.secret, url: more.url };
373
+ if (typeof more.option === 'string') step.option = more.option;
374
+ if (typeof more.value === 'string' && created.values) step.value = more.value;
273
375
  keep(created.events, step);
274
376
  // A step that has not settled yet is printed now: this one ends it.
275
377
  printLive(p, created);
@@ -288,7 +390,10 @@ async function startWatching(p, changes = false, live = false) {
288
390
  if (name !== WATCH_BINDING || !binding.record) return;
289
391
  let event;
290
392
  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);
393
+ if (event && PAGE_ACTIONS.has(event.action)) {
394
+ binding.record(event.action, String(event.target || ''), String(event.extra || ''), event.since,
395
+ { selector: typeof event.selector === 'string' ? event.selector : null, secret: !!event.secret, value: event.value, option: event.option });
396
+ }
292
397
  });
293
398
  // Calls are reported only to a session with Runtime on.
294
399
  await session.send('Runtime.enable');
@@ -297,7 +402,7 @@ async function startWatching(p, changes = false, live = false) {
297
402
  }
298
403
  tabState(p).watchBinding.record = record;
299
404
  await p.addInitScript(WATCH_SCRIPT);
300
- p.on('framenavigated', frame => { if (frame === p.mainFrame()) record('navigate', frame.url(), ''); });
405
+ p.on('framenavigated', frame => { if (frame === p.mainFrame()) record('navigate', frame.url(), '', 0, { url: frame.url() }); });
301
406
  // Only now: a failed first attempt must leave nothing half set up to retry against.
302
407
  tabState(p).watch = created;
303
408
  watch = created;
@@ -311,6 +416,8 @@ async function startWatching(p, changes = false, live = false) {
311
416
  await p.evaluate(WATCH_SCRIPT);
312
417
  watch.changes = changes;
313
418
  watch.live = live;
419
+ watch.values = values;
420
+ if (!watch.on) watch.startUrl = p.url();
314
421
  if (!live) watch.liveStep = null;
315
422
  // The first step's changes are measured from how the page looks now. A page
316
423
  // too busy to snapshot is not a reason to fail: the step after one that
@@ -358,7 +465,7 @@ function watchNewTab(p) {
358
465
  const want = nextTab;
359
466
  tabState(p).watchStarting = true;
360
467
  keepBodiesDurable(p);
361
- startWatching(p, want.changes, want.live).then(() => {
468
+ startWatching(p, want.changes, want.live, want.values).then(() => {
362
469
  delete tabState(p).watchStarting;
363
470
  tabState(p).watch.owner = want.client;
364
471
  const onNavigate = frame => {
@@ -378,7 +485,7 @@ function watchNewTab(p) {
378
485
  // A page that loaded before the watch was ready: its step is added, at its request's time.
379
486
  if (first < 0) {
380
487
  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: '' });
488
+ watch.events.unshift({ seq: watch.nextSeq++, t: request ? request.t : Date.now(), action: 'navigate', target: clipText(url), extra: '', url });
382
489
  }
383
490
  // Selected for the client that asked, whoever's command runs now.
384
491
  const record = clientRecord(want.client);
@@ -412,7 +519,8 @@ function requestsByStep(watch, p = state.page) {
412
519
  }
413
520
 
414
521
  function stepLines(e, caused, note = '', withChanges = true) {
415
- const lines = [`${clock(e.t)} ${e.action} ${e.target}${e.extra ? ` ${e.extra}` : ''}${note}`];
522
+ const typed = e.secret ? ' (a password: not recorded)' : typeof e.value === 'string' ? ` ${JSON.stringify(clipText(e.value))}` : '';
523
+ const lines = [`${clock(e.t)} ${e.action} ${e.target}${typed}${e.extra ? ` ${e.extra}` : ''}${note}`];
416
524
  const indent = ' '.repeat(clock(e.t).length + 1);
417
525
  for (const r of caused.slice(0, WATCH_REQUESTS_SHOWN)) lines.push(`${indent}#${r.id} ${r.method} ${r.status} ${r.url}`);
418
526
  if (caused.length > WATCH_REQUESTS_SHOWN) lines.push(`${indent}… ${caused.length - WATCH_REQUESTS_SHOWN} more (requests)`);
@@ -436,12 +544,66 @@ function showWatch(watch, all) {
436
544
  if (steps > shown.length) lines.push(`(last ${shown.length} of ${steps})`);
437
545
  }
438
546
  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']]));
547
+ const save = ['watch save <file>', 'save them as commands, for send --file to run again'];
548
+ if (watch?.on) lines.push(hints([more, ['watch new', 'only the steps not shown by watch new yet'], ...(steps ? [save] : []), ['watch off', 'stop recording']]));
549
+ else if (steps) lines.push(hints([more, save, ['watch on [--changes] [--live]', 'record again']]));
441
550
  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
551
  printOutput(lines.join('\n'), all);
443
552
  }
444
553
 
554
+ // watch save: the steps as commands for send --file to run again, a step that cannot be one (a value
555
+ // with a line break, an element in a frame) as a comment saying so. What was typed into a password field,
556
+ // or with --no-values, is a {{ PW_NAME }} variable, named after the field, that send --file fills in. A
557
+ // selector is quoted as lib/syntax.js reads one: single quotes, which escape nothing, unless it holds
558
+ // one. A value is always double-quoted, so an empty one, or one that starts with -, stays the value.
559
+ function savedCommands(watch, p) {
560
+ const sel = text => (text.includes("'") ? `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` : `'${text}'`);
561
+ const value = text => `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
562
+ const documents = (tabState(p).log || []).filter(r => r.navigation);
563
+ const events = watch.events.slice();
564
+ const first = events[0]?.action === 'navigate' ? events.shift().url : watch.startUrl;
565
+ const lines = [`goto ${first}`];
566
+ let skipped = 0;
567
+ const variables = new Map();
568
+ // PW_, so a field named User or pwd is not filled with the shell's USER or PWD; and never pw-repl's own.
569
+ const variableFor = e => {
570
+ if (variables.has(e.selector)) return variables.get(e.selector);
571
+ const label = /"([^"]+)"$/.exec(e.target)?.[1] || /(?:#|name=")([\w-]+)/.exec(e.selector)?.[1] || 'TEXT';
572
+ const base = `PW_${label.toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'TEXT'}`;
573
+ const taken = new Set([...variables.values(), ...PW_SETTINGS]);
574
+ let name = base;
575
+ for (let n = 2; taken.has(name); n++) name = `${base}_${n}`;
576
+ variables.set(e.selector, name);
577
+ return name;
578
+ };
579
+ const comment = (e, why) => { skipped += 1; lines[lines.length - 1] += `: ${why}`; };
580
+ for (const [i, e] of events.entries()) {
581
+ const previous = events[i - 1];
582
+ if (e.action === 'navigate') {
583
+ // The page an action led to: waited for if it loaded a document, not if the app only changed its URL.
584
+ if (previous && previous.action !== 'navigate') {
585
+ const loaded = documents.some(r => r.url === e.url && r.t >= previous.t && r.t <= e.t + 1000);
586
+ lines.push(loaded ? `wait load` : `# the app went to ${e.url} without loading a page`);
587
+ } else {
588
+ lines.push(`goto ${e.url}`);
589
+ }
590
+ continue;
591
+ }
592
+ if (e.action === 'submit') continue;
593
+ lines.push(`# ${e.action} ${e.target}${e.extra ? ` ${e.extra}` : ''}`);
594
+ if (!e.selector && !(e.action === 'press' && e.target === 'page')) { comment(e, 'no selector finds it alone: it is in a frame or a closed shadow root, or its place matches others too'); continue; }
595
+ if (e.action === 'click' || e.action === 'check' || e.action === 'uncheck') lines.push(`${e.action} ${sel(e.selector)}`);
596
+ else if (e.action === 'select' && typeof e.option !== 'string') comment(e, 'the option was not recorded');
597
+ else if (e.action === 'select' && /[\r\n]/.test(e.option)) comment(e, 'the option\'s value has a line break, which a line of a file cannot hold');
598
+ else if (e.action === 'select') lines.push(`select ${sel(e.selector)} ${value(e.option)}`);
599
+ else if (e.action === 'press') lines.push(e.target === 'page' ? `press ${e.extra}` : `press ${sel(e.selector)} ${e.extra}`);
600
+ else if (e.secret || typeof e.value !== 'string') lines.push(`fill ${sel(e.selector)} "{{ ${variableFor(e)} }}"`);
601
+ else if (/[\r\n]/.test(e.value)) comment(e, 'what was typed has a line break, which a line of a file cannot hold: type it with type, line by line, pressing Enter');
602
+ else lines.push(`fill ${sel(e.selector)} ${value(e.value)}`);
603
+ }
604
+ return { lines, skipped, variables: [...new Set(variables.values())] };
605
+ }
606
+
445
607
  function stopWatching(p, watch) {
446
608
  clearTimeout(watch.settleTimer);
447
609
  printLive(p, watch);
@@ -459,10 +621,10 @@ const commands = {
459
621
  if (sub === 'on' && next !== -1) {
460
622
  const part = flags[next + 1] && !flags[next + 1].startsWith('--') ? flags[next + 1] : null;
461
623
  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]');
624
+ if (!rest.every(f => WATCH_FLAGS.has(f))) throw new Error('Usage: watch on [--changes] [--live] [--no-values] --next-tab [url-part]');
463
625
  // One wait at a time: a second client's would silently take the first's place.
464
626
  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 };
627
+ nextTab = { part, changes: rest.includes('--changes'), live: rest.includes('--live'), values: !rest.includes('--no-values'), client: state.client };
466
628
  out.log(`${waitingText()}: it is watched from its first page, and selected. watch off stops waiting.`);
467
629
  return;
468
630
  }
@@ -470,13 +632,15 @@ const commands = {
470
632
  out.log(`${waitingText()} (watch on --next-tab); it is selected once it opens, and watch then shows its steps.`);
471
633
  return;
472
634
  }
473
- if (sub === 'on' && flags.every(f => f === '--changes' || f === '--live')) {
635
+ if (sub === 'on' && flags.every(f => WATCH_FLAGS.has(f))) {
474
636
  if (!state.page || state.page.isClosed()) throw new Error(NO_TAB);
475
637
  const changes = flags.includes('--changes');
476
638
  const live = flags.includes('--live');
477
- await startWatching(state.page, changes, live);
639
+ const values = !flags.includes('--no-values');
640
+ await startWatching(state.page, changes, live, values);
478
641
  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)`);
642
+ out.log(`Watching the selected tab: clicks, typing, form changes, submits and navigations${changes ? ', and what each changed on screen' : ''}`);
643
+ out.log(values ? 'What is typed is recorded, except in password fields; --no-values leaves it out.' : 'What is typed is not recorded (--no-values).');
480
644
  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
645
  return;
482
646
  }
@@ -491,7 +655,24 @@ const commands = {
491
655
  return;
492
656
  }
493
657
  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})`;
658
+ if (sub === 'save') {
659
+ const file = arg.slice(4).trim();
660
+ if (!file) throw new Error('Usage: watch save <file>, e.g. watch save flow.txt; pw-repl send --file flow.txt runs it');
661
+ if (!watch || !watch.events.length) throw new Error('Nothing to save: watch on records the steps first');
662
+ if (fs.existsSync(file)) throw new Error(`${file} exists already; name another file`);
663
+ const { lines, skipped, variables } = savedCommands(watch, state.page);
664
+ const run = `${variables.map(name => `${name}=... `).join('')}pw-repl send --file ${path.basename(file)}`;
665
+ const header = [`# pw-repl watch, saved ${new Date().toISOString()} from ${state.page.url()}`, `# ${run} runs it; a line starting with # is not run`];
666
+ const where = watch.values ? 'password fields' : 'fields (watch on --no-values)';
667
+ if (variables.length) header.push(`# It needs ${variables.join(', ')} set in the environment: what was typed into ${where}, not saved here`);
668
+ // It holds what was typed, so it is the user's own to read.
669
+ fs.writeFileSync(file, `${[...header, ...lines].join('\n')}\n`, { mode: 0o600, flag: 'wx' });
670
+ out.log(`Saved ${watch.events.length} step${watch.events.length === 1 ? '' : 's'} to ${file}${skipped ? `; ${skipped} could not be written as commands, and are comments there saying why` : ''}.`);
671
+ if (variables.length) out.log(`What was typed into ${where} is a variable there, filled in from the environment: ${variables.map(name => `{{ ${name} }}`).join(', ')}.`);
672
+ out.log(`${variables.map(name => `${name}=... `).join('')}pw-repl send --file ${file} runs them.`);
673
+ return;
674
+ }
675
+ const usage = `Usage: watch on [--changes] [--live] [--no-values] | watch off | watch [n] | watch new | watch save <file> (n from 1 to ${WATCH_MAX})`;
495
676
  const onlyNew = arg === 'new';
496
677
  if (arg && !onlyNew && !/^\d+$/.test(arg)) throw new Error(usage);
497
678
  const count = onlyNew ? RECENT_DEFAULT : Number(arg);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pw-repl",
3
- "version": "0.10.0",
3
+ "version": "0.11.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,6 +1,6 @@
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, 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, recording what the person does, and recording a video of a tab. 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.
3
+ description: Inspect and drive a Chromium browser, possibly one the user 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, recording what the user does, and recording a video of a tab, with a pointer and on-screen captions. 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
4
  allowed-tools: Bash(pw-repl:*) Bash(npx pw-repl@latest:*)
5
5
  ---
6
6
 
@@ -9,15 +9,17 @@ allowed-tools: Bash(pw-repl:*) Bash(npx pw-repl@latest:*)
9
9
  <!-- pw-repl skill stamp -->
10
10
 
11
11
  pw-repl (playwright-repl) is a REPL that drives a running Chromium over CDP. The browser may be a
12
- shared session: a person can have their own tabs open in it and be using it while you work.
12
+ shared session: the user can have their own tabs open in it and be using it while you work.
13
13
 
14
14
  `pw-repl` below is the command the `pw-repl` npm package installs. Without a global install, use
15
15
  `npx pw-repl@latest` in its place, in that same form for every command, so each one runs the same
16
16
  version; from a clone, `<clone>/bin/pw-repl.js`.
17
17
 
18
- `pw-repl where` also names the skill of the pw-repl you run: `skill: e5f6a7b8 (pw-repl 0.4.3)`. When
19
- that is not the skill named at the top of this file, tell the user once, in these words and in passing,
20
- with whatever else you are telling them, and carry on with the task:
18
+ `pw-repl where` also names the skill of the pw-repl you run: `skill: e5f6a7b8 (pw-repl 0.4.3)`. Compare
19
+ its hash (`e5f6a7b8`) with the one at the top of this file. The same hash is the same skill, whatever
20
+ the versions say, since a release that leaves the skill as it was keeps its hash: say nothing. When the
21
+ hashes differ, tell the user once, in these words and in passing, with whatever else you are telling
22
+ them, and carry on with the task:
21
23
 
22
24
  - if this file's pw-repl version is newer than that one, pw-repl is behind: "🔄 pw-repl is older than
23
25
  this skill (pw-repl <that version>, skill from <this file's>); `npx pw-repl@latest` or
@@ -50,12 +52,12 @@ run one; each takes an optional start URL, which opens in a new tab.
50
52
  - `pw-repl serve` runs the same in a terminal, where the pane shows every command. Its prompt is
51
53
  `pw[serve]>`. It stops at that prompt (`quit`, or Ctrl-C); `pw-repl stop` is for a background one.
52
54
  - Add `--launch` to `run` or `serve` (with or without `--background`) to have it start a Chromium of its
53
- own instead of connecting to one: headless unless `--headed`, in a temporary profile, on a free port of
54
- its own (never 9222). It stops with the REPL, Ctrl-C while it starts included, and `stop` returns once
55
- it has exited. It prints the command it ran; flags after `--` are passed to that Chromium, and a
56
- `--user-data-dir=<dir>` among them is used instead of the temporary profile, and kept. Started again in
57
- a kept profile, Chromium picks up where it left off, as it would for a person, headless too: its tabs
58
- come back, and session cookies with them, so a login stays. They start loading before the REPL is
55
+ own instead of connecting to one: headless unless `--headed`, in a temporary profile, on a free port
56
+ of its own (never 9222). It stops with the REPL, Ctrl-C while it starts included, and `stop` returns
57
+ once it has exited. It prints the command it ran; flags after `--` are passed to that Chromium, and a
58
+ `--user-data-dir=<dir>` among them is used instead of the temporary profile, and kept. Started again
59
+ in a kept profile, Chromium picks up where it left off, as it would for the user, headless too: its
60
+ tabs come back, and session cookies with them, so a login stays. They start loading before the REPL is
59
61
  attached, so `requests` and `console` have only what came after, often nothing: `reload` for a full
60
62
  record.
61
63
  - `pw-repl run` runs it in a terminal with no server; `send` then reaches it through tmux, if it runs in
@@ -74,14 +76,15 @@ pw-repl send -e /tmp/mine.sock tab new http://localhost:3000
74
76
  pw-repl stop -e /tmp/mine.sock
75
77
  ```
76
78
 
77
- A socket file left by a REPL that died is replaced when the next one starts there. The log of a background REPL is next to its socket
78
- (`/tmp/mine.log`), appended to run after run, with a line where each starts and stops (past 5 MB, a
79
- start moves it to `/tmp/mine.log.1`); `tail -f` on it follows along without a terminal to attach from.
80
- `serve <port>` listens on TCP 127.0.0.1 instead of a socket, with no access control.
79
+ A socket file left by a REPL that died is replaced when the next one starts there. The log of a
80
+ background REPL is next to its socket (`/tmp/mine.log`), appended to run after run, with a line where
81
+ each starts and stops (past 5 MB, a start moves it to `/tmp/mine.log.1`); `tail -f` on it follows along
82
+ without a terminal to attach from. `serve <port>` listens on TCP 127.0.0.1 instead of a socket, with no
83
+ access control.
81
84
 
82
85
  ### One REPL, many clients
83
86
 
84
- One REPL serves everyone using its browser: the person at its prompt and any number of agents. When
87
+ One REPL serves everyone using its browser: the user at its prompt and any number of agents. When
85
88
  `pw-repl where` finds one running for the browser you need, use it rather than start another, as a
86
89
  client of your own: `PW_CLIENT=<name>` (or `send -c <name>`), with a name that says who you are.
87
90
 
@@ -107,9 +110,10 @@ pw-repl send -t 90 'screenshot -d 60' # wait longer than the 20s default
107
110
  Always send commands with `pw-repl send`; don't type into the pane yourself, except `quit` at the prompt
108
111
  of a REPL of your own, which `send` refuses. The words after `send` are the command. For `fill`, `type`,
109
112
  `select`, `press` and `upload`, a word quoted in your shell stays one word
110
- (`pw-repl send fill "text=Your name" Ada`); other commands get the words as they are, joined by spaces
111
- (`pw-repl send eval "document.title + ' x'"`). A command that takes only a selector takes the whole line,
112
- spaces and all: `pw-repl send click "text=Your name"`. It works however the REPL was started:
113
+ (`pw-repl send fill "text=Your name" Ada`); other commands get the words as they are, joined by
114
+ spaces (`pw-repl send eval "document.title + ' x'"`). A command that takes only a selector takes the
115
+ whole line, spaces and all: `pw-repl send click "text=Your name"`. It works however the REPL was
116
+ started:
113
117
 
114
118
  - `run` (in tmux): `send` types the command into the tmux pane and reads the result back off the screen.
115
119
  It types only when the pane's last line is a bare prompt, so nothing lands in a shell or in the middle
@@ -123,8 +127,14 @@ spaces and all: `pw-repl send click "text=Your name"`. It works however the REPL
123
127
  REPL), or why neither is reachable, without running anything.
124
128
 
125
129
  `send --file take.txt` runs a file of commands, one per line, as typed at the prompt (no shell quoting;
126
- files relative to your folder, as on `send`'s command line; `#` comments), and stops at the first that fails, saying which line.
127
- It is the way to record a video without gaps (`help record`), and to keep a repro to run again.
130
+ files relative to your folder, as on `send`'s command line; `#` comments), and stops at the first that
131
+ fails, saying which line. It is the way to record a video without gaps (`help record`), and to keep a
132
+ repro to run again. A `{{ PW_NAME }}` in a line is the variable PW_NAME from `send`'s environment, best
133
+ in double quotes (`fill '#password' "{{ PW_PASSWORD }}"`): the REPL fills it in as it runs the line, and
134
+ its pane and log show the line as written, so a secret stays out of both the file and the log. Only
135
+ `PW_` names are variables; any other `{{ ... }}` (a page's template text) is sent as written. One that
136
+ is not set stops the file; `--no-vars` sends it as written. A single `send` takes them too: to type a
137
+ password, `PW_PASSWORD=... pw-repl send fill '#password' "{{ PW_PASSWORD }}"` keeps it out of the log.
128
138
 
129
139
  `send` takes one command; chain several in your shell. Use `&&` when a step only makes sense if the one
130
140
  before it worked: it stops at any exit status but 0, a failure and an outcome not confirmed included.
@@ -146,11 +156,15 @@ Every command you run and its output show in the REPL's pane, or in `attach` and
146
156
  background REPL (server commands as `[server]` lines), so whoever looks there sees what you do. The pane
147
157
  shows REPL commands only, not what was clicked in the browser (unless `watch on --live` is on); for
148
158
  that, look at the browser itself: `tab` and `info` for where things are, `requests` for the requests the
149
- clicks made (`body <#>` for what one returned), `console` for console messages and page errors. `watch
150
- on` records each step someone takes in a tab, with the requests it caused (`watch on --changes` adds
151
- what each step changed on the page); `watch` reads it back, and `watch new` only what it has not shown
152
- yet. For a tab they have not opened yet, `watch on --next-tab [url-part]` waits for it and watches it
153
- from its first page.
159
+ clicks made (`body <#>` for what one returned), `console` for console messages and page errors.
160
+ `watch on` records each step someone takes in a tab, with the requests it caused (`watch on --changes`
161
+ adds what each step changed on the page); `watch` reads it back, and `watch new` only what it has not
162
+ shown yet. For a tab they have not opened yet, `watch on --next-tab [url-part]` waits for it and
163
+ watches it from its first page. `watch save <file>` writes the steps as commands that `send --file`
164
+ runs again, a repro to keep. What is typed is recorded, except in password fields; `watch on
165
+ --no-values` leaves it out. The file has a `{{ PW_PASSWORD }}` variable where a password was typed,
166
+ which `send --file` fills in from its environment (`PW_PASSWORD=... pw-repl send --file login.txt`), so
167
+ the file can be shared without it.
154
168
 
155
169
  When watching someone:
156
170
 
@@ -185,7 +199,7 @@ task (`cart-bug`), since two agents with one name are one client:
185
199
  pw-repl send -c <name> open http://localhost:3000 # a tab of your own in the shared browser
186
200
  pw-repl send -c <name> snapshot # no snapshot after each command; ask for one
187
201
  pw-repl send -c <name> click e5
188
- pw-repl send -c <name> close # your modes off; tab close <url-part> closes your tab
202
+ pw-repl send -c <name> close # your modes off; tab close <url-part> closes yours
189
203
  ```
190
204
 
191
205
  - There is no browser of your own to open or close: `open` opens a tab in the one the REPL uses, and
@@ -199,27 +213,27 @@ pw-repl send -c <name> close # your modes off; tab close
199
213
  ## Sharing the browser
200
214
 
201
215
  - The browser may have tabs that are not yours, and someone may be using it. Whether to read or act in
202
- one of those tabs, or to open your own (`tab new <url>`), depends on the task; when that is not
203
- clear, ask.
216
+ one of those tabs, or to open your own (`tab new <url>`), depends on the task; when that is not clear,
217
+ ask. `tab` marks `(in front)` the tab the user sees: "this page" is usually that one. A tab you have
218
+ selected is never marked (help tab says why).
204
219
  - No tab is selected when the REPL starts. A start URL's tab, marked `(the start URL)` in `tab`, is
205
220
  selected at its prompt and for `send` without a client name; a named client (`-c`) selects it with
206
221
  `tab <index>`. Closing a tab the REPL opened goes back to the tab before it, if the REPL opened that
207
- one too; otherwise no tab is selected.
208
- Tab numbers change when tabs open or close; `tab <url-part>` and `tab close <url-part>` pick a tab by
209
- its URL and refuse if it is ambiguous.
210
- - You can answer any dialog yourself, one you opened included: `dialog` shows it, and `dialog accept`
211
- or `dialog dismiss` answers it. Nothing answers one on its own, and while one is open its page and
212
- the commands that read it wait. One answered in the browser is gone from `dialog` too.
222
+ one too; otherwise no tab is selected. Tab numbers change when tabs open or close; `tab <url-part>`
223
+ and `tab close <url-part>` pick a tab by its URL and refuse if it is ambiguous.
224
+ - You can answer any dialog yourself, one you opened included: `dialog` shows it, and `dialog accept` or
225
+ `dialog dismiss` answers it. Nothing answers one on its own, and while one is open its page and the
226
+ commands that read it wait. One answered in the browser is gone from `dialog` too.
213
227
  - Modes and tabs stay on or open until they are turned off or closed, whoever started them. `modes`
214
228
  lists what is on in every tab (the prompt shows the selected tab's, e.g. `(watch routes:1) pw>`);
215
229
  `modes off` turns off every mode in every tab.
216
230
  - Someone may be attached to the REPL's tmux session; killing the session ends it for them too.
217
- - To point something out, to the person or in a screenshot, `highlight <ref|selector>` draws a box over it
231
+ - To point something out, to the user or in a screenshot, `highlight <ref|selector>` draws a box over it
218
232
  without changing the page (`--style=<css>` restyles it; `highlight off` hides them), without
219
233
  Playwright's locator label unless `highlight --labels on`.
220
234
  - For a video, `help video` says how: a take written as a file and sent with `send --file`, `record on`
221
- around it, `cursor on` for a pointer, and the pacing recording adds for the viewer; a screen recorder on
222
- the user's machine records what they see.
235
+ around it, `cursor on` for a pointer, `toast` to say what is happening, and the pacing recording adds
236
+ for the viewer; a screen recorder on the user's machine records what they see.
223
237
 
224
238
  ## Environment
225
239
 
@@ -228,13 +242,13 @@ pw-repl send -c <name> close # your modes off; tab close
228
242
  - `PW_CHROME` — the Chromium `--launch` starts (default: Playwright's own, then one on the `PATH`).
229
243
  - `PW_FFMPEG` — the ffmpeg `record` uses, and no other, read by the REPL, not by `send` (default:
230
244
  Playwright's own, then one on the `PATH`).
231
- - Playwright's own Chromium and ffmpeg are found where Playwright installs them: `PLAYWRIGHT_BROWSERS_PATH`,
232
- or `~/.cache/ms-playwright`.
245
+ - Playwright's own Chromium and ffmpeg are found where Playwright installs them:
246
+ `PLAYWRIGHT_BROWSERS_PATH`, or `~/.cache/ms-playwright`.
233
247
  - `PW_CDP_URL` — CDP endpoint (default `http://localhost:9222`).
234
- - `PW_SCREENSHOT_DIR` — where the REPL saves screenshots, read when it starts, not by `send`. Without it,
235
- a REPL on a socket of its own saves them next to its socket, and any other in `/tmp`. `screenshot`
236
- prints each file's path; other REPLs may save theirs in the same place. Remove only your
237
- own, and keep those too if they are needed, e.g. as evidence or as something to hand over.
248
+ - `PW_SCREENSHOT_DIR` — where the REPL saves screenshots, read when it starts, not by `send`. Without
249
+ it, a REPL on a socket of its own saves them next to its socket, and any other in `/tmp`. `screenshot`
250
+ prints each file's path; other REPLs may save theirs in the same place. Remove only your own, and keep
251
+ those too if they are needed, e.g. as evidence or as something to hand over.
238
252
  - `PW_ENDPOINT` / `PW_TMUX_SESSION` — defaults for `send -e` / `-s`. `PW_SOCKET` — the socket `serve`
239
253
  serves on and `send` looks for when neither is given (default `/tmp/playwright-repl.sock`).
240
254