pw-repl 0.11.3 → 0.11.5
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 +3 -0
- package/lib/help.js +17 -5
- package/lib/inspect.js +39 -9
- package/lib/tabs.js +10 -4
- package/lib/watch.js +43 -9
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -4,6 +4,9 @@ How to use the REPL is in the skill `bin/pw-repl.js skill` prints (from `skill/S
|
|
|
4
4
|
its version and hash). Read it first. In this clone, `pw-repl` there means `bin/pw-repl.js` (from the
|
|
5
5
|
folder this file is in) when it is not installed globally.
|
|
6
6
|
|
|
7
|
+
If there is a local agent file (e.g. `AGENTS.LOCAL.md`) next to this file, read it too.
|
|
8
|
+
They add to the rules in this file, and where the two differ, the local file wins.
|
|
9
|
+
|
|
7
10
|
## Contributing
|
|
8
11
|
|
|
9
12
|
If you hit a limitation or write a workaround, consider adding the capability to the REPL instead.
|
package/lib/help.js
CHANGED
|
@@ -156,7 +156,7 @@ const COMMANDS = {
|
|
|
156
156
|
['<index>', 'selects by the number in your latest listing (each client has its own); numbers change when tabs open or close.'],
|
|
157
157
|
['<url-part>', 'selects the one tab whose URL contains it; refused if none or several do.'],
|
|
158
158
|
['new [url]', 'opens a tab behind the one in front, so it does not take the window from whoever is using it, and selects it. The URL is read as goto reads it.'],
|
|
159
|
-
['close [url-part]', 'closes the selected tab, or the one matching url-part.'],
|
|
159
|
+
['close [url-part]', 'closes the selected tab, or the one matching url-part; of several, the one you opened, if exactly one of them is, and refused otherwise.'],
|
|
160
160
|
],
|
|
161
161
|
detail: `Closing the selected tab goes back to the previous tab if tab new opened it; otherwise no tab is
|
|
162
162
|
selected, and commands that need one refuse until one is.
|
|
@@ -316,7 +316,8 @@ to the label.
|
|
|
316
316
|
Passwords: a password field's value is shown as (a password: not shown), --full too, as is that of a
|
|
317
317
|
field whose autocomplete is for a password, a one-time code or a card number; html and attrs show its
|
|
318
318
|
value attribute so too. A field a Show password button made plain text, with no such autocomplete,
|
|
319
|
-
shows its value.
|
|
319
|
+
shows its value. Every other field shows what it holds (textbox "Name": Ada), and a select its chosen
|
|
320
|
+
option: inputs lists the fields without what they hold.
|
|
320
321
|
|
|
321
322
|
Output over 60 lines ends with a line count.`,
|
|
322
323
|
},
|
|
@@ -341,7 +342,8 @@ Output over 60 lines ends with a line count.`,
|
|
|
341
342
|
navigation, described by role and name as in snapshot, with up to 5 of the requests it caused under it,
|
|
342
343
|
each with its status as in requests (body <#> for one), leaving out scripts and what requests hides. The
|
|
343
344
|
REPL's own fill shows as type. A double-click shows as two clicks; hovering and moving the mouse are not
|
|
344
|
-
recorded.
|
|
345
|
+
recorded. When other elements are described alike (cards that repeat their buttons), the step says which
|
|
346
|
+
one: (under "<its nearest heading>"), with n of N where the heading does not settle it.
|
|
345
347
|
|
|
346
348
|
Privacy: what is typed is recorded, and watch and watch save show it, except in a password field: one
|
|
347
349
|
that was ever type=password, or whose autocomplete is for a password, a one-time code or a card number.
|
|
@@ -380,7 +382,9 @@ off leaves the wait on.
|
|
|
380
382
|
|
|
381
383
|
watch save: a submit is left out, since the click or Enter that caused it is there. So is a URL the app
|
|
382
384
|
changed without loading a page (history.pushState or replaceState), as a comment: the step before it
|
|
383
|
-
changes it again. A click on a label
|
|
385
|
+
changes it again. A click on a label that holds its control is the control's click, as it passes it on;
|
|
386
|
+
one on a label that names its control with for= is the label's, since that can be a control anywhere
|
|
387
|
+
(the first of a page's repeated ids), and a for= label on a checkbox toggles it rather than checks it.
|
|
384
388
|
In an open shadow root, an element's selector is its host's, then its own; a closed one is out of reach.`,
|
|
385
389
|
},
|
|
386
390
|
text: { usage: 'text [--all] <selector>', summary: 'visible text of the first match' },
|
|
@@ -400,7 +404,15 @@ Playwright adds to window as it acts are left out, and it says how many.`,
|
|
|
400
404
|
count: { usage: 'count <selector>', summary: 'number of matches' },
|
|
401
405
|
visible: { usage: 'visible <selector>', summary: 'whether the first match is visible' },
|
|
402
406
|
links: { usage: 'links [--all]', summary: 'links on the page (text and href)' },
|
|
403
|
-
inputs: {
|
|
407
|
+
inputs: {
|
|
408
|
+
usage: 'inputs [--all]',
|
|
409
|
+
summary: 'form controls, native and ARIA; values are omitted',
|
|
410
|
+
detail: `Each control as JSON: tag, role, type, name, id, label, placeholder, disabled (by its own attribute,
|
|
411
|
+
a disabled fieldset around it, or aria-disabled), readonly, checked for a checkbox, radio or switch (true,
|
|
412
|
+
false or "mixed"), and autocomplete. An ARIA control is an element with role checkbox, radio, switch, combobox, listbox, textbox,
|
|
413
|
+
searchbox, slider or spinbutton, as Radix and the like draw them (a button that is a radio); its role
|
|
414
|
+
says what it is, and type is a native control's own. What a field holds is never shown.`,
|
|
415
|
+
},
|
|
404
416
|
highlight: {
|
|
405
417
|
usage: 'highlight [<selector> | off] [options]',
|
|
406
418
|
summary: 'draw a box over elements, for the user or a screenshot',
|
package/lib/inspect.js
CHANGED
|
@@ -15,6 +15,8 @@ const { waitForLoad, waitForRequest } = require('./requestlog');
|
|
|
15
15
|
const { deviceMetrics, viewportMetrics, reapplyMetrics } = require('./emulation');
|
|
16
16
|
|
|
17
17
|
const SCREENSHOT_DELAY_MAX = 60;
|
|
18
|
+
// What inputs lists: native form controls, and elements that are one by their ARIA role.
|
|
19
|
+
const INPUTS = ['input', 'select', 'textarea', ...['checkbox', 'radio', 'switch', 'combobox', 'listbox', 'textbox', 'searchbox', 'slider', 'spinbutton'].map(role => `[role=${role}]`)].join(', ');
|
|
18
20
|
|
|
19
21
|
function nextScreenshotPath(name) {
|
|
20
22
|
const filename = name ? `screenshot-${name}` : `screenshot-${Date.now()}`;
|
|
@@ -262,18 +264,46 @@ const commands = {
|
|
|
262
264
|
printOutput(links, all);
|
|
263
265
|
},
|
|
264
266
|
|
|
267
|
+
// Native controls and ARIA ones (a button with role=radio, as Radix draws them), each with what names
|
|
268
|
+
// it and, for a checkbox, radio or switch, whether it is checked: true, false or "mixed" for both.
|
|
265
269
|
async inputs(_args, all) {
|
|
266
|
-
const inputs = await state.page.$$eval(
|
|
267
|
-
|
|
270
|
+
const inputs = await state.page.$$eval(INPUTS, els => {
|
|
271
|
+
const plain = text => (text || '').replace(/\s+/g, ' ').trim().slice(0, 60);
|
|
272
|
+
// A label's own words, without what the fields inside it hold.
|
|
273
|
+
const labelText = label => {
|
|
274
|
+
const copy = label.cloneNode(true);
|
|
275
|
+
copy.querySelectorAll('select, textarea, input, button').forEach(n => n.remove());
|
|
276
|
+
return copy.textContent;
|
|
277
|
+
};
|
|
278
|
+
const labelOf = e => {
|
|
279
|
+
const byIds = (e.getAttribute('aria-labelledby') || '').split(/\s+/).map(id => id && document.getElementById(id)).filter(Boolean);
|
|
280
|
+
if (byIds.length) return plain(byIds.map(n => n.textContent).join(' '));
|
|
281
|
+
return plain(e.getAttribute('aria-label')) || plain([...(e.labels || [])].map(labelText).join(' ')) || plain(e.getAttribute('title'));
|
|
282
|
+
};
|
|
283
|
+
const native = e => e.matches('input, select, textarea');
|
|
284
|
+
const checkedOf = e => {
|
|
285
|
+
if (native(e) && /^(?:checkbox|radio)$/.test(e.type)) return e.indeterminate ? 'mixed' : e.checked;
|
|
286
|
+
if (!/^(?:checkbox|radio|switch)$/.test(e.getAttribute('role'))) return undefined;
|
|
287
|
+
const state = e.getAttribute('aria-checked');
|
|
288
|
+
return state === 'mixed' ? 'mixed' : state === 'true';
|
|
289
|
+
};
|
|
290
|
+
return els.map(e => ({
|
|
268
291
|
tag: e.tagName.toLowerCase(),
|
|
269
|
-
|
|
270
|
-
|
|
292
|
+
role: e.getAttribute('role') || '',
|
|
293
|
+
// A button's own type (submit) is not what kind of control it is: its role says that.
|
|
294
|
+
type: native(e) ? e.type : '',
|
|
295
|
+
name: e.getAttribute('name') || '',
|
|
271
296
|
id: e.id || '',
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
297
|
+
label: labelOf(e),
|
|
298
|
+
placeholder: e.getAttribute('placeholder') || '',
|
|
299
|
+
// By its own attribute (a button that is a radio, as Radix does it), a disabled fieldset around it,
|
|
300
|
+
// or aria-disabled.
|
|
301
|
+
disabled: e.matches(':disabled') || e.getAttribute('aria-disabled') === 'true',
|
|
302
|
+
readonly: e.matches('input, textarea') ? e.readOnly : e.getAttribute('aria-readonly') === 'true',
|
|
303
|
+
checked: checkedOf(e),
|
|
304
|
+
autocomplete: native(e) ? e.autocomplete || '' : ''
|
|
305
|
+
}));
|
|
306
|
+
});
|
|
277
307
|
if (!inputs.length) { out.log('No inputs found'); return; }
|
|
278
308
|
printOutput(inputs, all);
|
|
279
309
|
},
|
package/lib/tabs.js
CHANGED
|
@@ -164,11 +164,13 @@ const commands = {
|
|
|
164
164
|
ensureDialogHandler(p);
|
|
165
165
|
await keepDrawing(p);
|
|
166
166
|
};
|
|
167
|
-
|
|
167
|
+
// Opened by this client, with tab new: the prompt's and nameless senders' are theirs together.
|
|
168
|
+
const mine = p => 'opened' in tabState(p) && tabState(p).owner === state.client;
|
|
169
|
+
const byUrl = (part, closing = false) => {
|
|
168
170
|
const matches = all.filter(p => p.url().includes(part));
|
|
169
171
|
if (matches.length === 1) return matches[0];
|
|
170
172
|
if (!matches.length) throw new Error(`No tab URL contains "${part}"`);
|
|
171
|
-
throw new Error(`${matches.length} tabs match "${part}"; use a longer part:\n${matches.map(p => ` ${p.url()}`).join('\n')}`);
|
|
173
|
+
throw new Error(`${matches.length} tabs match "${part}"; use a longer part, or tab <index> from tab's listing${closing ? ', then tab close' : ''}:\n${matches.map(p => ` ${p.url()}${mine(p) ? ' (opened by you)' : ''}`).join('\n')}`);
|
|
172
174
|
};
|
|
173
175
|
if (/^\d+$/.test(subcommand)) {
|
|
174
176
|
if (parts.length) throw new Error(usage);
|
|
@@ -196,7 +198,11 @@ const commands = {
|
|
|
196
198
|
return listTabs();
|
|
197
199
|
}
|
|
198
200
|
if (subcommand === 'close') {
|
|
199
|
-
const
|
|
201
|
+
const part = parts.join(' ');
|
|
202
|
+
const matches = part ? all.filter(p => p.url().includes(part)) : [];
|
|
203
|
+
// Of several, the one tab you opened is the one to close: the others may be someone else's.
|
|
204
|
+
const yours = matches.length > 1 && matches.filter(mine).length === 1 ? matches.find(mine) : null;
|
|
205
|
+
const target = yours || (part ? byUrl(part, true) : state.page);
|
|
200
206
|
if (!target || target.isClosed() || !all.includes(target)) throw new Error('Selected tab is unavailable. Run tab again.');
|
|
201
207
|
if (all.length <= 1) throw new Error('Refusing to close the last tab');
|
|
202
208
|
const url = target.url();
|
|
@@ -208,7 +214,7 @@ const commands = {
|
|
|
208
214
|
state.page = back && !back.isClosed() && 'opened' in tabState(back) ? back : null;
|
|
209
215
|
state.previousPage = null;
|
|
210
216
|
}
|
|
211
|
-
out.log(`Closed ${url}${wasSelected && !state.page ? '; no tab is selected now' : ''}`);
|
|
217
|
+
out.log(`Closed ${url}${yours ? `, the one you opened; ${matches.length - 1} other ${matches.length === 2 ? 'tab matches' : 'tabs match'} "${part}"` : ''}${wasSelected && !state.page ? '; no tab is selected now' : ''}`);
|
|
212
218
|
if (recording) out.log(await endedByTabClose(recording));
|
|
213
219
|
return listTabs();
|
|
214
220
|
}
|
package/lib/watch.js
CHANGED
|
@@ -64,6 +64,30 @@ const WATCH_SCRIPT = `(() => {
|
|
|
64
64
|
|| el.getAttribute('placeholder') || el.getAttribute('title') || (typedInto(el) || hasEditable(el) ? '' : el.innerText));
|
|
65
65
|
};
|
|
66
66
|
const describe = el => { const name = nameOf(el); return roleOf(el) + (name ? ' ' + JSON.stringify(name) : ''); };
|
|
67
|
+
// Which of the elements described alike this one is: under its nearest heading, and n of N where that
|
|
68
|
+
// does not settle it (cards that repeat a heading). Empty when nothing else is described as it is.
|
|
69
|
+
const HEADING = 'h1,h2,h3,h4,h5,h6,[role=heading]';
|
|
70
|
+
const headingOf = el => {
|
|
71
|
+
for (let at = el.parentElement; at; at = at.parentElement) {
|
|
72
|
+
const heading = at.querySelector(HEADING);
|
|
73
|
+
if (heading && !heading.contains(el)) return clip(heading.innerText);
|
|
74
|
+
}
|
|
75
|
+
return '';
|
|
76
|
+
};
|
|
77
|
+
// Run before the page's own handlers, so only elements of its kind are compared: a page can have
|
|
78
|
+
// thousands of divs. One with no name is alike every other of its role, and n of N says nothing.
|
|
79
|
+
const contextOf = el => {
|
|
80
|
+
if (el.getRootNode() !== document || !nameOf(el)) return '';
|
|
81
|
+
const role = roleOf(el);
|
|
82
|
+
const me = describe(el);
|
|
83
|
+
const kind = el.hasAttribute('role') ? '[role]' : el.localName + ',[role]';
|
|
84
|
+
const alike = [...document.querySelectorAll(kind)].filter(other => other === el || (roleOf(other) === role && describe(other) === me));
|
|
85
|
+
if (alike.length < 2) return '';
|
|
86
|
+
const heading = headingOf(el);
|
|
87
|
+
const peers = heading ? alike.filter(other => headingOf(other) === heading) : alike;
|
|
88
|
+
const which = peers.length > 1 ? (peers.indexOf(el) + 1) + ' of ' + peers.length : '';
|
|
89
|
+
return [heading && 'under ' + JSON.stringify(heading), which].filter(Boolean).join(', ');
|
|
90
|
+
};
|
|
67
91
|
// A field that was a password field while watched stays one when a "show password" button makes it
|
|
68
92
|
// text; and some take secrets under another type. Their values never leave the page.
|
|
69
93
|
const secrets = new WeakSet();
|
|
@@ -233,24 +257,34 @@ const WATCH_SCRIPT = `(() => {
|
|
|
233
257
|
if (typeof window.__pwReplWatch !== 'function') return;
|
|
234
258
|
let selector = null;
|
|
235
259
|
try { selector = el ? selectorOf(el) : null; } catch {}
|
|
260
|
+
let context = '';
|
|
261
|
+
try { context = el ? contextOf(el) : ''; } catch {}
|
|
236
262
|
const typed = (action === 'type' || action === 'fill') && el;
|
|
237
263
|
const secret = typed && isSecret(el);
|
|
238
264
|
const option = action === 'select' ? el.value : null;
|
|
239
|
-
window.__pwReplWatch(JSON.stringify({ action, target: el ? describe(el) : 'page', extra: extra || '', since: since || 0, selector, secret, value: typed && !secret ? valueOf(el) : null, option }));
|
|
265
|
+
window.__pwReplWatch(JSON.stringify({ action, target: el ? describe(el) : 'page', context, extra: extra || '', since: since || 0, selector, secret, value: typed && !secret ? valueOf(el) : null, option }));
|
|
240
266
|
};
|
|
241
267
|
// The element itself, not its component's host, when it is inside an open shadow root.
|
|
242
268
|
const targetOf = e => (e.composedPath && e.composedPath()[0]) || e.target;
|
|
243
269
|
// Enter in a form's field submits it with a click on its submit button, which is the Enter's doing:
|
|
244
270
|
// recorded too, it would submit the form again when the steps run again.
|
|
245
271
|
let enteredForm = null;
|
|
272
|
+
let labelled = null;
|
|
246
273
|
document.addEventListener('click', e => {
|
|
247
274
|
const t = targetOf(e);
|
|
248
275
|
const el = t.closest ? (t.closest(INTERACTIVE) || t) : t;
|
|
249
276
|
if (!el.matches || el.matches('input[type=checkbox],input[type=radio],select') || isPassword(el)) return;
|
|
250
277
|
if (e.detail === 0 && enteredForm && el.form === enteredForm) return;
|
|
251
|
-
// A click on a label is passed on to its control as a click of its own,
|
|
252
|
-
//
|
|
253
|
-
|
|
278
|
+
// A click on a label is passed on to its control as a click of its own, in the same task; one of them
|
|
279
|
+
// is recorded, or a replay would toggle a checkbox twice. A label that holds its control passes it on
|
|
280
|
+
// inside what was clicked: the control's click is recorded (or the check its change records), and
|
|
281
|
+
// found by its role. A label's for= can name a control anywhere, even another card's where ids
|
|
282
|
+
// repeat: the label is what was clicked, and the passed-on click and its change are left out.
|
|
283
|
+
if (el.localName === 'label' && el.control) {
|
|
284
|
+
if (el.contains(el.control)) return;
|
|
285
|
+
labelled = el.control;
|
|
286
|
+
setTimeout(() => { labelled = null; });
|
|
287
|
+
} else if (el === labelled) return;
|
|
254
288
|
send('click', el);
|
|
255
289
|
}, true);
|
|
256
290
|
// Typing is recorded once it pauses, not on change, so a typeahead's requests
|
|
@@ -295,7 +329,7 @@ const WATCH_SCRIPT = `(() => {
|
|
|
295
329
|
}, true);
|
|
296
330
|
document.addEventListener('change', e => {
|
|
297
331
|
const el = targetOf(e);
|
|
298
|
-
if (!el.matches) return;
|
|
332
|
+
if (!el.matches || el === labelled) return;
|
|
299
333
|
if (el.matches('input[type=checkbox],input[type=radio]')) return send(el.checked ? 'check' : 'uncheck', el);
|
|
300
334
|
if (el.matches('select')) return send('select', el, JSON.stringify(clip(el.selectedOptions[0]?.text)));
|
|
301
335
|
if (pending.has(el)) { flushTyping(el); typed.delete(el); return; }
|
|
@@ -460,7 +494,7 @@ async function startWatching(p, changes = false, live = false, values = true) {
|
|
|
460
494
|
if (!created.on) return;
|
|
461
495
|
const previous = created.events[created.events.length - 1];
|
|
462
496
|
const t = Math.max(Date.now() - Math.min(Math.max(Number(since) || 0, 0), TYPING_BACKDATE_MAX), previous ? previous.t : 0);
|
|
463
|
-
const step = { seq: created.nextSeq++, t, action, target: clipText(target), extra: clipText(extra), selector: more.selector || null, secret: !!more.secret, url: more.url, loaded: !!more.loaded };
|
|
497
|
+
const step = { seq: created.nextSeq++, t, action, target: clipText(target), context: clipText(more.context || ''), extra: clipText(extra), selector: more.selector || null, secret: !!more.secret, url: more.url, loaded: !!more.loaded };
|
|
464
498
|
if (typeof more.option === 'string') step.option = more.option;
|
|
465
499
|
if (typeof more.value === 'string' && created.values) step.value = more.value;
|
|
466
500
|
keep(created.events, step);
|
|
@@ -483,7 +517,7 @@ async function startWatching(p, changes = false, live = false, values = true) {
|
|
|
483
517
|
try { event = JSON.parse(payload); } catch { return; }
|
|
484
518
|
if (event && PAGE_ACTIONS.has(event.action)) {
|
|
485
519
|
binding.record(event.action, String(event.target || ''), String(event.extra || ''), event.since,
|
|
486
|
-
{ selector: typeof event.selector === 'string' ? event.selector : null, secret: !!event.secret, value: event.value, option: event.option });
|
|
520
|
+
{ selector: typeof event.selector === 'string' ? event.selector : null, secret: !!event.secret, value: event.value, option: event.option, context: String(event.context || '') });
|
|
487
521
|
}
|
|
488
522
|
});
|
|
489
523
|
// Calls are reported only to a session with Runtime on.
|
|
@@ -619,7 +653,7 @@ function requestsByStep(watch, p = state.page) {
|
|
|
619
653
|
|
|
620
654
|
function stepLines(e, caused, note = '', withChanges = true) {
|
|
621
655
|
const typed = e.secret ? ' (a password: not recorded)' : typeof e.value === 'string' ? ` ${JSON.stringify(clipText(e.value))}` : '';
|
|
622
|
-
const lines = [`${clock(e.t)} ${e.action} ${e.target}${typed}${e.extra ? ` ${e.extra}` : ''}${note}`];
|
|
656
|
+
const lines = [`${clock(e.t)} ${e.action} ${e.target}${e.context ? ` (${e.context})` : ''}${typed}${e.extra ? ` ${e.extra}` : ''}${note}`];
|
|
623
657
|
const indent = ' '.repeat(clock(e.t).length + 1);
|
|
624
658
|
for (const r of caused.slice(0, WATCH_REQUESTS_SHOWN)) lines.push(`${indent}#${r.id} ${r.method} ${r.status} ${r.url}`);
|
|
625
659
|
if (caused.length > WATCH_REQUESTS_SHOWN) lines.push(`${indent}… ${caused.length - WATCH_REQUESTS_SHOWN} more (requests)`);
|
|
@@ -700,7 +734,7 @@ function savedCommands(watch, p) {
|
|
|
700
734
|
if (e.action === 'submit' || changedAgain(i)) continue;
|
|
701
735
|
if (routed && e.selector) lines.push(`wait ${sel(e.selector)} ${ROUTE_WAIT}`);
|
|
702
736
|
routed = false;
|
|
703
|
-
lines.push(`# ${e.action} ${e.target}${e.extra ? ` ${e.extra}` : ''}`);
|
|
737
|
+
lines.push(`# ${e.action} ${e.target}${e.context ? ` (${e.context})` : ''}${e.extra ? ` ${e.extra}` : ''}`);
|
|
704
738
|
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; }
|
|
705
739
|
if (e.action === 'click' || e.action === 'check' || e.action === 'uncheck') lines.push(`${e.action} ${sel(e.selector)}`);
|
|
706
740
|
else if (e.action === 'select' && typeof e.option !== 'string') comment(e, 'the option was not recorded');
|