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 +5 -1
- package/lib/help.js +16 -9
- package/lib/inspect.js +5 -2
- package/lib/record.js +14 -3
- package/lib/send.js +3 -2
- package/lib/watch.js +123 -13
- package/package.json +1 -1
- package/skill/SKILL.md +6 -2
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
|
|
354
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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/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
|
|
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
|
-
|
|
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
|
-
//
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
}
|
|
588
|
-
|
|
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
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.
|