pw-repl 0.11.2 → 0.11.3

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
@@ -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
@@ -350,12 +350,18 @@ that was ever type=password, or whose autocomplete is for a password, a one-time
350
350
 
351
351
  Saving (watch save): a goto to where the watch started, then each step as a command, under a # line
352
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
353
+ element is found by its data-testid, its role and a piece of its name (role=button[name*="Save"]), its id,
354
+ name attribute or text; else by an id a framework made up, which another render can number
355
+ differently, or its place in the page, which a change to the page can break. A field changed again before
356
+ anything else was done (a date picker stepping through days) is filled once, with its last value. After
357
+ the app changed its URL without loading a page, the next step waits up to 30s for its element first, as
358
+ 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
359
  field (or any field, with --no-values) is a variable named after the field, e.g. fill '#password'
356
360
  "{{ PW_PASSWORD }}": run it with PW_PASSWORD=... pw-repl send --file login.txt, and send passes the
357
361
  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.
362
+ one no selector finds alone) stays a comment saying why, and the save says how many there are. The file
363
+ ends with the last step, not with what it brought: to end on a result that comes later (search results
364
+ loading), add a wait for it at the end (wait text ...).
359
365
 
360
366
  Shared: every client sees the same steps, and watch new's place is shared.`,
361
367
  moreAbout: 'bodies kept, --live timing, what --changes misses, one --next-tab at a time, and what watch save leaves out',
@@ -373,7 +379,8 @@ waited for at a time: while one client waits, another's watch on --next-tab is r
373
379
  off leaves the wait on.
374
380
 
375
381
  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.
382
+ 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.
377
384
  In an open shadow root, an element's selector is its host's, then its own; a closed one is out of reach.`,
378
385
  },
379
386
  text: { usage: 'text [--all] <selector>', summary: 'visible text of the first match' },
@@ -516,7 +523,7 @@ takes no clicks, and it points at elements inside frames too.`,
516
523
  optionsFor: 'record on',
517
524
  options: [
518
525
  ['--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.'],
526
+ ['--pause=<ms>', 'a pause after each action, and each highlight drawn, so the viewer sees it. Default: 750; 0 none.'],
520
527
  ['--lead=<ms>', 'still page recorded before record on returns. Default: 1000; counts toward the seconds.'],
521
528
  ['--tail=<ms>', 'still page recorded after record off, before it stops. Default: 1000; none when it stops at its seconds.'],
522
529
  ['--filename=<file>', 'the file, as playwright-cli writes it.'],
@@ -528,13 +535,13 @@ owner-only, as a screenshot is, and never replaces one. One recording per tab; a
528
535
  tab selected can stop it, and both are told whose it was.
529
536
 
530
537
  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
538
+ an element out of view is scrolled to smoothly, each action and each highlight drawn is followed by --pause, and with the cursor
532
539
  on it rests 300ms on the element before acting. fill still sets a value at once: use type for text the
533
540
  viewer should see typed. Send the take as a file of commands (pw-repl send --file take.txt), or the time
534
541
  between commands is in the video. help video has the whole way through.
535
542
 
536
543
  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
544
+ 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
545
  moment in the video ("at 0:12 it clicked Save"), to add captions or cuts in an editor, or to check what
539
546
  ran. It names the command and what it acted on, never what it typed or chose, the code it ran or a URL.
540
547
  A (page) line marks each new page, loaded or routed to by a single-page app. It is renamed with the
@@ -602,7 +609,7 @@ drew with them (times, colors set by its script) changes only when it reloads.`,
602
609
  summary: 'wait for an element, text, a response or a load (10s)',
603
610
  args: [
604
611
  ['<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.'],
612
+ ['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
613
  ['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
614
  ['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
615
  ['[secs]', 'how long, up to 120. Default: 10.'],
package/lib/inspect.js CHANGED
@@ -403,7 +403,8 @@ const commands = {
403
403
  let seconds = WAIT_DEFAULT;
404
404
  // A number last is the seconds, unless it is all there is to wait for: wait text 990.
405
405
  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());
406
+ const tookSeconds = tokens.length > 1 && !onlyWhat && /^\d+$/.test(tokens[tokens.length - 1]);
407
+ if (tookSeconds) seconds = Number(tokens.pop());
407
408
  if (!tokens.length || seconds < 1 || seconds > WAIT_MAX) throw new Error(`${usage} (1-${WAIT_MAX})`);
408
409
  const timeout = seconds * 1000;
409
410
  // Said plainly, not as Playwright's call log.
@@ -420,7 +421,9 @@ const commands = {
420
421
  return waitForRequest(what, timeout);
421
422
  }
422
423
  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`));
424
+ // Text that ends in a number gives it up as the seconds, unless quoted: said when it times out.
425
+ const quote = tookSeconds && !/^["']/.test(tokens[1]) ? ` (${seconds} was taken as the seconds: wait text "${what} ${seconds}" waits for that text)` : '';
426
+ await state.page.getByText(what).first().waitFor({ state: 'visible', timeout }).catch(plainly(`No visible text matches ${what} within ${seconds}s${quote}`));
424
427
  out.log(`Visible: ${what}`);
425
428
  return;
426
429
  }
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/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 = `(() => {
@@ -81,13 +83,96 @@ const WATCH_SCRIPT = `(() => {
81
83
  };
82
84
  const textOf = el => (el.textContent || '').replace(/\\s+/g, ' ').trim();
83
85
  const cssString = text => '"' + text.replace(/\\\\/g, '\\\\\\\\').replace(/"/g, '\\\\"') + '"';
86
+ // An id a framework made up (radix-vue-combobox-option-v-0-17-4, :r1:), which another render or build
87
+ // numbers differently: used only when nothing else finds the element.
88
+ const madeUpId = id => /:|\\d+\\D+\\d|\\d{3}/.test(id) || /^(?:radix|reka|headlessui|mui|react-aria|downshift)-/.test(id);
89
+ // Roles as Playwright's role selector reads them, for the elements a step acts on.
90
+ const WIDGET_ROLES = new Set(['button', 'checkbox', 'combobox', 'link', 'listbox', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab', 'textbox', 'treeitem']);
91
+ 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 };
92
+ const widgetRole = el => {
93
+ if (el.hasAttribute('role')) {
94
+ const role = el.getAttribute('role').trim().split(/\\s+/)[0];
95
+ return WIDGET_ROLES.has(role) ? role : null;
96
+ }
97
+ const tag = el.localName;
98
+ if ((tag === 'a' || tag === 'area') && el.hasAttribute('href')) return 'link';
99
+ if (tag === 'button') return 'button';
100
+ if (tag === 'textarea') return 'textbox';
101
+ if (tag === 'option') return 'option';
102
+ if (tag === 'select') return el.multiple || el.size > 1 ? 'listbox' : 'combobox';
103
+ if (tag !== 'input') return null;
104
+ if (el.list && /^(?:email|search|tel|text|url)$/.test(el.type)) return 'combobox';
105
+ return el.type in INPUT_ROLES ? INPUT_ROLES[el.type] : 'textbox';
106
+ };
107
+ // Pieces of its accessible name that Playwright's is sure to hold, longest first: an attribute that
108
+ // names it, or the text nodes it is named from (its label's, or its own), one at a time. Put together,
109
+ // they could be spaced as Playwright's are not: it sets a child apart by spaces by how it is laid out.
110
+ const plain = text => (text || '').replace(/\\s+/g, ' ').trim();
111
+ const NAMED_BY_TEXT = new Set(['button', 'checkbox', 'link', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'switch', 'tab', 'treeitem']);
112
+ // Text Playwright leaves out of a name, or names another way: hidden text, and text in an element
113
+ // named by an attribute, in a control, in an SVG or in the element a label names.
114
+ const textPieces = (root, el, hiddenToo) => {
115
+ const pieces = [];
116
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
117
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
118
+ const text = plain(node.data);
119
+ let at = node.parentElement;
120
+ if (!text || !at || (!hiddenToo && !at.checkVisibility({ visibilityProperty: true }))) continue;
121
+ for (; at && at !== root; at = at.parentElement) if (at === el || at.matches('[aria-hidden=true],[aria-label],[aria-labelledby],select,textarea,button,svg')) break;
122
+ if (at === root) pieces.push(text);
123
+ }
124
+ return pieces;
125
+ };
126
+ const namePieces = el => {
127
+ // Ids that name nothing are passed over, as Playwright passes them over.
128
+ const labelledBy = (el.getAttribute('aria-labelledby') || '').split(/\\s+/).map(id => id && document.getElementById(id)).filter(Boolean);
129
+ if (labelledBy.length) return labelledBy.flatMap(part => textPieces(part, null, true));
130
+ if (plain(el.getAttribute('aria-label'))) return [plain(el.getAttribute('aria-label'))];
131
+ if (el.labels && el.labels.length) return [...el.labels].flatMap(label => textPieces(label, el, false));
132
+ const role = widgetRole(el);
133
+ const own = NAMED_BY_TEXT.has(role) ? textPieces(el, null, false) : [];
134
+ if (own.length) return own;
135
+ // An input's title and placeholder: Playwright takes one or the other, so only one there is.
136
+ const named = [el.getAttribute('title'), typedInto(el) ? el.getAttribute('placeholder') : null].map(plain).filter(Boolean);
137
+ return named.length === 1 ? named : [];
138
+ };
139
+ // Everything an element's name could hold, without spaces, for whether another element's name could
140
+ // hold a piece of this one's: more than it does, never less.
141
+ const squash = text => (text || '').replace(/\\s+/g, '').toLowerCase();
142
+ // An input that is a button is named by its value (Submit or Reset without one), or as an image its alt.
143
+ const inputButtonName = el => el.localName === 'input' && /^(?:submit|button|reset|image)$/.test(el.type)
144
+ ? [el.value, { submit: 'Submit', reset: 'Reset' }[el.type], el.getAttribute('alt')].join(' ') : '';
145
+ const nameHay = el => squash([el.getAttribute('aria-label'), el.getAttribute('title'), el.getAttribute('placeholder'), el.textContent, inputButtonName(el),
146
+ ...(el.getAttribute('aria-labelledby') || '').split(/\\s+/).map(id => id && document.getElementById(id)?.textContent),
147
+ ...[...(el.labels || [])].map(label => label.textContent),
148
+ ...[...el.querySelectorAll('[alt],[aria-label],[title]')].map(n => [n.getAttribute('alt'), n.getAttribute('aria-label'), n.getAttribute('title')].join(' '))].join(' '));
149
+ // role=<role>[name*="<piece>"], with the first piece of its name that finds it and no other: *= finds
150
+ // the piece anywhere in an element's name, in any case, so every element of the role whose name could
151
+ // hold it is counted.
152
+ const roleSelector = el => {
153
+ const role = widgetRole(el);
154
+ if (!role) return null;
155
+ // A long piece by its first words, which Playwright still finds. A count or a price alone would not
156
+ // find it again.
157
+ 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*$/, '')))];
158
+ pieces.sort((a, b) => b.length - a.length);
159
+ const others = [];
160
+ for (const r of shadowRoots(document)) for (const other of r.querySelectorAll('*')) if (other !== el && widgetRole(other) === role) others.push(nameHay(other));
161
+ const piece = pieces.slice(0, 5).find(piece => !others.some(hay => hay.includes(squash(piece))));
162
+ return piece ? 'role=' + role + '[name*=' + cssString(piece) + ']' : null;
163
+ };
84
164
  const selectorIn = (el, scope) => {
85
165
  const roots = shadowRoots(scope);
86
166
  const count = sel => { let n = 0; for (const r of roots) n += r.querySelectorAll(sel).length; return n; };
167
+ const ownId = at => at.id && count('#' + CSS.escape(at.id)) === 1;
87
168
  const tag = el.localName;
88
- if (el.id && count('#' + CSS.escape(el.id)) === 1) return '#' + CSS.escape(el.id);
89
169
  const testid = el.getAttribute('data-testid');
90
170
  if (testid && count('[data-testid=' + cssString(testid) + ']') === 1) return '[data-testid=' + cssString(testid) + ']';
171
+ // Before an id, as Playwright's own locators are: an id can be a counter's that looks written by hand.
172
+ // Playwright's role selector goes into shadow roots, but takes no CSS before it: in the document only.
173
+ const byRole = scope === document && roleSelector(el);
174
+ if (byRole) return byRole;
175
+ if (ownId(el) && !madeUpId(el.id)) return '#' + CSS.escape(el.id);
91
176
  const name = el.getAttribute('name');
92
177
  if (name && /^(?:input|select|textarea)$/.test(tag) && count(tag + '[name=' + cssString(name) + ']') === 1) return tag + '[name=' + cssString(name) + ']';
93
178
  const text = textOf(el);
@@ -96,10 +181,11 @@ const WATCH_SCRIPT = `(() => {
96
181
  for (const r of roots) for (const other of r.querySelectorAll(tag)) if (textOf(other) === text) same++;
97
182
  if (same === 1) return tag + ':text-is(' + cssString(text) + ')';
98
183
  }
184
+ if (ownId(el)) return '#' + CSS.escape(el.id);
99
185
  // Its place: the path down from the nearest ancestor with an id of its own, or from the top.
100
186
  const steps = [];
101
187
  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; }
188
+ if (at !== el && ownId(at) && !madeUpId(at.id)) { steps.unshift('#' + CSS.escape(at.id)); break; }
103
189
  if (at.localName === 'body') { steps.unshift('body'); break; }
104
190
  const same = [...at.parentNode.children].filter(c => c.localName === at.localName);
105
191
  steps.unshift(same.length > 1 ? at.localName + ':nth-of-type(' + (same.indexOf(at) + 1) + ')' : at.localName);
@@ -134,6 +220,8 @@ const WATCH_SCRIPT = `(() => {
134
220
  if (window !== window.top) return null;
135
221
  const parts = partsOf(el);
136
222
  if (!parts) return null;
223
+ // Counted as Playwright matches it already, which el.matches cannot.
224
+ if (parts[0].startsWith('role=')) return parts[0];
137
225
  let n = 0;
138
226
  for (const r of shadowRoots(document)) for (const other of r.querySelectorAll('*')) if (matchesParts(other, parts) && ++n > 1) return null;
139
227
  return n === 1 ? parts.join(' ') : null;
@@ -160,6 +248,9 @@ const WATCH_SCRIPT = `(() => {
160
248
  const el = t.closest ? (t.closest(INTERACTIVE) || t) : t;
161
249
  if (!el.matches || el.matches('input[type=checkbox],input[type=radio],select') || isPassword(el)) return;
162
250
  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;
163
254
  send('click', el);
164
255
  }, true);
165
256
  // Typing is recorded once it pauses, not on change, so a typeahead's requests
@@ -369,7 +460,7 @@ async function startWatching(p, changes = false, live = false, values = true) {
369
460
  if (!created.on) return;
370
461
  const previous = created.events[created.events.length - 1];
371
462
  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 };
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 };
373
464
  if (typeof more.option === 'string') step.option = more.option;
374
465
  if (typeof more.value === 'string' && created.values) step.value = more.value;
375
466
  keep(created.events, step);
@@ -402,7 +493,15 @@ async function startWatching(p, changes = false, live = false, values = true) {
402
493
  }
403
494
  tabState(p).watchBinding.record = record;
404
495
  await p.addInitScript(WATCH_SCRIPT);
405
- p.on('framenavigated', frame => { if (frame === p.mainFrame()) record('navigate', frame.url(), '', 0, { url: frame.url() }); });
496
+ // Whether it loaded a document is read now, while the request log still has the request: a page's
497
+ // document is requested before it commits, after the step before it.
498
+ p.on('framenavigated', frame => {
499
+ if (frame !== p.mainFrame()) return;
500
+ const url = frame.url();
501
+ const since = created.events[created.events.length - 1]?.t ?? 0;
502
+ const loaded = (tabState(p).log || []).some(r => r.navigation && r.url === url && r.t >= since);
503
+ record('navigate', url, '', 0, { url, loaded });
504
+ });
406
505
  // Only now: a failed first attempt must leave nothing half set up to retry against.
407
506
  tabState(p).watch = created;
408
507
  watch = created;
@@ -559,7 +658,6 @@ function showWatch(watch, all) {
559
658
  function savedCommands(watch, p) {
560
659
  const sel = text => (text.includes("'") ? `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` : `'${text}'`);
561
660
  const value = text => `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
562
- const documents = (tabState(p).log || []).filter(r => r.navigation);
563
661
  const events = watch.events.slice();
564
662
  const first = events[0]?.action === 'navigate' ? events.shift().url : watch.startUrl;
565
663
  const lines = [`goto ${first}`];
@@ -577,19 +675,31 @@ function savedCommands(watch, p) {
577
675
  return name;
578
676
  };
579
677
  const comment = (e, why) => { skipped += 1; lines[lines.length - 1] += `: ${why}`; };
678
+ // A field changed again before anything else was done (a date picker stepping through days, typing
679
+ // that paused) is filled once, with its last value.
680
+ const changedAgain = i => {
681
+ const e = events[i];
682
+ if ((e.action !== 'type' && e.action !== 'fill') || !e.selector) return false;
683
+ const next = events.slice(i + 1).find(n => !(n.action === 'navigate' && !n.loaded));
684
+ return !!next && (next.action === 'type' || next.action === 'fill') && next.selector === e.selector;
685
+ };
686
+ // After the app's own routing, the next step waits for its element: the new view can take longer to
687
+ // draw than an action waits.
688
+ let routed = false;
580
689
  for (const [i, e] of events.entries()) {
581
690
  const previous = events[i - 1];
582
691
  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
- }
692
+ // One that loaded no document was the app's own routing (the history API), which the step before
693
+ // it does again when the steps run: never a goto, or the steps would go there twice. One that
694
+ // loaded a document is waited for after an action, and gone to otherwise.
695
+ routed = !e.loaded;
696
+ if (!e.loaded) lines.push(`# the app went to ${e.url} without loading a page`);
697
+ else lines.push(previous && previous.action !== 'navigate' ? 'wait load' : `goto ${e.url}`);
590
698
  continue;
591
699
  }
592
- if (e.action === 'submit') continue;
700
+ if (e.action === 'submit' || changedAgain(i)) continue;
701
+ if (routed && e.selector) lines.push(`wait ${sel(e.selector)} ${ROUTE_WAIT}`);
702
+ routed = false;
593
703
  lines.push(`# ${e.action} ${e.target}${e.extra ? ` ${e.extra}` : ''}`);
594
704
  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
705
  if (e.action === 'click' || e.action === 'check' || e.action === 'uncheck') lines.push(`${e.action} ${sel(e.selector)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pw-repl",
3
- "version": "0.11.2",
3
+ "version": "0.11.3",
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.