pw-repl 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/elements.js CHANGED
@@ -9,6 +9,7 @@ const { numbers, hints } = require('./util');
9
9
  const { tabState } = require('./tabstate');
10
10
  const { cursorOf, aiming, aimAt, glideTo, showClick, showSelect } = require('./cursor');
11
11
  const { recordingOf, stepTarget } = require('./record');
12
+ const { settled } = require('./emulation');
12
13
 
13
14
  // The type a page sees for a file it is given, from its extension.
14
15
  const MIME_TYPES = {
@@ -196,10 +197,16 @@ async function pointerAction(selector, act, options = {}, clicks = 0) {
196
197
  return '';
197
198
  }
198
199
  try {
200
+ // The first of several is named, as it may not be the one meant (a heading with a button's text);
201
+ // before acting, since a click may take the page away.
202
+ const what = chosen.position === 1
203
+ ? await chosen.handle.evaluate(el => `<${el.tagName.toLowerCase()}> ${(el.innerText || el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60)}`).catch(() => null)
204
+ : null;
199
205
  const point = await aimAt(state.page, chosen.handle, left());
200
206
  await onElement(selector, () => act(chosen.handle, null, { ...options, timeout: left() }));
201
207
  await landed(point, clicks);
202
- return chosen.position === 1 ? '' : ` (match ${chosen.position} of ${chosen.count}; the ones before it are hidden or covered)`;
208
+ if (chosen.position !== 1) return ` (match ${chosen.position} of ${chosen.count}; the ones before it are hidden or covered)`;
209
+ return ` (the first of ${chosen.count} matches${what ? `: ${what}` : ''})`;
203
210
  } catch (error) {
204
211
  if (!/not attached/i.test(error.message) || Date.now() >= deadline) throw error;
205
212
  } finally {
@@ -282,7 +289,7 @@ async function hideHighlights(p, mine = false) {
282
289
  if (!highlights?.size) return 0;
283
290
  const hidden = [...highlights].filter(([, h]) => !mine || (h.owner ?? null) === (state.client ?? null));
284
291
  if (hidden.length === highlights.size) await p.hideHighlight();
285
- else for (const [selector] of hidden) await p.locator(selector).hideHighlight();
292
+ else for (const [selector, h] of hidden) await (h.drawn || p.locator(selector)).hideHighlight();
286
293
  for (const [selector] of hidden) highlights.delete(selector);
287
294
  return hidden.length;
288
295
  }
@@ -293,6 +300,85 @@ onShutdown(async () => {
293
300
  await Promise.all(pages.map(p => hideHighlights(p).catch(() => {})));
294
301
  });
295
302
 
303
+ // Playwright's highlight labels each box with its locator, which covers what is below it, and has
304
+ // no option not to; highlights are drawn without it unless highlight --labels on. Its overlay is an
305
+ // x-pw-glass element with a closed shadow root, which only CDP reaches: a stylesheet adopted there
306
+ // hides the labels, under any CSP, and stays as Playwright redraws them. Playwright removes the
307
+ // overlay with its last highlight, and the sheet with it, so it is applied again after each one.
308
+ const LABELS_OFF = 'x-pw-tooltip:not([data-pw-repl])';
309
+ // The clients told that the labels could not be hidden, each once.
310
+ const labelsWarned = new Set();
311
+
312
+ // Adds or removes the sheet in the main document's overlay, and says whether it worked: false when
313
+ // there is an overlay but its labels are not hidden. Null when there is no overlay.
314
+ async function applyLabels(p, hide) {
315
+ const session = await p.context().newCDPSession(p);
316
+ try {
317
+ const { root } = await session.send('DOM.getDocument', { depth: 0 });
318
+ const { nodeId } = await session.send('DOM.querySelector', { nodeId: root.nodeId, selector: 'x-pw-glass' });
319
+ if (!nodeId) return null;
320
+ const { node } = await session.send('DOM.describeNode', { nodeId, pierce: true });
321
+ const shadow = node.shadowRoots?.[0];
322
+ if (!shadow) return false;
323
+ const { object } = await session.send('DOM.resolveNode', { backendNodeId: shadow.backendNodeId });
324
+ const call = (fn, args) => session.send('Runtime.callFunctionOn', { objectId: object.objectId, functionDeclaration: fn.toString(), arguments: args.map(value => ({ value })), returnByValue: true });
325
+ await call(function (rule, hidden) {
326
+ const sheets = [...this.adoptedStyleSheets].filter(s => ![...s.cssRules].some(r => r.selectorText === rule));
327
+ if (hidden) {
328
+ const sheet = new CSSStyleSheet();
329
+ sheet.replaceSync(`${rule} { display: none !important; }`);
330
+ sheets.push(sheet);
331
+ }
332
+ this.adoptedStyleSheets = sheets;
333
+ }, [LABELS_OFF, hide]);
334
+ if (!hide) return true;
335
+ // Playwright draws its labels on a later frame.
336
+ await settled(p);
337
+ const { result } = await call(function (rule) {
338
+ const ours = [...this.adoptedStyleSheets].some(s => [...s.cssRules].some(r => r.selectorText === rule));
339
+ return ours && [...this.querySelectorAll('x-pw-tooltip')].every(t => getComputedStyle(t).display === 'none');
340
+ }, [LABELS_OFF]);
341
+ return result.value === true;
342
+ } finally {
343
+ await session.detach().catch(() => {});
344
+ }
345
+ }
346
+
347
+ // Hides the tab's labels unless they are on; says so once if that failed, and the highlight is drawn
348
+ // with its label as Playwright draws it. A CDP call that fails, as one does when the tab navigates or
349
+ // closes, says nothing of Playwright's overlay: only the check finding the labels shown does.
350
+ async function keepLabels(p) {
351
+ if (tabState(p).labelsOn) return;
352
+ const worked = await applyLabels(p, true).catch(() => null);
353
+ const client = state.client ?? null;
354
+ if (worked !== false || labelsWarned.has(client)) return;
355
+ labelsWarned.add(client);
356
+ out.log(`highlight: could not hide the labels; Playwright's highlight has changed (playwright-core ${require('playwright-core/package.json').version}). The boxes are drawn with their labels.`);
357
+ }
358
+
359
+ async function setLabels(p, on) {
360
+ if (on) tabState(p).labelsOn = { owner: state.client }; else delete tabState(p).labelsOn;
361
+ if (on) await applyLabels(p, false); else await keepLabels(p);
362
+ }
363
+
364
+ // A date or time input takes keys part by part, in the order it shows them (10/02/2026 in en-US), so
365
+ // its ISO form, typed, comes out empty or mangled; fill sets it from that.
366
+ const DATE_INPUTS = {
367
+ date: [/^\d{4,}-\d\d-\d\d$/, '2026-10-02'],
368
+ time: [/^\d\d:\d\d(:\d\d(\.\d+)?)?$/, '14:30'],
369
+ 'datetime-local': [/^\d{4,}-\d\d-\d\d[T ]\d\d:\d\d(:\d\d(\.\d+)?)?$/, '2026-10-02T14:30'],
370
+ month: [/^\d{4,}-\d\d$/, '2026-10'],
371
+ week: [/^\d{4,}-W\d\d$/, '2026-W40'],
372
+ };
373
+ function refuseIsoDate(kind, text, what, selector) {
374
+ if (DATE_INPUTS[kind]?.[0].test(text)) throw new Error(`${what} is a ${kind} input: typed, ${text} comes out mangled, since it takes keys part by part in the order it shows them. fill ${selector} ${text} sets it`);
375
+ }
376
+ function dateInputHolds(kind, value, what, selector) {
377
+ if (!DATE_INPUTS[kind]) return '';
378
+ if (!value) throw new Error(`${what}, a ${kind} input, is still empty: it takes keys part by part, in the order it shows them. fill sets it from its ISO form: fill ${selector} ${DATE_INPUTS[kind][1]}`);
379
+ return `, a ${kind} input, which now holds ${value}`;
380
+ }
381
+
296
382
  const commands = {
297
383
  async click(args) {
298
384
  const { selector, shown, options } = clickArgs(args, 'click <selector> [left|right|middle] [--modifiers=<key>[,<key>]]');
@@ -313,15 +399,27 @@ const commands = {
313
399
  },
314
400
 
315
401
  async highlight(args) {
316
- const usage = 'Usage: highlight [<selector> [--style=<css>] | --hide [<selector>] | off]';
317
- const { found, rest } = takeOptions((args || '').trim(), { style: 'value', hide: 'flag' });
402
+ const usage = 'Usage: highlight [<selector> [--style=<css>] | --hide [<selector>] | --labels off|on | off]';
403
+ const { found, rest } = takeOptions((args || '').trim(), { style: 'value', hide: 'flag', labels: 'value' });
318
404
  const style = found.find(o => o.name === 'style');
319
405
  const hide = found.some(o => o.name === 'hide');
406
+ const labels = found.find(o => o.name === 'labels');
320
407
  const highlights = highlightsOf(state.page);
408
+ // For the whole tab, with a highlight to draw or on its own.
409
+ if (labels) {
410
+ if (hide || rest === 'off' || !['off', 'on'].includes(labels.value)) throw new Error(usage);
411
+ await setLabels(state.page, labels.value === 'on');
412
+ out.log(labels.value === 'on'
413
+ ? 'Labels on in the selected tab: each highlight is labelled with its locator; highlight --labels off hides them'
414
+ : 'Labels off in the selected tab');
415
+ if (!rest) return;
416
+ }
321
417
  if (!rest && !found.length) {
322
418
  if (!highlights.size) { out.log(`Nothing is highlighted in the selected tab.\n${hints([['highlight <selector>', 'draw a box around it']])}`); return; }
323
- for (const [selector, h] of highlights) out.log(`${selector}${h.style ? ` --style=${JSON.stringify(h.style)}` : ''}${h.owner ? ` (by ${h.owner})` : ''}`);
324
- out.log(hints([['highlight --hide [<selector>]', 'hide one, or all']]));
419
+ for (const [selector, h] of highlights) out.log(`${selector}${h.drawn?.toString() !== state.page.locator(selector).toString() ? ` as ${h.drawn}` : ''}${h.style ? ` --style=${JSON.stringify(h.style)}` : ''}${h.owner ? ` (by ${h.owner})` : ''}`);
420
+ const on = tabState(state.page).labelsOn;
421
+ if (on) out.log(`Labels are on${on.owner ? ` (by ${on.owner})` : ''}.`);
422
+ out.log(hints([['highlight --hide [<selector>]', 'hide one, or all'], on ? ['highlight --labels off', 'hide their labels'] : ['highlight --labels on', 'label each with its locator']]));
325
423
  return;
326
424
  }
327
425
  if (rest === 'off' && !found.length || hide && !rest) {
@@ -334,7 +432,7 @@ const commands = {
334
432
  const locator = state.page.locator(selector);
335
433
  if (hide) {
336
434
  if (!highlights.has(selector)) throw new Error(`${rest} is not highlighted; highlight lists what is`);
337
- await locator.hideHighlight();
435
+ await (highlights.get(selector).drawn || locator).hideHighlight();
338
436
  highlights.delete(selector);
339
437
  out.log(`Hid the highlight on ${rest}`);
340
438
  return;
@@ -342,9 +440,13 @@ const commands = {
342
440
  const count = await locator.count();
343
441
  if (!count) throw new Error(`No element matches ${rest}; nothing was highlighted`);
344
442
  // Drawn again, with its new style, rather than twice.
345
- if (highlights.has(selector)) await locator.hideHighlight();
346
- await locator.highlight(style ? { style: style.value } : {});
347
- highlights.set(selector, { owner: state.client, style: style?.value || null });
443
+ if (highlights.has(selector)) await (highlights.get(selector).drawn || locator).hideHighlight();
444
+ // Playwright labels a box with the locator it is given, and a snapshot ref's (aria-ref=e5) finds
445
+ // nothing in the page's code: one is drawn with the locator Playwright makes for its element.
446
+ const drawn = REF.test(rest) ? await locator.normalize().catch(() => locator) : locator;
447
+ await drawn.highlight(style ? { style: style.value } : {});
448
+ await keepLabels(state.page);
449
+ highlights.set(selector, { owner: state.client, style: style?.value || null, drawn });
348
450
  out.log(`Highlighted ${rest}${count > 1 ? ` (all ${count} matches)` : ''}; highlight --hide ${rest} hides it`);
349
451
  },
350
452
 
@@ -404,25 +506,34 @@ const commands = {
404
506
  if (sole && sole.rest === undefined) {
405
507
  // Named, since it may not be the element meant; one that takes no text is refused.
406
508
  const focused = await state.page.evaluate(() => {
407
- const el = document.activeElement;
509
+ let el = document.activeElement;
510
+ while (el?.shadowRoot?.activeElement) el = el.shadowRoot.activeElement;
408
511
  if (!el || el === document.body || el === document.documentElement) return null;
409
512
  const editable = el.isContentEditable || el.matches('textarea, input:not([type=checkbox],[type=radio],[type=button],[type=submit],[type=reset],[type=image],[type=file],[type=range],[type=color],[type=hidden])');
410
513
  const id = el.id ? `#${el.id}` : el.name ? `[name=${el.name}]` : el.placeholder ? `[placeholder="${el.placeholder}"]` : '';
411
- return { editable, name: `${el.tagName.toLowerCase()}${id}` };
514
+ return { editable, name: `${el.tagName.toLowerCase()}${id}`, type: el.type };
412
515
  });
413
516
  const usage = 'type <selector> <text> types into an element, e.g. type #search garden hose';
414
517
  if (!focused) throw new Error(`Nothing on the page has focus: ${usage}`);
415
518
  if (!focused.editable) throw new Error(`The focused element, ${focused.name}, takes no text: ${usage}`);
519
+ refuseIsoDate(focused.type, sole.word, `The focused ${focused.name}`, '<selector>');
416
520
  await state.page.keyboard.type(sole.word, { delay: delay() });
521
+ const held = DATE_INPUTS[focused.type] ? dateInputHolds(focused.type, await state.page.evaluate(() => {
522
+ let el = document.activeElement;
523
+ while (el?.shadowRoot?.activeElement) el = el.shadowRoot.activeElement;
524
+ return el?.value;
525
+ }), `The focused ${focused.name}`, '<selector>') : '';
417
526
  if (submit) await state.page.keyboard.press('Enter');
418
- out.log(`Typed into the focused ${focused.name}${submit ? ', and submitted' : ''}`);
527
+ out.log(`Typed into the focused ${focused.name}${held}${submit ? ', and submitted' : ''}`);
419
528
  return;
420
529
  }
421
530
  const { selector, value, shown } = selectorAndValue(args, 'type [<selector>] <text> [--submit]', 'type #search garden hose');
422
531
  const timeout = await aimFor(selector);
423
532
  // Typed after what the field already holds, as someone clicking into it
424
533
  // at the end would; focusing it alone leaves the caret at the start.
425
- await onElement(selector, () => state.page.locator(selector).first().evaluate(el => {
534
+ const kind = await onElement(selector, () => state.page.locator(selector).first().evaluate((el, dateKinds) => {
535
+ // Focused again, so a date input already focused takes keys from its first part, not where it was left.
536
+ if (dateKinds.includes(el.type) && el.matches(':focus')) el.blur();
426
537
  el.focus();
427
538
  if (typeof el.value === 'string' && typeof el.setSelectionRange === 'function') {
428
539
  try { el.setSelectionRange(el.value.length, el.value.length); } catch {}
@@ -433,10 +544,14 @@ const commands = {
433
544
  getSelection().removeAllRanges();
434
545
  getSelection().addRange(range);
435
546
  }
436
- }, null, { timeout }));
547
+ return el.type;
548
+ }, Object.keys(DATE_INPUTS), { timeout }));
549
+ const quoted = /\s/.test(shown) ? `"${shown}"` : shown;
550
+ refuseIsoDate(kind, value, shown, quoted);
437
551
  await state.page.keyboard.type(value, { delay: delay() });
552
+ const held = DATE_INPUTS[kind] ? dateInputHolds(kind, await onElement(selector, () => state.page.locator(selector).first().evaluate(el => el.value, null, { timeout: 5000 })), shown, quoted) : '';
438
553
  if (submit) await state.page.keyboard.press('Enter');
439
- out.log(`Typed into${submit ? ' and submitted' : ''}: ${shown}`);
554
+ out.log(`Typed into${submit ? ' and submitted' : ''}: ${shown}${held}`);
440
555
  },
441
556
 
442
557
  async press(args) {
@@ -483,7 +598,7 @@ const commands = {
483
598
  async upload(args) {
484
599
  const parsed = splitSelector(args);
485
600
  // playwright-cli's upload <file> answers a file picker a click opened. Here
486
- // the input is named instead: catching pickers would catch the person's too.
601
+ // the input is named instead: catching pickers would catch the user's too.
487
602
  const usage = 'Usage: upload <selector> <file>..., naming the file input (or the button that opens it) first, e.g. upload e12 ./doc.pdf; quote a path with spaces';
488
603
  if (!parsed || parsed.rest === undefined) throw new Error(usage);
489
604
  // A path, not a selector; // starts an XPath.
@@ -511,4 +626,4 @@ const commands = {
511
626
  },
512
627
  };
513
628
 
514
- module.exports = { commands, soleSelector, onElement, hideHighlights };
629
+ module.exports = { commands, soleSelector, onElement, hideHighlights, setLabels };
package/lib/emulation.js CHANGED
@@ -66,6 +66,18 @@ function settled(p) {
66
66
  ]);
67
67
  }
68
68
 
69
+ // Bringing a tab to the front can leave Chrome capturing it at the window's size after its next
70
+ // navigation, although the page is still laid out at its viewport's or phone's: sending the same
71
+ // metrics again, on the kept session, has it draw at them.
72
+ async function reapplyMetrics(p) {
73
+ const tab = tabState(p);
74
+ if (!tab.viewport && !tab.emulation?.device) return;
75
+ const session = await keptSession(p);
76
+ if (tab.viewport) await viewportMetrics(session, tab.viewport);
77
+ else if (tab.emulation?.device) await deviceMetrics(session, tab.emulation.device);
78
+ await settled(p);
79
+ }
80
+
69
81
  async function setViewport(p, size) {
70
82
  const session = await keptSession(p);
71
83
  if (size) {
@@ -220,4 +232,4 @@ const commands = {
220
232
  },
221
233
  };
222
234
 
223
- module.exports = { commands, viewportText, EMULATE_KINDS, emulatedKinds, deviceMetrics, viewportMetrics, setEmulation, setViewport, settled };
235
+ module.exports = { commands, viewportText, EMULATE_KINDS, emulatedKinds, deviceMetrics, viewportMetrics, setEmulation, setViewport, settled, reapplyMetrics };
package/lib/help.js CHANGED
@@ -23,7 +23,7 @@ function wrap(text, width = WIDTH) {
23
23
  }
24
24
 
25
25
  const OVERVIEW = wrap(`playwright-repl drives Chromium over CDP. Commands act on the selected tab (\`tab\` shows which).
26
- The browser may be a shared session, with other people's tabs open in it.
26
+ The browser may be a shared session, with other users' tabs open in it.
27
27
 
28
28
  Common tasks:
29
29
  where am I? tab, info
@@ -85,8 +85,9 @@ the selectors it needs (#id, text=..., role=...), not snapshot refs: the take st
85
85
  page, where the refs are stale.
86
86
 
87
87
  2. Write the take, one command per line, then run it: pw-repl send --file take.txt (pw-repl help, outside
88
- the REPL, has the rest). Its first line puts the page back as it was before the rehearsal (goto <url>),
89
- so the take does not start from the rehearsal's end.
88
+ the REPL, has the rest). Its first lines put the page back as it was before the rehearsal (goto <url>),
89
+ so the take does not start from the rehearsal's end. A toast, the cursor and highlights stay up across
90
+ page loads: turn off any the rehearsal left (toast off, cursor off, highlight off) before record on.
90
91
 
91
92
  goto https://shop.example/support/new
92
93
  record on clip.webm
@@ -100,7 +101,8 @@ so the take does not start from the rehearsal's end.
100
101
  3. While it records, pw-repl paces it for a viewer (help record): type types at a readable pace (use it,
101
102
  not fill, for text the viewer should see typed), each action is followed by a pause, an element out of
102
103
  view is scrolled to smoothly, and the video starts and ends on a still page. highlight <selector> draws a
103
- box to point at something. After a step that loads a page, wait for text only the result shows (wait
104
+ box to point at something, and toast <text> a line of text saying what is happening, up for as long as
105
+ the viewer needs to read it. After a step that loads a page, wait for text only the result shows (wait
104
106
  text ...), not a heading the page always has, and not wait load: a single-page app loads nothing.
105
107
 
106
108
  record on --steps also saves when and where each step ran, in the video's seconds and pixels, for
@@ -109,7 +111,7 @@ editing it afterwards.
109
111
  The user's own screen, with their real pointer, takes a screen recorder they run (OBS, ffmpeg -f
110
112
  x11grab, macOS screencapture -v): ask first. playwright-cli itself is a separate tool that records a
111
113
  clean browser of its own (here, its video-start is record on).`,
112
- commands: ['record', 'cursor'],
114
+ commands: ['record', 'cursor', 'toast'],
113
115
  },
114
116
  network: {
115
117
  intro: `requests and console record all the time; a capture records only while it runs.`,
@@ -150,14 +152,21 @@ const COMMANDS = {
150
152
  usage: 'tab [<index>|<url-part>|new [url]|close [url-part]]',
151
153
  summary: 'list tabs (* is selected); select, open or close one',
152
154
  args: [
153
- ['(none)', 'lists the tabs: * marks the selected one, and each says who opened it with tab new.'],
155
+ ['(none)', 'lists the tabs: * marks the selected one, (in front) the one the browser shows, and each says who opened it with tab new.'],
154
156
  ['<index>', 'selects by the number in your latest listing (each client has its own); numbers change when tabs open or close.'],
155
157
  ['<url-part>', 'selects the one tab whose URL contains it; refused if none or several do.'],
156
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.'],
157
159
  ['close [url-part]', 'closes the selected tab, or the one matching url-part.'],
158
160
  ],
159
161
  detail: `Closing the selected tab goes back to the previous tab if tab new opened it; otherwise no tab is
160
- selected, and commands that need one refuse until one is.`,
162
+ selected, and commands that need one refuse until one is.
163
+
164
+ In front: (in front) marks the tab the browser shows in its window, the one the user sees; (in front,
165
+ focused) when its window has the keyboard too, and it is not in the address bar. A tab a client has
166
+ selected or opened is never marked: pw-repl tells it that it is in front, so that it keeps drawing and
167
+ clicks in it work while it is behind another, and so cannot ask it. The user's other tabs are told
168
+ nothing, and say truly whether they are seen. Each window has a tab in front, so with several windows
169
+ (an incognito one, say) several tabs are marked; focused is the one with the keyboard.`,
161
170
  },
162
171
  goto: {
163
172
  usage: 'goto <url>',
@@ -190,7 +199,8 @@ afresh.`,
190
199
  ['--modifiers=<keys>', 'keys held down meanwhile, comma-separated: Alt, Control, ControlOrMeta, Meta, Shift.'],
191
200
  ],
192
201
  detail: `Several matches: hidden ones and ones covered by something else (a dialog's overlay) are passed over,
193
- and it says which it used: Clicked: text=Buy (match 3 of 4; ...). If none can be clicked within 5s, it
202
+ and it says which it used: Clicked: text=Buy (match 3 of 4; ...), or names the first when it was the
203
+ one: (the first of 2 matches: <h1> Buy), as it may not be the one meant. If none can be clicked within 5s, it
194
204
  says why for each and does nothing. A ref, or a single match, is clicked as it is, waiting up to 5s for
195
205
  it to be ready.
196
206
 
@@ -252,7 +262,11 @@ once, even while recording: type shows it being typed.`,
252
262
  The whole text takes its length times the gap, and send waits 20s by default: about 300 characters at
253
263
  60ms need a longer send -t, or a shorter --delay. --delay is a gap between keys, not a wait before
254
264
  starting like screenshot's. A page that reacts to each key, such as search as you type, may react to
255
- each one.`,
265
+ each one.
266
+
267
+ Date and time inputs: they take keys part by part, in the order they show them (10/02/2026 in en-US),
268
+ from the first part with a selector, or from the part a click landed on. type says what the input then
269
+ holds. Their ISO form (2026-10-02) is refused, since typed it comes out mangled: fill sets it.`,
256
270
  },
257
271
  press: { usage: 'press <key> | press <selector> <key>', summary: 'press a key (Enter, Escape, Control+A; any case), maybe on an element' },
258
272
  select: {
@@ -299,21 +313,28 @@ new snapshot after it does.
299
313
  ref of the element holding it, so snapshot <that ref> shows what sits beside it, such as the value next
300
314
  to the label.
301
315
 
316
+ Passwords: a password field's value is shown as (a password: not shown), --full too, as is that of a
317
+ field whose autocomplete is for a password, a one-time code or a card number; html and attrs show its
318
+ value attribute so too. A field a Show password button made plain text, with no such autocomplete,
319
+ shows its value.
320
+
302
321
  Output over 60 lines ends with a line count.`,
303
322
  },
304
323
  watch: {
305
- usage: 'watch on [--changes] [--live] | off | [n|new]',
306
- summary: 'record what happens in the tab; show the last n steps',
324
+ usage: 'watch on [options] | off | [n|new] | save <file>',
325
+ summary: 'record what happens in the tab; show or save the steps',
307
326
  args: [
308
327
  ['on', 'starts recording the tab\'s steps; after watch off, a new recording.'],
309
328
  ['off', 'stops it; anyone\'s watch off stops it, since a watch belongs to the tab.'],
310
329
  ['(none) | <n>', 'says whether it is on, and shows the last 20 steps, or the last n.'],
311
330
  ['new', 'only the steps not shown yet, and requests that have since arrived for the last one.'],
331
+ ['save <file>', 'writes the steps as commands, a file that pw-repl send --file <file> runs again; it refuses a file that exists.'],
312
332
  ],
313
333
  optionsFor: 'watch on',
314
334
  options: [
315
335
  ['--changes', 'also what each step changed on the page once it settled, in up to 5 lines: + added, - removed, ~ changed.'],
316
336
  ['--live', 'also prints each step in the REPL window once it settles (never in a command\'s answer), marked [watch], or [watch <url>] for a tab that is not the selected one.'],
337
+ ['--no-values', 'records that something was typed, not what.'],
317
338
  ['--next-tab [part]', 'returns at once, then watches the next tab someone opens, or the next whose URL contains part, from its first page, and selects it for you. tab new, a client\'s own, does not count.'],
318
339
  ],
319
340
  detail: `What a step is: a click, typing (once it pauses), Enter or Escape, a form change, a submit or a
@@ -322,11 +343,22 @@ each with its status as in requests (body <#> for one), leaving out scripts and
322
343
  REPL's own fill shows as type. A double-click shows as two clicks; hovering and moving the mouse are not
323
344
  recorded.
324
345
 
325
- Privacy: typed values are not recorded, and password fields not at all; --changes leaves out field
326
- values, but what the page itself shows (e.g. "Hello <name>") is shown. Nothing is replayed.
346
+ Privacy: what is typed is recorded, and watch and watch save show it, except in a password field: one
347
+ that was ever type=password, or whose autocomplete is for a password, a one-time code or a card number.
348
+ --no-values leaves out every value. --changes leaves out field values, but what the page itself shows
349
+ (e.g. "Hello <name>") is shown. watch save's file can be read by its owner only.
350
+
351
+ Saving (watch save): a goto to where the watch started, then each step as a command, under a # line
352
+ describing it: click, fill, select, check, press, and wait load after a step that loaded a page. An
353
+ element is found by its id, data-testid or name, its text, or else its place in the page, which a change
354
+ to the page can break. A select step chooses the option by its value. What was typed into a password
355
+ field (or any field, with --no-values) is a variable named after the field, e.g. fill '#password'
356
+ "{{ PW_PASSWORD }}", which pw-repl send --file fills in from its environment: PW_PASSWORD=... pw-repl
357
+ send --file login.txt. A step that cannot be a command (a value with a line break, an element in a frame, or
358
+ one no selector finds alone) stays a comment saying why, and the save says how many there are.
327
359
 
328
360
  Shared: every client sees the same steps, and watch new's place is shared.`,
329
- moreAbout: 'bodies kept, --live timing, what --changes misses, and one --next-tab at a time',
361
+ moreAbout: 'bodies kept, --live timing, what --changes misses, one --next-tab at a time, and what watch save leaves out',
330
362
  more: `From its first watch on, a tab keeps response bodies of up to 100 KB, so body <#> still shows one
331
363
  after the tab navigates, even when the page navigates as soon as the request answers.
332
364
 
@@ -336,13 +368,17 @@ under the next step.
336
368
  --changes: each element once, with the first names inside it. Not for navigations: what a page changes
337
369
  on its own after a step settles (data arriving later) shows under the next step.
338
370
 
339
- --next-tab: it takes --changes and --live too; watch off stops waiting, and modes lists it. One tab is
371
+ --next-tab: it takes --changes, --live and --no-values too; watch off stops waiting, and modes lists it. One tab is
340
372
  waited for at a time: while one client waits, another's watch on --next-tab is refused, and its watch
341
- off leaves the wait on.`,
373
+ off leaves the wait on.
374
+
375
+ watch save: a submit is left out, since the click or Enter that caused it is there. So is a URL the app
376
+ changed without loading a page (history.pushState), as a comment: the step before it changes it again.
377
+ In an open shadow root, an element's selector is its host's, then its own; a closed one is out of reach.`,
342
378
  },
343
379
  text: { usage: 'text [--all] <selector>', summary: 'visible text of the first match' },
344
- html: { usage: 'html [--all] <selector>', summary: 'outer HTML of the first match' },
345
- attrs: { usage: 'attrs [--all] <selector>', summary: 'attributes of the first match' },
380
+ html: { usage: 'html [--all] <selector>', summary: 'outer HTML of the first match; no password values' },
381
+ attrs: { usage: 'attrs [--all] <selector>', summary: 'attributes of the first match; no password values' },
346
382
  listeners: {
347
383
  usage: 'listeners <selector>|document|window',
348
384
  summary: 'the page\'s event listeners on an element',
@@ -359,26 +395,37 @@ Playwright adds to window as it acts are left out, and it says how many.`,
359
395
  links: { usage: 'links [--all]', summary: 'links on the page (text and href)' },
360
396
  inputs: { usage: 'inputs [--all]', summary: 'form controls on the page; values are omitted' },
361
397
  highlight: {
362
- usage: 'highlight [<selector> [--style=<css>] | off]',
398
+ usage: 'highlight [<selector> | off] [options]',
363
399
  summary: 'draw a box over elements, for the user or a screenshot',
364
400
  args: [
365
- ['<selector>', 'draws Playwright\'s highlight over every match, as playwright-cli\'s does: a tinted box labelled with its locator, that follows the element as the page scrolls or changes, and is in screenshots.'],
401
+ ['<selector>', 'draws Playwright\'s highlight over every match, as playwright-cli\'s does: a tinted box that follows the element as the page scrolls or changes, and is in screenshots.'],
366
402
  ['(none)', 'lists the selected tab\'s highlights.'],
367
403
  ['off', 'hides them all, as highlight --hide does.'],
368
404
  ],
369
405
  options: [
370
406
  ['--style=<css>', 'CSS added to the box, quoted if it has spaces: highlight e5 --style="outline: 3px solid blue; border-radius: 50%" circles e5 in blue.'],
371
407
  ['--hide <selector>', 'hides one.'],
408
+ ['--labels on|off', 'labels every box in the selected tab with its locator, as Playwright does, or not: those drawn already too. With a selector or on its own (highlight --labels on). Default: off.'],
372
409
  ],
373
410
  detail: `A highlight is a mode of its tab: modes shows it, modes off hides it, and the REPL hides every one as
374
411
  it stops. Loading a new page drops them; a page that changes its route without loading keeps them.
375
412
 
376
413
  The box is drawn over the page, in an element of Playwright's own added to it (x-pw-glass); the page's
377
- own elements are not changed, and clicks and typing reach them as before. Playwright always labels the
378
- box, and the label can cover what is just below it.`,
379
- moreAbout: 'marking a screenshot without the label',
380
- more: `For a screenshot without the label, eval can outline the element itself (with border-radius: 50% for
381
- a circle). That changes the page, so put it back afterwards, as it was if it had a style of its own:
414
+ own elements are not changed, and clicks and typing reach them as before.
415
+
416
+ Labels (--labels): Playwright labels each box with its locator, which covers what is just below it and
417
+ shows selectors in a screenshot or a video, so the REPL hides the labels unless they are on. On is a mode
418
+ of the tab, until --labels off or modes off. A box is labelled with the selector it was drawn from, e.g.
419
+ locator('role=button[name="Pay"]'); one drawn from a snapshot ref, with the locator Playwright makes for
420
+ its element, e.g. getByRole('button', { name: 'Pay' }), as the ref's own is not in the page's code.`,
421
+ moreAbout: 'labels inside a frame, and when they cannot be hidden',
422
+ more: `A highlight on an element inside an iframe is drawn in the frame's own document, and keeps its label.
423
+
424
+ The REPL hides the labels by reaching into Playwright's overlay, which a new Playwright could change: if
425
+ it cannot, it tells each client once, and the boxes are drawn with their labels.
426
+
427
+ For a screenshot without one then, eval can outline the element itself (with border-radius: 50% for a
428
+ circle). That changes the page, so put it back afterwards, as it was if it had a style of its own:
382
429
  eval document.querySelector("#rate-3").style.outline = "3px solid blue"
383
430
  eval document.querySelector("#rate-3").style.outline = ""`,
384
431
  },
@@ -399,14 +446,45 @@ else in /tmp.
399
446
 
400
447
  It brings the tab to the front of its window first: Chrome draws only the tab in front. The image is
401
448
  at CSS pixel size, one image pixel per CSS pixel. While emulate mobile is on, it is what the phone
402
- shows: a page laid out wider than the phone (no viewport meta tag) comes out shrunk to fit.`,
449
+ shows: a page laid out wider than the phone (no viewport meta tag) comes out shrunk to fit. An element bigger
450
+ than the viewport is taken with the page's scrollbar hidden for a moment, so that it does not move over.`,
451
+ },
452
+ toast: {
453
+ usage: 'toast <text> [options] | toast off',
454
+ summary: 'show a line of text over the page, to narrate a video',
455
+ args: [
456
+ ['<text>', 'the rest of the line, quotes around all of it removed; it wraps at 80% of the page\'s width.'],
457
+ ['off', 'fades it out.'],
458
+ ['(none)', 'says whether one is up, and what it says.'],
459
+ ],
460
+ options: [
461
+ ['--duration=<time>', 'hides it after that long: 3s, 1.5s, or milliseconds (1500). Default: stays until toast off or the next toast.'],
462
+ ['--read-time=<time>', 'while the tab records, the least time it stays up (below). Default: 300ms a word, at least 1.5s; 0 none.'],
463
+ ['--position=<where>', 'top, center or bottom. Default: bottom.'],
464
+ ['--size=<px>', 'the text\'s size. Default: 24.'],
465
+ ['--align=<how>', 'left, center or right, for text over more than one line. Default: center.'],
466
+ ['--color=<css>', 'the text\'s color. Default: #fff.'],
467
+ ['--background=<css>', 'its box\'s color. Default: rgba(20, 20, 20, .85).'],
468
+ ['--opacity=<0-1>', 'of the whole toast. Default: 1.'],
469
+ ],
470
+ detail: `It stays up until toast off or the next toast, which replaces it: the old one fades out, then the new
471
+ one fades in. It returns once it has faded in, so a screenshot right after it shows it whole. The
472
+ commands after it run while it is up, so it can say what they are doing.
473
+
474
+ Read time (--read-time): while the tab records, a toast stays up for its read time before the next
475
+ toast, toast off or record off takes it down; each waits out what is left of it, and says so. toast
476
+ says what its read time is, and past 15s, the send -t the command that takes it down needs: send waits
477
+ 20s by default. A toast with --duration has none: it ends by itself. Not recording, nothing waits.
478
+
479
+ It is a mode of its tab: modes lists it and modes off turns it off. It stays up across page loads, and
480
+ shows in screenshots and recordings. Selectors, snapshot and text don't see it, and it takes no clicks.`,
403
481
  },
404
482
  cursor: {
405
483
  usage: 'cursor [on [<selector> | <x> <y>] | off]',
406
484
  summary: 'draw a pointer where the REPL acts, for videos',
407
485
  args: [
408
486
  ['on', 'fades it in where the mouse last went, or the middle.'],
409
- ['on <selector>', 'fades it in on the element (a video\'s first, say), scrolled into view; when it is on already, glides it there.'],
487
+ ['on <selector>', 'fades it in on the element (a video\'s first, say); when it is on already, glides it there. An element partly in view is pointed at where it shows, without scrolling, so one the page cuts off still looks cut off; one out of view is scrolled to.'],
410
488
  ['on <x> <y>', 'the same, at a point in the viewport.'],
411
489
  ['off', 'fades it out.'],
412
490
  ['(none)', 'says whether it is on, and where.'],
@@ -511,7 +589,8 @@ goes on recording. There is no --mp4 or --webm: the file's ending picks the form
511
589
  ['(none)', 'shows what is on.'],
512
590
  ],
513
591
  detail: `Per tab, until turned off or the REPL exits; emulate mobile <device> while another is on switches to
514
- it. While mobile is on, viewport refuses: the device sets the size.
592
+ it. Mobile and viewport both set the screen size, so each refuses while the other is on: viewport off before
593
+ emulate mobile, and emulate mobile off before viewport.
515
594
 
516
595
  The page sees the user agent, touch and navigator.languages from its next load: reload after turning
517
596
  mobile or locale on or off. Touch means the page sees a touch screen; click still clicks with the
@@ -793,7 +872,7 @@ Names both have (goto, click, fill, snapshot, eval, ...) take playwright-cli's f
793
872
  Its other options are refused, never read as part of a selector or a value.
794
873
 
795
874
  Different on purpose, since the browser may be someone else's too:
796
- routes, network and emulation per tab, not the whole browser, so other people's tabs are untouched
875
+ routes, network and emulation per tab, not the whole browser, so other users' tabs are untouched
797
876
  close turns off your modes; tabs stay open (tab close <url-part> closes one)
798
877
  upload <selector> <file> names the input: file pickers are not caught, as the user's would be
799
878
  no snapshot after each command the user reads the pane too; snapshot when you need one