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/AGENTS.md +2 -1
- package/README.md +19 -14
- 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 +21 -47
- package/lib/elements.js +45 -8
- package/lib/help.js +85 -19
- package/lib/inspect.js +35 -4
- package/lib/modes.js +6 -1
- package/lib/output.js +1 -1
- package/lib/overlay.js +79 -0
- package/lib/record.js +13 -2
- 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 +58 -44
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);
|
package/package.json
CHANGED
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
|
|
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:
|
|
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)`.
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
54
|
-
its own (never 9222). It stops with the REPL, Ctrl-C while it starts included, and `stop` returns
|
|
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
|
|
57
|
-
a kept profile, Chromium picks up where it left off, as it would for
|
|
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
|
|
78
|
-
(`/tmp/mine.log`), appended to run after run, with a line where
|
|
79
|
-
start moves it to `/tmp/mine.log.1`); `tail -f` on it follows along
|
|
80
|
-
`serve <port>` listens on TCP 127.0.0.1 instead of a socket, with no
|
|
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
|
|
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
|
|
111
|
-
(`pw-repl send eval "document.title + ' x'"`). A command that takes only a selector takes the
|
|
112
|
-
spaces and all: `pw-repl send click "text=Your name"`. It works however the REPL was
|
|
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
|
|
127
|
-
It is the way to record a video without gaps (`help record`), and to keep a
|
|
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.
|
|
150
|
-
on` records each step someone takes in a tab, with the requests it caused (`watch on --changes`
|
|
151
|
-
what each step changed on the page); `watch` reads it back, and `watch new` only what it has not
|
|
152
|
-
yet. For a tab they have not opened yet, `watch on --next-tab [url-part]` waits for it and
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
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,
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|