pw-repl 0.11.2 → 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/elements.js CHANGED
@@ -8,7 +8,7 @@ const { unquote, toSelector, keyName, splitSelector, REF } = require('./syntax')
8
8
  const { numbers, hints } = require('./util');
9
9
  const { tabState } = require('./tabstate');
10
10
  const { cursorOf, aiming, aimAt, glideTo, showClick, showSelect } = require('./cursor');
11
- const { recordingOf, stepTarget } = require('./record');
11
+ const { recordingOf, stepTarget, pauseToShow } = require('./record');
12
12
  const { settled } = require('./emulation');
13
13
 
14
14
  // The type a page sees for a file it is given, from its extension.
@@ -448,6 +448,10 @@ const commands = {
448
448
  await keepLabels(state.page);
449
449
  highlights.set(selector, { owner: state.client, style: style?.value || null, drawn });
450
450
  out.log(`Highlighted ${rest}${count > 1 ? ` (all ${count} matches)` : ''}; highlight --hide ${rest} hides it`);
451
+ // Recording, its steps say where the box is (the first match's), and it is shown before the next
452
+ // command, which may take it away.
453
+ if (recordingOf(state.page)) stepTarget(state.page, await locator.first().boundingBox({ timeout: 1000 }).catch(() => null), null);
454
+ pauseToShow(state.page);
451
455
  },
452
456
 
453
457
  // At a point, in CSS pixels from the top left of the tab's viewport, as playwright-cli's are.
package/lib/help.js CHANGED
@@ -99,7 +99,7 @@ page loads: turn off any the rehearsal left (toast off, cursor off, highlight of
99
99
  record off
100
100
 
101
101
  3. While it records, pw-repl paces it for a viewer (help record): type types at a readable pace (use it,
102
- not fill, for text the viewer should see typed), each action is followed by a pause, an element out of
102
+ not fill, for text the viewer should see typed), each action and each highlight is followed by a pause, an element out of
103
103
  view is scrolled to smoothly, and the video starts and ends on a still page. highlight <selector> draws a
104
104
  box to point at something, and toast <text> a line of text saying what is happening, up for as long as
105
105
  the viewer needs to read it. After a step that loads a page, wait for text only the result shows (wait
@@ -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.
@@ -350,12 +352,18 @@ that was ever type=password, or whose autocomplete is for a password, a one-time
350
352
 
351
353
  Saving (watch save): a goto to where the watch started, then each step as a command, under a # line
352
354
  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
+ element is found by its data-testid, its role and a piece of its name (role=button[name*="Save"]), its id,
356
+ name attribute or text; else by an id a framework made up, which another render can number
357
+ differently, or its place in the page, which a change to the page can break. A field changed again before
358
+ anything else was done (a date picker stepping through days) is filled once, with its last value. After
359
+ the app changed its URL without loading a page, the next step waits up to 30s for its element first, as
360
+ the new view may take longer to draw than an action waits. A select step chooses the option by its value. What was typed into a password
355
361
  field (or any field, with --no-values) is a variable named after the field, e.g. fill '#password'
356
362
  "{{ PW_PASSWORD }}": run it with PW_PASSWORD=... pw-repl send --file login.txt, and send passes the
357
363
  value along for the REPL to put in, which shows the line as written. 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.
364
+ one no selector finds alone) stays a comment saying why, and the save says how many there are. The file
365
+ ends with the last step, not with what it brought: to end on a result that comes later (search results
366
+ loading), add a wait for it at the end (wait text ...).
359
367
 
360
368
  Shared: every client sees the same steps, and watch new's place is shared.`,
361
369
  moreAbout: 'bodies kept, --live timing, what --changes misses, one --next-tab at a time, and what watch save leaves out',
@@ -373,7 +381,10 @@ waited for at a time: while one client waits, another's watch on --next-tab is r
373
381
  off leaves the wait on.
374
382
 
375
383
  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.
384
+ changed without loading a page (history.pushState or replaceState), as a comment: the step before it
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.
377
388
  In an open shadow root, an element's selector is its host's, then its own; a closed one is out of reach.`,
378
389
  },
379
390
  text: { usage: 'text [--all] <selector>', summary: 'visible text of the first match' },
@@ -393,7 +404,15 @@ Playwright adds to window as it acts are left out, and it says how many.`,
393
404
  count: { usage: 'count <selector>', summary: 'number of matches' },
394
405
  visible: { usage: 'visible <selector>', summary: 'whether the first match is visible' },
395
406
  links: { usage: 'links [--all]', summary: 'links on the page (text and href)' },
396
- 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
+ },
397
416
  highlight: {
398
417
  usage: 'highlight [<selector> | off] [options]',
399
418
  summary: 'draw a box over elements, for the user or a screenshot',
@@ -516,7 +535,7 @@ takes no clicks, and it points at elements inside frames too.`,
516
535
  optionsFor: 'record on',
517
536
  options: [
518
537
  ['--steps', 'also save clip.steps.txt next to clip.webm: when and where each command ran (Steps, below). Default: off.'],
519
- ['--pause=<ms>', 'a pause after each action, so the viewer sees what it did. Default: 750; 0 none.'],
538
+ ['--pause=<ms>', 'a pause after each action, and each highlight drawn, so the viewer sees it. Default: 750; 0 none.'],
520
539
  ['--lead=<ms>', 'still page recorded before record on returns. Default: 1000; counts toward the seconds.'],
521
540
  ['--tail=<ms>', 'still page recorded after record off, before it stops. Default: 1000; none when it stops at its seconds.'],
522
541
  ['--filename=<file>', 'the file, as playwright-cli writes it.'],
@@ -528,13 +547,13 @@ owner-only, as a screenshot is, and never replaces one. One recording per tab; a
528
547
  tab selected can stop it, and both are told whose it was.
529
548
 
530
549
  While recording: type types at a pace the video can show (help type), mousewheel scrolls in eased steps,
531
- an element out of view is scrolled to smoothly, each action is followed by --pause, and with the cursor
550
+ an element out of view is scrolled to smoothly, each action and each highlight drawn is followed by --pause, and with the cursor
532
551
  on it rests 300ms on the element before acting. fill still sets a value at once: use type for text the
533
552
  viewer should see typed. Send the take as a file of commands (pw-repl send --file take.txt), or the time
534
553
  between commands is in the video. help video has the whole way through.
535
554
 
536
555
  Steps (--steps): one line per command run on the tab while it recorded: when it began and ended, in
537
- seconds into the video, and the element or point it acted on, in the video's pixels. Use it to find a
556
+ seconds into the video, and the element or point it acted on (or highlighted), in the video's pixels. Use it to find a
538
557
  moment in the video ("at 0:12 it clicked Save"), to add captions or cuts in an editor, or to check what
539
558
  ran. It names the command and what it acted on, never what it typed or chose, the code it ran or a URL.
540
559
  A (page) line marks each new page, loaded or routed to by a single-page app. It is renamed with the
@@ -602,7 +621,7 @@ drew with them (times, colors set by its script) changes only when it reloads.`,
602
621
  summary: 'wait for an element, text, a response or a load (10s)',
603
622
  args: [
604
623
  ['<selector>', 'for a matching element to be visible.'],
605
- ['text <text>', 'for text to be visible: any part of an element\'s text, any case.'],
624
+ ['text <text>', 'for text to be visible: any part of an element\'s text, any case. Quote text that ends in a number, or the number is taken as the seconds: wait text "Showing 1".'],
606
625
  ['request <part>', 'for a matching response (a URL part or a glob), counting one that finished since the previous command began, or is still under way: click, then wait request, does not miss it.'],
607
626
  ['load', 'for the page to finish loading (its load event), counting a navigation that began since the previous command began: click a link, then wait load.'],
608
627
  ['[secs]', 'how long, up to 120. Default: 10.'],
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
  },
@@ -403,7 +433,8 @@ const commands = {
403
433
  let seconds = WAIT_DEFAULT;
404
434
  // A number last is the seconds, unless it is all there is to wait for: wait text 990.
405
435
  const onlyWhat = (tokens[0] === 'text' || tokens[0] === 'request') && tokens.length === 2;
406
- if (tokens.length > 1 && !onlyWhat && /^\d+$/.test(tokens[tokens.length - 1])) seconds = Number(tokens.pop());
436
+ const tookSeconds = tokens.length > 1 && !onlyWhat && /^\d+$/.test(tokens[tokens.length - 1]);
437
+ if (tookSeconds) seconds = Number(tokens.pop());
407
438
  if (!tokens.length || seconds < 1 || seconds > WAIT_MAX) throw new Error(`${usage} (1-${WAIT_MAX})`);
408
439
  const timeout = seconds * 1000;
409
440
  // Said plainly, not as Playwright's call log.
@@ -420,7 +451,9 @@ const commands = {
420
451
  return waitForRequest(what, timeout);
421
452
  }
422
453
  if (gone) return waitGone(state.page.getByText(what), what, timeout);
423
- await state.page.getByText(what).first().waitFor({ state: 'visible', timeout }).catch(plainly(`No visible text matches ${what} within ${seconds}s`));
454
+ // Text that ends in a number gives it up as the seconds, unless quoted: said when it times out.
455
+ const quote = tookSeconds && !/^["']/.test(tokens[1]) ? ` (${seconds} was taken as the seconds: wait text "${what} ${seconds}" waits for that text)` : '';
456
+ await state.page.getByText(what).first().waitFor({ state: 'visible', timeout }).catch(plainly(`No visible text matches ${what} within ${seconds}s${quote}`));
424
457
  out.log(`Visible: ${what}`);
425
458
  return;
426
459
  }
package/lib/record.js CHANGED
@@ -403,6 +403,8 @@ function stepLabel(cmd, args) {
403
403
  }
404
404
  // A toast's text is what it said in the video, for whoever edits it.
405
405
  if (cmd === 'toast') return `toast ${args.trim()}`.trim();
406
+ // A highlight's selector, or off: which box came and went, without its style.
407
+ if (cmd === 'highlight') return `highlight ${args.replace(/\s*--style=(?:"(?:[^"\\]|\\.)*"|'[^']*'|\S+)/, '').trim()}`.trim();
406
408
  if (!TARGET_FIRST.has(cmd)) return cmd;
407
409
  const rest = cmd === 'type' ? args.replace(/^\s*--delay(?:=|\s+)\S*\s*/, '') : args;
408
410
  const parsed = splitSelector(rest);
@@ -457,11 +459,20 @@ function beforeRecordingEnds(wait) {
457
459
  beforeEnding.push(wait);
458
460
  }
459
461
 
460
- // The pause after an action on a recording tab, for its result to be seen (record on --pause).
462
+ // The pause after an action on a recording tab, for its result to be seen (record on --pause), and
463
+ // after a command that drew something to be seen (pauseToShow), as highlight does. After the command's
464
+ // step ends, as an action's does.
461
465
  const ACTIONS = new Set([...WHOLE, ...TARGET_FIRST]);
466
+ function pauseToShow(p) {
467
+ const rec = recordingOf(p);
468
+ if (rec) rec.showing = true;
469
+ }
462
470
  async function pauseAfter(p, cmd) {
463
471
  const rec = p && !p.isClosed() ? recordingOf(p) : null;
464
- if (rec && !rec.finishing && rec.pacing.pause && ACTIONS.has(cmd)) await sleep(rec.pacing.pause);
472
+ if (!rec) return;
473
+ const show = rec.showing;
474
+ rec.showing = false;
475
+ if (!rec.finishing && rec.pacing.pause && (show || ACTIONS.has(cmd))) await sleep(rec.pacing.pause);
465
476
  }
466
477
 
467
478
  function stepsPath(file) {
@@ -692,4 +703,4 @@ const commands = {
692
703
  },
693
704
  };
694
705
 
695
- module.exports = { commands, recordingOf, beforeRecordingEnds, stepsOf, pauseAfter, finishRecording, endedByTabClose, findFfmpeg, jpegSize, stepStarted, stepEnded, stepTarget, cursorStep, stepLabel };
706
+ module.exports = { commands, recordingOf, beforeRecordingEnds, stepsOf, pauseAfter, pauseToShow, finishRecording, endedByTabClose, findFfmpeg, jpegSize, stepStarted, stepEnded, stepTarget, cursorStep, stepLabel };
package/lib/send.js CHANGED
@@ -98,8 +98,9 @@ function commandLine(words) {
98
98
  // words are joined as they are.
99
99
  const { SELECTOR_FIRST } = require('./syntax');
100
100
  // So does playwright-cli's storage key (localstorage-set "my key" v).
101
- // And record's file, which may have spaces in it.
102
- const requote = words.length > 1 && (SELECTOR_FIRST.has(words[0]) || /^(?:local|session)storage-/.test(words[0]) || words[0] === 'record' || /^video-(?:start|stop)$/.test(words[0]));
101
+ // And record's file, which may have spaces in it, and wait's text, whose last word would otherwise be
102
+ // taken as the seconds when it is a number (wait text 'Showing 1').
103
+ const requote = words.length > 1 && (SELECTOR_FIRST.has(words[0]) || /^(?:local|session)storage-/.test(words[0]) || words[0] === 'record' || words[0] === 'wait' || /^video-(?:start|stop)$/.test(words[0]));
103
104
  // An empty word (fill #name "") is the empty value.
104
105
  // upload's files are read by the REPL, whose folder may not be this one.
105
106
  // So are the files upload reads and screenshot --filename writes.
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
@@ -22,6 +22,8 @@ const PW_SETTINGS = ['PW_CDP_URL', 'PW_CHROME', 'PW_CLIENT', 'PW_ENDPOINT', 'PW_
22
22
  'PW_SOCKET', 'PW_START_URL', 'PW_TEST_CHROME', 'PW_TMUX_SESSION'];
23
23
  const PAGE_ACTIONS = new Set(['click', 'check', 'uncheck', 'select', 'fill', 'type', 'press', 'submit']);
24
24
  const WATCH_BINDING = '__pwReplWatch';
25
+ // Seconds a saved step waits for its element after the app's own routing (watch save).
26
+ const ROUTE_WAIT = 30;
25
27
 
26
28
  // Runs in the page. Describes elements the way snapshot does: role and name.
27
29
  const WATCH_SCRIPT = `(() => {
@@ -62,6 +64,30 @@ const WATCH_SCRIPT = `(() => {
62
64
  || el.getAttribute('placeholder') || el.getAttribute('title') || (typedInto(el) || hasEditable(el) ? '' : el.innerText));
63
65
  };
64
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
+ };
65
91
  // A field that was a password field while watched stays one when a "show password" button makes it
66
92
  // text; and some take secrets under another type. Their values never leave the page.
67
93
  const secrets = new WeakSet();
@@ -81,13 +107,96 @@ const WATCH_SCRIPT = `(() => {
81
107
  };
82
108
  const textOf = el => (el.textContent || '').replace(/\\s+/g, ' ').trim();
83
109
  const cssString = text => '"' + text.replace(/\\\\/g, '\\\\\\\\').replace(/"/g, '\\\\"') + '"';
110
+ // An id a framework made up (radix-vue-combobox-option-v-0-17-4, :r1:), which another render or build
111
+ // numbers differently: used only when nothing else finds the element.
112
+ const madeUpId = id => /:|\\d+\\D+\\d|\\d{3}/.test(id) || /^(?:radix|reka|headlessui|mui|react-aria|downshift)-/.test(id);
113
+ // Roles as Playwright's role selector reads them, for the elements a step acts on.
114
+ const WIDGET_ROLES = new Set(['button', 'checkbox', 'combobox', 'link', 'listbox', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab', 'textbox', 'treeitem']);
115
+ const INPUT_ROLES = { checkbox: 'checkbox', radio: 'radio', button: 'button', submit: 'button', reset: 'button', image: 'button', file: 'button', range: 'slider', number: 'spinbutton', search: 'searchbox', hidden: null };
116
+ const widgetRole = el => {
117
+ if (el.hasAttribute('role')) {
118
+ const role = el.getAttribute('role').trim().split(/\\s+/)[0];
119
+ return WIDGET_ROLES.has(role) ? role : null;
120
+ }
121
+ const tag = el.localName;
122
+ if ((tag === 'a' || tag === 'area') && el.hasAttribute('href')) return 'link';
123
+ if (tag === 'button') return 'button';
124
+ if (tag === 'textarea') return 'textbox';
125
+ if (tag === 'option') return 'option';
126
+ if (tag === 'select') return el.multiple || el.size > 1 ? 'listbox' : 'combobox';
127
+ if (tag !== 'input') return null;
128
+ if (el.list && /^(?:email|search|tel|text|url)$/.test(el.type)) return 'combobox';
129
+ return el.type in INPUT_ROLES ? INPUT_ROLES[el.type] : 'textbox';
130
+ };
131
+ // Pieces of its accessible name that Playwright's is sure to hold, longest first: an attribute that
132
+ // names it, or the text nodes it is named from (its label's, or its own), one at a time. Put together,
133
+ // they could be spaced as Playwright's are not: it sets a child apart by spaces by how it is laid out.
134
+ const plain = text => (text || '').replace(/\\s+/g, ' ').trim();
135
+ const NAMED_BY_TEXT = new Set(['button', 'checkbox', 'link', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'switch', 'tab', 'treeitem']);
136
+ // Text Playwright leaves out of a name, or names another way: hidden text, and text in an element
137
+ // named by an attribute, in a control, in an SVG or in the element a label names.
138
+ const textPieces = (root, el, hiddenToo) => {
139
+ const pieces = [];
140
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
141
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
142
+ const text = plain(node.data);
143
+ let at = node.parentElement;
144
+ if (!text || !at || (!hiddenToo && !at.checkVisibility({ visibilityProperty: true }))) continue;
145
+ for (; at && at !== root; at = at.parentElement) if (at === el || at.matches('[aria-hidden=true],[aria-label],[aria-labelledby],select,textarea,button,svg')) break;
146
+ if (at === root) pieces.push(text);
147
+ }
148
+ return pieces;
149
+ };
150
+ const namePieces = el => {
151
+ // Ids that name nothing are passed over, as Playwright passes them over.
152
+ const labelledBy = (el.getAttribute('aria-labelledby') || '').split(/\\s+/).map(id => id && document.getElementById(id)).filter(Boolean);
153
+ if (labelledBy.length) return labelledBy.flatMap(part => textPieces(part, null, true));
154
+ if (plain(el.getAttribute('aria-label'))) return [plain(el.getAttribute('aria-label'))];
155
+ if (el.labels && el.labels.length) return [...el.labels].flatMap(label => textPieces(label, el, false));
156
+ const role = widgetRole(el);
157
+ const own = NAMED_BY_TEXT.has(role) ? textPieces(el, null, false) : [];
158
+ if (own.length) return own;
159
+ // An input's title and placeholder: Playwright takes one or the other, so only one there is.
160
+ const named = [el.getAttribute('title'), typedInto(el) ? el.getAttribute('placeholder') : null].map(plain).filter(Boolean);
161
+ return named.length === 1 ? named : [];
162
+ };
163
+ // Everything an element's name could hold, without spaces, for whether another element's name could
164
+ // hold a piece of this one's: more than it does, never less.
165
+ const squash = text => (text || '').replace(/\\s+/g, '').toLowerCase();
166
+ // An input that is a button is named by its value (Submit or Reset without one), or as an image its alt.
167
+ const inputButtonName = el => el.localName === 'input' && /^(?:submit|button|reset|image)$/.test(el.type)
168
+ ? [el.value, { submit: 'Submit', reset: 'Reset' }[el.type], el.getAttribute('alt')].join(' ') : '';
169
+ const nameHay = el => squash([el.getAttribute('aria-label'), el.getAttribute('title'), el.getAttribute('placeholder'), el.textContent, inputButtonName(el),
170
+ ...(el.getAttribute('aria-labelledby') || '').split(/\\s+/).map(id => id && document.getElementById(id)?.textContent),
171
+ ...[...(el.labels || [])].map(label => label.textContent),
172
+ ...[...el.querySelectorAll('[alt],[aria-label],[title]')].map(n => [n.getAttribute('alt'), n.getAttribute('aria-label'), n.getAttribute('title')].join(' '))].join(' '));
173
+ // role=<role>[name*="<piece>"], with the first piece of its name that finds it and no other: *= finds
174
+ // the piece anywhere in an element's name, in any case, so every element of the role whose name could
175
+ // hold it is counted.
176
+ const roleSelector = el => {
177
+ const role = widgetRole(el);
178
+ if (!role) return null;
179
+ // A long piece by its first words, which Playwright still finds. A count or a price alone would not
180
+ // find it again.
181
+ const pieces = [...new Set(namePieces(el).filter(piece => /\\p{L}/u.test(piece)).map(piece => piece.length <= 60 ? piece : piece.slice(0, 61).replace(/\\s+\\S*$/, '')))];
182
+ pieces.sort((a, b) => b.length - a.length);
183
+ const others = [];
184
+ for (const r of shadowRoots(document)) for (const other of r.querySelectorAll('*')) if (other !== el && widgetRole(other) === role) others.push(nameHay(other));
185
+ const piece = pieces.slice(0, 5).find(piece => !others.some(hay => hay.includes(squash(piece))));
186
+ return piece ? 'role=' + role + '[name*=' + cssString(piece) + ']' : null;
187
+ };
84
188
  const selectorIn = (el, scope) => {
85
189
  const roots = shadowRoots(scope);
86
190
  const count = sel => { let n = 0; for (const r of roots) n += r.querySelectorAll(sel).length; return n; };
191
+ const ownId = at => at.id && count('#' + CSS.escape(at.id)) === 1;
87
192
  const tag = el.localName;
88
- if (el.id && count('#' + CSS.escape(el.id)) === 1) return '#' + CSS.escape(el.id);
89
193
  const testid = el.getAttribute('data-testid');
90
194
  if (testid && count('[data-testid=' + cssString(testid) + ']') === 1) return '[data-testid=' + cssString(testid) + ']';
195
+ // Before an id, as Playwright's own locators are: an id can be a counter's that looks written by hand.
196
+ // Playwright's role selector goes into shadow roots, but takes no CSS before it: in the document only.
197
+ const byRole = scope === document && roleSelector(el);
198
+ if (byRole) return byRole;
199
+ if (ownId(el) && !madeUpId(el.id)) return '#' + CSS.escape(el.id);
91
200
  const name = el.getAttribute('name');
92
201
  if (name && /^(?:input|select|textarea)$/.test(tag) && count(tag + '[name=' + cssString(name) + ']') === 1) return tag + '[name=' + cssString(name) + ']';
93
202
  const text = textOf(el);
@@ -96,10 +205,11 @@ const WATCH_SCRIPT = `(() => {
96
205
  for (const r of roots) for (const other of r.querySelectorAll(tag)) if (textOf(other) === text) same++;
97
206
  if (same === 1) return tag + ':text-is(' + cssString(text) + ')';
98
207
  }
208
+ if (ownId(el)) return '#' + CSS.escape(el.id);
99
209
  // Its place: the path down from the nearest ancestor with an id of its own, or from the top.
100
210
  const steps = [];
101
211
  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; }
212
+ if (at !== el && ownId(at) && !madeUpId(at.id)) { steps.unshift('#' + CSS.escape(at.id)); break; }
103
213
  if (at.localName === 'body') { steps.unshift('body'); break; }
104
214
  const same = [...at.parentNode.children].filter(c => c.localName === at.localName);
105
215
  steps.unshift(same.length > 1 ? at.localName + ':nth-of-type(' + (same.indexOf(at) + 1) + ')' : at.localName);
@@ -134,6 +244,8 @@ const WATCH_SCRIPT = `(() => {
134
244
  if (window !== window.top) return null;
135
245
  const parts = partsOf(el);
136
246
  if (!parts) return null;
247
+ // Counted as Playwright matches it already, which el.matches cannot.
248
+ if (parts[0].startsWith('role=')) return parts[0];
137
249
  let n = 0;
138
250
  for (const r of shadowRoots(document)) for (const other of r.querySelectorAll('*')) if (matchesParts(other, parts) && ++n > 1) return null;
139
251
  return n === 1 ? parts.join(' ') : null;
@@ -145,21 +257,34 @@ const WATCH_SCRIPT = `(() => {
145
257
  if (typeof window.__pwReplWatch !== 'function') return;
146
258
  let selector = null;
147
259
  try { selector = el ? selectorOf(el) : null; } catch {}
260
+ let context = '';
261
+ try { context = el ? contextOf(el) : ''; } catch {}
148
262
  const typed = (action === 'type' || action === 'fill') && el;
149
263
  const secret = typed && isSecret(el);
150
264
  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 }));
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 }));
152
266
  };
153
267
  // The element itself, not its component's host, when it is inside an open shadow root.
154
268
  const targetOf = e => (e.composedPath && e.composedPath()[0]) || e.target;
155
269
  // Enter in a form's field submits it with a click on its submit button, which is the Enter's doing:
156
270
  // recorded too, it would submit the form again when the steps run again.
157
271
  let enteredForm = null;
272
+ let labelled = null;
158
273
  document.addEventListener('click', e => {
159
274
  const t = targetOf(e);
160
275
  const el = t.closest ? (t.closest(INTERACTIVE) || t) : t;
161
276
  if (!el.matches || el.matches('input[type=checkbox],input[type=radio],select') || isPassword(el)) return;
162
277
  if (e.detail === 0 && enteredForm && el.form === enteredForm) 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;
163
288
  send('click', el);
164
289
  }, true);
165
290
  // Typing is recorded once it pauses, not on change, so a typeahead's requests
@@ -204,7 +329,7 @@ const WATCH_SCRIPT = `(() => {
204
329
  }, true);
205
330
  document.addEventListener('change', e => {
206
331
  const el = targetOf(e);
207
- if (!el.matches) return;
332
+ if (!el.matches || el === labelled) return;
208
333
  if (el.matches('input[type=checkbox],input[type=radio]')) return send(el.checked ? 'check' : 'uncheck', el);
209
334
  if (el.matches('select')) return send('select', el, JSON.stringify(clip(el.selectedOptions[0]?.text)));
210
335
  if (pending.has(el)) { flushTyping(el); typed.delete(el); return; }
@@ -369,7 +494,7 @@ async function startWatching(p, changes = false, live = false, values = true) {
369
494
  if (!created.on) return;
370
495
  const previous = created.events[created.events.length - 1];
371
496
  const t = Math.max(Date.now() - Math.min(Math.max(Number(since) || 0, 0), TYPING_BACKDATE_MAX), previous ? previous.t : 0);
372
- const step = { seq: created.nextSeq++, t, action, target: clipText(target), extra: clipText(extra), selector: more.selector || null, secret: !!more.secret, url: more.url };
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 };
373
498
  if (typeof more.option === 'string') step.option = more.option;
374
499
  if (typeof more.value === 'string' && created.values) step.value = more.value;
375
500
  keep(created.events, step);
@@ -392,7 +517,7 @@ async function startWatching(p, changes = false, live = false, values = true) {
392
517
  try { event = JSON.parse(payload); } catch { return; }
393
518
  if (event && PAGE_ACTIONS.has(event.action)) {
394
519
  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 });
520
+ { selector: typeof event.selector === 'string' ? event.selector : null, secret: !!event.secret, value: event.value, option: event.option, context: String(event.context || '') });
396
521
  }
397
522
  });
398
523
  // Calls are reported only to a session with Runtime on.
@@ -402,7 +527,15 @@ async function startWatching(p, changes = false, live = false, values = true) {
402
527
  }
403
528
  tabState(p).watchBinding.record = record;
404
529
  await p.addInitScript(WATCH_SCRIPT);
405
- p.on('framenavigated', frame => { if (frame === p.mainFrame()) record('navigate', frame.url(), '', 0, { url: frame.url() }); });
530
+ // Whether it loaded a document is read now, while the request log still has the request: a page's
531
+ // document is requested before it commits, after the step before it.
532
+ p.on('framenavigated', frame => {
533
+ if (frame !== p.mainFrame()) return;
534
+ const url = frame.url();
535
+ const since = created.events[created.events.length - 1]?.t ?? 0;
536
+ const loaded = (tabState(p).log || []).some(r => r.navigation && r.url === url && r.t >= since);
537
+ record('navigate', url, '', 0, { url, loaded });
538
+ });
406
539
  // Only now: a failed first attempt must leave nothing half set up to retry against.
407
540
  tabState(p).watch = created;
408
541
  watch = created;
@@ -520,7 +653,7 @@ function requestsByStep(watch, p = state.page) {
520
653
 
521
654
  function stepLines(e, caused, note = '', withChanges = true) {
522
655
  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}`];
656
+ const lines = [`${clock(e.t)} ${e.action} ${e.target}${e.context ? ` (${e.context})` : ''}${typed}${e.extra ? ` ${e.extra}` : ''}${note}`];
524
657
  const indent = ' '.repeat(clock(e.t).length + 1);
525
658
  for (const r of caused.slice(0, WATCH_REQUESTS_SHOWN)) lines.push(`${indent}#${r.id} ${r.method} ${r.status} ${r.url}`);
526
659
  if (caused.length > WATCH_REQUESTS_SHOWN) lines.push(`${indent}… ${caused.length - WATCH_REQUESTS_SHOWN} more (requests)`);
@@ -559,7 +692,6 @@ function showWatch(watch, all) {
559
692
  function savedCommands(watch, p) {
560
693
  const sel = text => (text.includes("'") ? `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` : `'${text}'`);
561
694
  const value = text => `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
562
- const documents = (tabState(p).log || []).filter(r => r.navigation);
563
695
  const events = watch.events.slice();
564
696
  const first = events[0]?.action === 'navigate' ? events.shift().url : watch.startUrl;
565
697
  const lines = [`goto ${first}`];
@@ -577,20 +709,32 @@ function savedCommands(watch, p) {
577
709
  return name;
578
710
  };
579
711
  const comment = (e, why) => { skipped += 1; lines[lines.length - 1] += `: ${why}`; };
712
+ // A field changed again before anything else was done (a date picker stepping through days, typing
713
+ // that paused) is filled once, with its last value.
714
+ const changedAgain = i => {
715
+ const e = events[i];
716
+ if ((e.action !== 'type' && e.action !== 'fill') || !e.selector) return false;
717
+ const next = events.slice(i + 1).find(n => !(n.action === 'navigate' && !n.loaded));
718
+ return !!next && (next.action === 'type' || next.action === 'fill') && next.selector === e.selector;
719
+ };
720
+ // After the app's own routing, the next step waits for its element: the new view can take longer to
721
+ // draw than an action waits.
722
+ let routed = false;
580
723
  for (const [i, e] of events.entries()) {
581
724
  const previous = events[i - 1];
582
725
  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
- }
726
+ // One that loaded no document was the app's own routing (the history API), which the step before
727
+ // it does again when the steps run: never a goto, or the steps would go there twice. One that
728
+ // loaded a document is waited for after an action, and gone to otherwise.
729
+ routed = !e.loaded;
730
+ if (!e.loaded) lines.push(`# the app went to ${e.url} without loading a page`);
731
+ else lines.push(previous && previous.action !== 'navigate' ? 'wait load' : `goto ${e.url}`);
590
732
  continue;
591
733
  }
592
- if (e.action === 'submit') continue;
593
- lines.push(`# ${e.action} ${e.target}${e.extra ? ` ${e.extra}` : ''}`);
734
+ if (e.action === 'submit' || changedAgain(i)) continue;
735
+ if (routed && e.selector) lines.push(`wait ${sel(e.selector)} ${ROUTE_WAIT}`);
736
+ routed = false;
737
+ lines.push(`# ${e.action} ${e.target}${e.context ? ` (${e.context})` : ''}${e.extra ? ` ${e.extra}` : ''}`);
594
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; }
595
739
  if (e.action === 'click' || e.action === 'check' || e.action === 'uncheck') lines.push(`${e.action} ${sel(e.selector)}`);
596
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.2",
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",
package/skill/SKILL.md CHANGED
@@ -109,8 +109,8 @@ pw-repl send -t 90 'screenshot -d 60' # wait longer than the 20s default
109
109
 
110
110
  Always send commands with `pw-repl send`; don't type into the pane yourself, except `quit` at the prompt
111
111
  of a REPL of your own, which `send` refuses. The words after `send` are the command. For `fill`, `type`,
112
- `select`, `press` and `upload`, a word quoted in your shell stays one word
113
- (`pw-repl send fill "text=Your name" Ada`); other commands get the words as they are, joined by
112
+ `select`, `press`, `upload` and `wait`, a word quoted in your shell stays one word
113
+ (`pw-repl send fill "text=Your name" Ada`, `pw-repl send wait text "Showing 1"`); other commands get the words as they are, joined by
114
114
  spaces (`pw-repl send eval "document.title + ' x'"`). A command that takes only a selector takes the
115
115
  whole line, spaces and all: `pw-repl send click "text=Your name"`. It works however the REPL was
116
116
  started:
@@ -224,6 +224,10 @@ pw-repl send -c <name> close # your modes off; tab close
224
224
  `tab <index>`. Closing a tab the REPL opened goes back to the tab before it, if the REPL opened that
225
225
  one too; otherwise no tab is selected. Tab numbers change when tabs open or close; `tab <url-part>`
226
226
  and `tab close <url-part>` pick a tab by its URL and refuse if it is ambiguous.
227
+ - A login form in the user's browser may be filled by its password manager, which can keep what it
228
+ filled from the page until someone clicks in the field or picks from its dropdown: a field that looks
229
+ filled can read as empty. Check a password field by its length (`eval` on its `value.length`), never
230
+ read or print it; if it stays empty, ask the user to fill it.
227
231
  - You can answer any dialog yourself, one you opened included: `dialog` shows it, and `dialog accept` or
228
232
  `dialog dismiss` answers it. Nothing answers one on its own, and while one is open its page and the
229
233
  commands that read it wait. One answered in the browser is gone from `dialog` too.