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/AGENTS.md +2 -1
- package/README.md +23 -16
- package/bin/pw-repl.js +12 -1
- package/lib/background.js +14 -4
- package/lib/cdp.js +6 -0
- package/lib/cli-names.js +1 -1
- package/lib/client.js +3 -2
- package/lib/commands.js +3 -0
- package/lib/cursor.js +36 -50
- package/lib/elements.js +133 -18
- package/lib/emulation.js +13 -1
- package/lib/help.js +108 -29
- package/lib/inspect.js +65 -6
- package/lib/modes.js +11 -2
- package/lib/output.js +1 -1
- package/lib/overlay.js +79 -0
- package/lib/record.js +15 -11
- package/lib/runner.js +16 -9
- package/lib/send.js +27 -4
- package/lib/server.js +12 -4
- package/lib/skill.js +1 -1
- package/lib/start.js +4 -1
- package/lib/syntax.js +30 -1
- package/lib/tabs.js +24 -8
- package/lib/toast.js +166 -0
- package/lib/watch.js +210 -29
- package/package.json +1 -1
- package/skill/SKILL.md +73 -56
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
91
|
-
if (!el.matches ||
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
108
|
-
if (!el.matches
|
|
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
|
|
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
|
|
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
|
-
|
|
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))
|
|
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
|
|
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
|
-
|
|
440
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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' : ''}
|
|
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
|
-
|
|
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);
|