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 +3 -0
- package/lib/elements.js +5 -1
- package/lib/help.js +32 -13
- package/lib/inspect.js +44 -11
- package/lib/record.js +14 -3
- package/lib/send.js +3 -2
- package/lib/tabs.js +10 -4
- package/lib/watch.js +162 -18
- package/package.json +1 -1
- package/skill/SKILL.md +6 -2
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
|
|
354
|
-
|
|
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
|
|
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: {
|
|
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
|
|
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(
|
|
267
|
-
|
|
270
|
+
const inputs = await state.page.$$eval(INPUTS, els => {
|
|
271
|
+
const plain = text => (text || '').replace(/\s+/g, ' ').trim().slice(0, 60);
|
|
272
|
+
// A label's own words, without what the fields inside it hold.
|
|
273
|
+
const labelText = label => {
|
|
274
|
+
const copy = label.cloneNode(true);
|
|
275
|
+
copy.querySelectorAll('select, textarea, input, button').forEach(n => n.remove());
|
|
276
|
+
return copy.textContent;
|
|
277
|
+
};
|
|
278
|
+
const labelOf = e => {
|
|
279
|
+
const byIds = (e.getAttribute('aria-labelledby') || '').split(/\s+/).map(id => id && document.getElementById(id)).filter(Boolean);
|
|
280
|
+
if (byIds.length) return plain(byIds.map(n => n.textContent).join(' '));
|
|
281
|
+
return plain(e.getAttribute('aria-label')) || plain([...(e.labels || [])].map(labelText).join(' ')) || plain(e.getAttribute('title'));
|
|
282
|
+
};
|
|
283
|
+
const native = e => e.matches('input, select, textarea');
|
|
284
|
+
const checkedOf = e => {
|
|
285
|
+
if (native(e) && /^(?:checkbox|radio)$/.test(e.type)) return e.indeterminate ? 'mixed' : e.checked;
|
|
286
|
+
if (!/^(?:checkbox|radio|switch)$/.test(e.getAttribute('role'))) return undefined;
|
|
287
|
+
const state = e.getAttribute('aria-checked');
|
|
288
|
+
return state === 'mixed' ? 'mixed' : state === 'true';
|
|
289
|
+
};
|
|
290
|
+
return els.map(e => ({
|
|
268
291
|
tag: e.tagName.toLowerCase(),
|
|
269
|
-
|
|
270
|
-
|
|
292
|
+
role: e.getAttribute('role') || '',
|
|
293
|
+
// A button's own type (submit) is not what kind of control it is: its role says that.
|
|
294
|
+
type: native(e) ? e.type : '',
|
|
295
|
+
name: e.getAttribute('name') || '',
|
|
271
296
|
id: e.id || '',
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
297
|
+
label: labelOf(e),
|
|
298
|
+
placeholder: e.getAttribute('placeholder') || '',
|
|
299
|
+
// By its own attribute (a button that is a radio, as Radix does it), a disabled fieldset around it,
|
|
300
|
+
// or aria-disabled.
|
|
301
|
+
disabled: e.matches(':disabled') || e.getAttribute('aria-disabled') === 'true',
|
|
302
|
+
readonly: e.matches('input, textarea') ? e.readOnly : e.getAttribute('aria-readonly') === 'true',
|
|
303
|
+
checked: checkedOf(e),
|
|
304
|
+
autocomplete: native(e) ? e.autocomplete || '' : ''
|
|
305
|
+
}));
|
|
306
|
+
});
|
|
277
307
|
if (!inputs.length) { out.log('No inputs found'); return; }
|
|
278
308
|
printOutput(inputs, all);
|
|
279
309
|
},
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
167
|
+
// Opened by this client, with tab new: the prompt's and nameless senders' are theirs together.
|
|
168
|
+
const mine = p => 'opened' in tabState(p) && tabState(p).owner === state.client;
|
|
169
|
+
const byUrl = (part, closing = false) => {
|
|
168
170
|
const matches = all.filter(p => p.url().includes(part));
|
|
169
171
|
if (matches.length === 1) return matches[0];
|
|
170
172
|
if (!matches.length) throw new Error(`No tab URL contains "${part}"`);
|
|
171
|
-
throw new Error(`${matches.length} tabs match "${part}"; use a longer part:\n${matches.map(p => ` ${p.url()}`).join('\n')}`);
|
|
173
|
+
throw new Error(`${matches.length} tabs match "${part}"; use a longer part, or tab <index> from tab's listing${closing ? ', then tab close' : ''}:\n${matches.map(p => ` ${p.url()}${mine(p) ? ' (opened by you)' : ''}`).join('\n')}`);
|
|
172
174
|
};
|
|
173
175
|
if (/^\d+$/.test(subcommand)) {
|
|
174
176
|
if (parts.length) throw new Error(usage);
|
|
@@ -196,7 +198,11 @@ const commands = {
|
|
|
196
198
|
return listTabs();
|
|
197
199
|
}
|
|
198
200
|
if (subcommand === 'close') {
|
|
199
|
-
const
|
|
201
|
+
const part = parts.join(' ');
|
|
202
|
+
const matches = part ? all.filter(p => p.url().includes(part)) : [];
|
|
203
|
+
// Of several, the one tab you opened is the one to close: the others may be someone else's.
|
|
204
|
+
const yours = matches.length > 1 && matches.filter(mine).length === 1 ? matches.find(mine) : null;
|
|
205
|
+
const target = yours || (part ? byUrl(part, true) : state.page);
|
|
200
206
|
if (!target || target.isClosed() || !all.includes(target)) throw new Error('Selected tab is unavailable. Run tab again.');
|
|
201
207
|
if (all.length <= 1) throw new Error('Refusing to close the last tab');
|
|
202
208
|
const url = target.url();
|
|
@@ -208,7 +214,7 @@ const commands = {
|
|
|
208
214
|
state.page = back && !back.isClosed() && 'opened' in tabState(back) ? back : null;
|
|
209
215
|
state.previousPage = null;
|
|
210
216
|
}
|
|
211
|
-
out.log(`Closed ${url}${wasSelected && !state.page ? '; no tab is selected now' : ''}`);
|
|
217
|
+
out.log(`Closed ${url}${yours ? `, the one you opened; ${matches.length - 1} other ${matches.length === 2 ? 'tab matches' : 'tabs match'} "${part}"` : ''}${wasSelected && !state.page ? '; no tab is selected now' : ''}`);
|
|
212
218
|
if (recording) out.log(await endedByTabClose(recording));
|
|
213
219
|
return listTabs();
|
|
214
220
|
}
|
package/lib/watch.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
//
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
}
|
|
588
|
-
|
|
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
|
-
|
|
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
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 `
|
|
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.
|