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 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 is the click it passes on to its control, and saved as that.
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: { usage: 'inputs [--all]', summary: 'form controls on the page; values are omitted' },
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('input, select, textarea', els =>
267
- els.map(e => ({
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
- type: e.type || '',
270
- name: e.name || '',
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
- placeholder: e.placeholder || '',
273
- disabled: e.disabled,
274
- autocomplete: e.autocomplete || ''
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
- const byUrl = part => {
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 target = parts.length ? byUrl(parts.join(' ')) : state.page;
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, recorded as that (or as the
252
- // check a checkbox's change records): recorded too, a checkbox would be toggled twice.
253
- if (el.localName === 'label' && el.control) return;
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');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pw-repl",
3
- "version": "0.11.3",
3
+ "version": "0.11.5",
4
4
  "description": "Text REPL for driving an existing Chromium through Playwright over CDP",
5
5
  "keywords": [
6
6
  "playwright",