pw-repl 0.9.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.9.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",