pw-repl 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +13 -35
- package/README.md +9 -3
- package/lib/cli-names.js +4 -0
- package/lib/commands.js +4 -0
- package/lib/elements.js +104 -79
- package/lib/help.js +74 -68
- package/lib/inspect.js +14 -2
- package/lib/record.js +2 -2
- package/lib/runner.js +5 -0
- package/lib/send.js +3 -2
- package/lib/syntax.js +4 -1
- package/lib/tabs.js +6 -0
- package/lib/touch.js +93 -0
- package/package.json +1 -1
- package/skill/SKILL.md +4 -3
package/AGENTS.md
CHANGED
|
@@ -17,7 +17,7 @@ If you hit a limitation or write a workaround, consider adding the capability to
|
|
|
17
17
|
`READ_ONLY` or `INSPECTION`, and one that takes fixed words in `complete` (`lib/commands.js`).
|
|
18
18
|
- The modules, one per facility, each with its commands: `lib/tabs.js` (tabs, navigating),
|
|
19
19
|
`lib/elements.js` (choosing a match; the interact and mouse commands; highlight), `lib/cursor.js` (the pointer
|
|
20
|
-
drawn over the page, which the element and mouse commands glide first), `lib/toast.js` (a line of text
|
|
20
|
+
drawn over the page, which the element and mouse commands glide first), `lib/touch.js` (touch gestures, under emulate mobile), `lib/toast.js` (a line of text
|
|
21
21
|
over the page), `lib/overlay.js` (the element the cursor and toast are drawn in, one layer each), `lib/inspect.js` (reading the
|
|
22
22
|
page, screenshots, eval, CDP, waits), `lib/watch.js`, `lib/requestlog.js` (requests, bodies, console),
|
|
23
23
|
`lib/routes.js`, `lib/network.js`, `lib/emulation.js` (and viewport), `lib/capture.js`, `lib/profile.js` (performance traces;
|
|
@@ -56,48 +56,24 @@ If you hit a limitation or write a workaround, consider adding the capability to
|
|
|
56
56
|
after an intended change to it, save it again (the command is in `test/units.test.js`). The common
|
|
57
57
|
tasks and modes on help's front page (`TASKS` and `MODES`) are the README's too: change both, as a
|
|
58
58
|
unit test compares them.
|
|
59
|
-
- Time each full run, and put the result in the body of the commit it tests (`npm test: 151 tests, 79s`)
|
|
60
|
-
Compare with the last commit that has one (`git log
|
|
61
|
-
|
|
59
|
+
- Time each full run, and put the result in the body of the commit it tests (`npm test: 151 tests, 79s`),
|
|
60
|
+
with the load average (`uptime`) when it is high. Compare with the last commit that has one (`git log
|
|
61
|
+
--grep='npm test:'`): a run that grew by more than a few seconds, at a like load (a busy machine slows
|
|
62
|
+
every test), means a slow test crept in, usually one waiting out a real timeout (5s for a click).
|
|
62
63
|
Find it (each test prints its time) and make it faster, or say why it can't be, before committing.
|
|
63
64
|
- Don't race the clock in a test: a page that changes on a timer, or a command timed against a budget,
|
|
64
65
|
fails on a busy machine. Have the page change when the test asks (eval), or measure the thing itself
|
|
65
66
|
(frames drawn, not clicks timed). A click waits for its element to hold still for two frames.
|
|
66
67
|
- For anything the tests can't reach, exercise the change in a running REPL of your own: on a socket of
|
|
67
68
|
your own, with `--launch` unless the change needs the user's browser, and then in a tab of your own.
|
|
69
|
+
One started before a change to `lib/` runs the old code: restart it after a change. Stop a process by
|
|
70
|
+
its pid, after checking what it is; a broad `pkill -f` pattern can match your own shell, or a REPL
|
|
71
|
+
someone else is using.
|
|
68
72
|
- `skill/SKILL.md` is how agents learn to use the REPL: keep it in step with a change to how it is
|
|
69
73
|
used, and leave its Custom rules section empty. Keep its `<!-- pw-repl skill stamp -->` line: `pw-repl
|
|
70
74
|
skill` replaces it with the version and a hash of the skill's text, which `where` reports, so agents
|
|
71
75
|
can tell a saved copy is out of date.
|
|
72
76
|
|
|
73
|
-
## Waves
|
|
74
|
-
|
|
75
|
-
Work goes in waves, and each one ends with a release that is ready to push.
|
|
76
|
-
|
|
77
|
-
1. Build what was asked, with tests, and try it in a running REPL. For anything the user will look at
|
|
78
|
-
(a cursor, an overlay, a video), show them a demo while it is still a prototype (a recording or
|
|
79
|
-
screenshots they can open) and get their go-ahead on how it looks before writing tests or
|
|
80
|
-
building on it.
|
|
81
|
-
2. Try it on a fresh agent that has not seen the code or the change (a guinea pig), on something real: a
|
|
82
|
-
small local page whose planted bugs need the change to find.
|
|
83
|
-
- Brief it with goals only: what to find out or do in the browser, never the commands.
|
|
84
|
-
- First it reads back its plan, learned from `pw-repl skill` and help alone: the commands, where it
|
|
85
|
-
found them, and what is unclear. It runs nothing yet.
|
|
86
|
-
- A wrong or unsure plan means the docs or the brief were unclear: find out which, fix that, then
|
|
87
|
-
ask again.
|
|
88
|
-
- Then it carries the plan out, in a browser and on a socket of its own, and reports as it goes:
|
|
89
|
-
above all, where the tool did something other than what the docs led it to expect.
|
|
90
|
-
- Once it has confirmed the fixes work, it reviews them as a second pair of eyes: anything important
|
|
91
|
-
they miss or get wrong, not nitpicks.
|
|
92
|
-
3. Triage what it reports. Fix what matters, and what is small and useful; don't put off something
|
|
93
|
-
useful for later. Leave trivia alone, and say why.
|
|
94
|
-
4. Repeat 2 and 3, with a fresh agent each time, until a pass comes back clean: no real bugs, and
|
|
95
|
-
nothing unclear in the docs or the brief that changed the plan. A pass after fixes tries the fixes,
|
|
96
|
-
and something not tried yet.
|
|
97
|
-
5. Only then cut the release (Releasing, steps 1-3), and say it is ready. The maintainer pushes and
|
|
98
|
-
publishes it, and the next wave starts from what they find. A release cut too early and not pushed
|
|
99
|
-
yet is undone (its commit and tag) and cut again after the fixes.
|
|
100
|
-
|
|
101
77
|
## Releasing
|
|
102
78
|
|
|
103
79
|
Commits follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `docs:`,
|
|
@@ -106,9 +82,9 @@ Commits follow [Conventional Commits](https://www.conventionalcommits.org/): `fe
|
|
|
106
82
|
|
|
107
83
|
1. `npm test` passes and the working tree is clean.
|
|
108
84
|
2. `npm outdated` and `npm audit` show nothing that needs doing first. Dependencies are always the newest
|
|
109
|
-
stable release, never pinned back
|
|
110
|
-
|
|
111
|
-
|
|
85
|
+
stable release, never pinned back. When the newest breaks a test, fix it in our code; only a
|
|
86
|
+
regression of the dependency's own, with the fix already in its next release, keeps the version before
|
|
87
|
+
it, until that release ships. playwright-core stays at 1.63 for that reason: on 1.64.1 a click
|
|
112
88
|
on a link to a 204 hangs at "waiting for scheduled navigations to finish" until its timeout, in plain
|
|
113
89
|
Playwright too (chromium.launch, a page with a link to a 204, page.click on it); 1.65's alphas fix it.
|
|
114
90
|
The test "waits for the page to load, counting a navigation that just began" shows it: move up once it
|
|
@@ -118,4 +94,6 @@ Commits follow [Conventional Commits](https://www.conventionalcommits.org/): `fe
|
|
|
118
94
|
4. `git push --follow-tags` pushes the commits and the tag.
|
|
119
95
|
5. `npm publish` publishes it; `prepublishOnly` runs the tests again first.
|
|
120
96
|
|
|
97
|
+
A release cut too early and not pushed yet is undone (its commit and tag) and cut again after the fixes.
|
|
98
|
+
|
|
121
99
|
Pushing and publishing need the maintainer's GitHub and npm credentials.
|
package/README.md
CHANGED
|
@@ -140,11 +140,12 @@ example the Docker bridge gateway, `ip route | awk '/default/ {print $3}'`).
|
|
|
140
140
|
| see where I am | `tab`, `info` |
|
|
141
141
|
| see what is on the page | `snapshot`, `screenshot` |
|
|
142
142
|
| do something on it | `click`, `fill`, `type`, `press`, `drag` |
|
|
143
|
-
| click what has no selector (a canvas) | `
|
|
143
|
+
| click what has no selector (a canvas) | `mouse click <x> <y>`, `mouse dblclick <x> <y>` |
|
|
144
144
|
| run some JavaScript in the page | `eval document.title`; `eval --file=check.js` for a long one |
|
|
145
145
|
| wait for a page or element | `wait load`, `wait <selector> [--gone]`, `wait text <text>` |
|
|
146
146
|
| choose a file in a file input | `upload <selector> <file>` |
|
|
147
147
|
| see it as a phone, or in dark mode | `emulate mobile`, `emulate dark` |
|
|
148
|
+
| tap, swipe or pinch as a finger | `touch tap <x> <y>`, `touch pinch <x> <y> <scale>` |
|
|
148
149
|
| see what the page requested | `requests`, then `body <#>` for what one got back |
|
|
149
150
|
| see console messages and errors | `console` |
|
|
150
151
|
| show an agent what I do | `watch on`, click around, then `watch` |
|
|
@@ -199,7 +200,10 @@ pw-repl where # which REPL send would reach
|
|
|
199
200
|
|
|
200
201
|
`pw-repl send` sends one command and prints its result, through the server when one is running, and
|
|
201
202
|
through the `pw-repl` tmux session otherwise. Chain several in the shell: `&&` stops at the first that
|
|
202
|
-
fails or is not confirmed (exit 1 or 2), and `;` carries on.
|
|
203
|
+
fails or is not confirmed (exit 1 or 2), and `;` carries on. `-t` is how long it waits for the answer
|
|
204
|
+
(default 20s). Times, there and in every command, are in milliseconds, as in Playwright, or in seconds
|
|
205
|
+
with an `s`: `-t 90000` or `-t 90s`. A send that gives up cancels its command: one still queued never
|
|
206
|
+
runs, and one running is no longer waited for.
|
|
203
207
|
|
|
204
208
|
Several agents can share one REPL. `pw-repl send -c <name>` (or `PW_CLIENT=<name>`) sends as a client
|
|
205
209
|
with a selected tab of its own, shown in the REPL as `[server:<name>]`. `modes` says which client turned
|
|
@@ -227,7 +231,8 @@ The body may also have `"client": "<name>"`, and `"vars": {"PW_NAME": "<value>"}
|
|
|
227
231
|
"error", "output": "..."}`, plus `"unconfirmed": true` when the command may or may not have done what it
|
|
228
232
|
was sent to do (it timed out, or the REPL quit while it ran and it was not read-only). While the REPL
|
|
229
233
|
starts, `/run` answers 503 with `"starting": true`: retry. `GET /health` says `"status": "starting"` until
|
|
230
|
-
it serves, then `"ok"`.
|
|
234
|
+
it serves, then `"ok"`. A client that closes its connection before the answer cancels the command, as
|
|
235
|
+
`send` does when it gives up. `pw-repl serve <port>` serves TCP on 127.0.0.1; other addresses are refused.
|
|
231
236
|
|
|
232
237
|
## How it differs from playwright-cli
|
|
233
238
|
|
|
@@ -237,6 +242,7 @@ playwright-cli's command names and most of their options work here too (`help pl
|
|
|
237
242
|
them). Where pw-repl differs, it is mostly because someone else may be using the browser:
|
|
238
243
|
|
|
239
244
|
- `open` and `close` open a tab and turn off your modes; they never start, close or wipe a browser.
|
|
245
|
+
`close` also lists the files you wrote that are still there.
|
|
240
246
|
- Routes, network and emulation apply to one tab, not the whole browser.
|
|
241
247
|
- File pickers are not caught, since they may be the user's: `upload` names the input instead.
|
|
242
248
|
- There is no snapshot after every command, since the user reads the REPL too; `snapshot` shows one.
|
package/lib/cli-names.js
CHANGED
|
@@ -17,6 +17,10 @@ const NAMES = {
|
|
|
17
17
|
// It ends playwright-cli's own browser; this one is shared, so tabs stay open.
|
|
18
18
|
close: [() => 'close', 'modes off --mine; tabs stay open; lists the files you wrote'],
|
|
19
19
|
'go-back': [() => 'back', 'back'],
|
|
20
|
+
mousemove: [a => `mouse move ${a}`, 'mouse move <x> <y>'],
|
|
21
|
+
mousedown: [a => `mouse down ${a}`.trim(), 'mouse down [button]'],
|
|
22
|
+
mouseup: [a => `mouse up ${a}`.trim(), 'mouse up [button]'],
|
|
23
|
+
mousewheel: [a => `mouse wheel ${a}`, 'mouse wheel <dx> <dy>'],
|
|
20
24
|
'go-forward': [() => 'forward', 'forward'],
|
|
21
25
|
find: [a => `snapshot --grep ${a}`, 'snapshot --grep <text>'],
|
|
22
26
|
'dialog-accept': [a => `dialog accept ${a}`.trim(), 'dialog accept [text]'],
|
package/lib/commands.js
CHANGED
|
@@ -8,6 +8,7 @@ const { tabState } = require('./tabstate');
|
|
|
8
8
|
const { commands: dialogsCommands, ensureDialogHandler } = require('./dialogs');
|
|
9
9
|
const { commands: elementsCommands } = require('./elements');
|
|
10
10
|
const { commands: cursorCommands } = require('./cursor');
|
|
11
|
+
const { commands: touchCommands, GESTURES } = require('./touch');
|
|
11
12
|
const { commands: toastCommands } = require('./toast');
|
|
12
13
|
const { commands: requestlogCommands, ensureRecentLog } = require('./requestlog');
|
|
13
14
|
const { commands: routesCommands, routesFor } = require('./routes');
|
|
@@ -26,6 +27,7 @@ const commands = {
|
|
|
26
27
|
...tabsCommands,
|
|
27
28
|
...elementsCommands,
|
|
28
29
|
...cursorCommands,
|
|
30
|
+
...touchCommands,
|
|
29
31
|
...toastCommands,
|
|
30
32
|
...inspectCommands,
|
|
31
33
|
...watchCommands,
|
|
@@ -114,6 +116,8 @@ function complete(line) {
|
|
|
114
116
|
if (words.length === 2) {
|
|
115
117
|
if (command === 'help') return match(['--all', ...Object.keys(HELP.TOPICS), ...Object.keys(HELP.COMMANDS).sort()]);
|
|
116
118
|
if (command === 'tab') return match(['new', 'close']);
|
|
119
|
+
if (command === 'touch') return match(GESTURES);
|
|
120
|
+
if (command === 'mouse') return match(['move', 'click', 'dblclick', 'down', 'up', 'wheel']);
|
|
117
121
|
if (command === 'network') return match(['on', 'off', 'slow']);
|
|
118
122
|
if (command === 'emulate') return match([...EMULATE_KINDS, 'off']);
|
|
119
123
|
if (command === 'wait') return match(['text', 'request', 'load']);
|
package/lib/elements.js
CHANGED
|
@@ -39,7 +39,7 @@ const MODIFIERS = ['Alt', 'Control', 'ControlOrMeta', 'Meta', 'Shift'];
|
|
|
39
39
|
// click takes --click-count too; dblclick is click's count of 2.
|
|
40
40
|
function clickArgs(args, usage, counts = false) {
|
|
41
41
|
// Found outside quotes only, so a quoted selector keeps whatever is in it.
|
|
42
|
-
const { found, rest: text } = takeOptions((args || '').trim(), { modifiers: 'value', button: 'value', 'click-count': 'value' });
|
|
42
|
+
const { found, rest: text } = takeOptions((args || '').trim(), { modifiers: 'value', button: 'value', 'click-count': 'value', force: 'flag' });
|
|
43
43
|
if (!counts && found.some(o => o.name === 'click-count')) throw new Error('dblclick is a click count of 2; for another count, click --click-count=<n>');
|
|
44
44
|
const clickCount = clickCountOf(found, usage);
|
|
45
45
|
const modifiers = found.filter(o => o.name === 'modifiers').flatMap(o => (o.value || '').split(',').filter(Boolean).map(keyName));
|
|
@@ -49,12 +49,14 @@ function clickArgs(args, usage, counts = false) {
|
|
|
49
49
|
const positional = parsed && /^(?:left|right|middle)$/.test(parsed.rest || '') && (REF.test(parsed.word) || /^["']/.test(text)) ? parsed.rest : null;
|
|
50
50
|
const button = chooseButton(found, positional, usage);
|
|
51
51
|
const selector = positional ? parsed.selector : soleSelector(text, usage);
|
|
52
|
-
const
|
|
52
|
+
const force = found.some(o => o.name === 'force');
|
|
53
|
+
const options = { ...(button ? { button } : {}), ...(modifiers.length ? { modifiers } : {}), ...(clickCount > 1 ? { clickCount } : {}), ...(force ? { force } : {}) };
|
|
53
54
|
// The selector as written, then the kind of click, so an option never reads as part of the selector.
|
|
54
55
|
const kind = [
|
|
55
56
|
...(button && button !== 'left' ? [`${button} button`] : []),
|
|
56
57
|
...(clickCount > 1 ? [`${clickCount} times`] : []),
|
|
57
58
|
...(modifiers.length ? [`with ${modifiers.join('+')}`] : []),
|
|
59
|
+
...(force ? ['forced'] : []),
|
|
58
60
|
];
|
|
59
61
|
return { selector, shown: [positional ? parsed.word : unquote(text), ...kind].join(', '), options };
|
|
60
62
|
}
|
|
@@ -104,8 +106,9 @@ async function onElement(selector, action, budget = 5000) {
|
|
|
104
106
|
console.error(`Playwright's call log:\n${message}`);
|
|
105
107
|
// Found, so the ref is not stale: no hint.
|
|
106
108
|
const summary = new Error(`Timed out after ${budget / 1000}s on ${ref || selector}: ${lastBlocker(message)} (its call log is in the REPL's pane or log)`);
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
+
// Unknown only once input was sent, in any retry. Playwright's checks (visible, enabled, stable, and
|
|
110
|
+
// an element covering the point) come before it logs "performing <x> action" and sends it.
|
|
111
|
+
summary.uncertain = /performing \w+ action/.test(message);
|
|
109
112
|
throw summary;
|
|
110
113
|
}
|
|
111
114
|
}
|
|
@@ -227,6 +230,18 @@ function mouseButton(args, name) {
|
|
|
227
230
|
// A page that renders the chosen element again (as React lists do) replaces it before it is
|
|
228
231
|
// acted on; it is chosen again then, as Playwright finds a locator's element again.
|
|
229
232
|
async function pointerAction(selector, act, options = {}, clicks = 0) {
|
|
233
|
+
try {
|
|
234
|
+
return await choosingPointerAction(selector, act, options, clicks);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
// Playwright counts aria-disabled as disabled, though a page may keep such an element clickable.
|
|
237
|
+
if (clicks && !options.force && /: element is not enabled \(/.test(error.message)) {
|
|
238
|
+
error.message += '\nIt may be aria-disabled, which counts as disabled; --force clicks it anyway (help click).';
|
|
239
|
+
}
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function choosingPointerAction(selector, act, options, clicks) {
|
|
230
245
|
const TIMEOUT = 5000;
|
|
231
246
|
const deadline = Date.now() + TIMEOUT;
|
|
232
247
|
const left = () => Math.max(1000, deadline - Date.now());
|
|
@@ -424,15 +439,93 @@ function dateInputHolds(kind, value, what, selector) {
|
|
|
424
439
|
return `, a ${kind} input, which now holds ${value}`;
|
|
425
440
|
}
|
|
426
441
|
|
|
442
|
+
// mouse's actions.
|
|
443
|
+
const MOUSE = {
|
|
444
|
+
// At a point, in CSS pixels from the top left of the tab's viewport, as playwright-cli's are.
|
|
445
|
+
async move(args) {
|
|
446
|
+
const [x, y, extra] = numbers(args, 'mouse move <x> <y>');
|
|
447
|
+
if (y === undefined || extra !== undefined) throw new Error('Usage: mouse move <x> <y>, in CSS pixels from the top left of the viewport');
|
|
448
|
+
stepTarget(state.page, null, { x, y });
|
|
449
|
+
await glideTo(state.page, x, y);
|
|
450
|
+
await state.page.mouse.move(x, y);
|
|
451
|
+
tabState(state.page).mouseAt = { x, y };
|
|
452
|
+
out.log(`Moved the mouse to ${x}, ${y}`);
|
|
453
|
+
},
|
|
454
|
+
|
|
455
|
+
// A click at a point, as Playwright's mouse.click: for what has no selector, such as a marker drawn on a canvas.
|
|
456
|
+
async click(line) {
|
|
457
|
+
const usage = 'mouse click <x> <y> [left|right|middle] [--button=<button>] [--click-count=<n>] [--delay=<ms>]';
|
|
458
|
+
const { words, found, button } = mouseArgs(line, usage, { 'click-count': 'value', delay: 'value' });
|
|
459
|
+
const clickCount = clickCountOf(found, usage);
|
|
460
|
+
const delay = found.find(o => o.name === 'delay');
|
|
461
|
+
if (delay && !/^\d+$/.test(delay.value || '')) throw new Error(`Usage: ${usage}, the ms between press and release, e.g. mouse click 400 300 --delay=500`);
|
|
462
|
+
const held = delay ? Number(delay.value) : 0;
|
|
463
|
+
const [x, y, extra] = numbers(words.join(' '), usage);
|
|
464
|
+
if (y === undefined || extra !== undefined) throw new Error(`Usage: ${usage}, in CSS pixels from the top left of the viewport`);
|
|
465
|
+
stepTarget(state.page, null, { x, y });
|
|
466
|
+
await glideTo(state.page, x, y);
|
|
467
|
+
await state.page.mouse.move(x, y);
|
|
468
|
+
tabState(state.page).mouseAt = { x, y };
|
|
469
|
+
// Each press counts up, as Playwright's mouse.click does it. The ring shows at the first press, as
|
|
470
|
+
// mouse down's does, while a --delay holds the button.
|
|
471
|
+
for (let count = 1; count <= clickCount; count += 1) {
|
|
472
|
+
await state.page.mouse.down({ button, clickCount: count });
|
|
473
|
+
if (count === 1) await showClick(state.page, clickCount);
|
|
474
|
+
if (held) await new Promise(resolve => setTimeout(resolve, held));
|
|
475
|
+
await state.page.mouse.up({ button, clickCount: count });
|
|
476
|
+
}
|
|
477
|
+
out.log(`Clicked the ${button} button at ${x}, ${y}${clickCount > 1 ? ` ${clickCount} times` : ''}`);
|
|
478
|
+
},
|
|
479
|
+
|
|
480
|
+
// Two mouse clicks are two single clicks (each event's detail is 1), so no dblclick fires: this is one.
|
|
481
|
+
async dblclick(line) {
|
|
482
|
+
const usage = 'mouse dblclick <x> <y> [left|right|middle] [--button=<button>]';
|
|
483
|
+
const { words, button } = mouseArgs(line, usage);
|
|
484
|
+
const [x, y, extra] = numbers(words.join(' '), usage);
|
|
485
|
+
if (y === undefined || extra !== undefined) throw new Error(`Usage: ${usage}, in CSS pixels from the top left of the viewport`);
|
|
486
|
+
stepTarget(state.page, null, { x, y });
|
|
487
|
+
await glideTo(state.page, x, y);
|
|
488
|
+
await state.page.mouse.dblclick(x, y, { button });
|
|
489
|
+
tabState(state.page).mouseAt = { x, y };
|
|
490
|
+
await showClick(state.page, 2);
|
|
491
|
+
out.log(`Double-clicked the ${button} button at ${x}, ${y}`);
|
|
492
|
+
},
|
|
493
|
+
|
|
494
|
+
async down(args) {
|
|
495
|
+
const button = mouseButton(args, 'mouse down');
|
|
496
|
+
await glideToMouse();
|
|
497
|
+
await state.page.mouse.down({ button });
|
|
498
|
+
await showClick(state.page);
|
|
499
|
+
out.log(`Pressed the ${button} button`);
|
|
500
|
+
},
|
|
501
|
+
|
|
502
|
+
async up(args) {
|
|
503
|
+
const button = mouseButton(args, 'mouse up');
|
|
504
|
+
await glideToMouse();
|
|
505
|
+
await state.page.mouse.up({ button });
|
|
506
|
+
out.log(`Released the ${button} button`);
|
|
507
|
+
},
|
|
508
|
+
|
|
509
|
+
// Wheel events where the mouse is (mouse move puts it there): a map zooms around that point.
|
|
510
|
+
async wheel(args) {
|
|
511
|
+
const [dx, dy, extra] = numbers(args, 'mouse wheel <dx> <dy>');
|
|
512
|
+
if (dy === undefined || extra !== undefined) throw new Error('Usage: mouse wheel <dx> <dy>, e.g. mouse wheel 0 300 scrolls down (negative dy up)');
|
|
513
|
+
await glideToMouse();
|
|
514
|
+
if (recordingOf(state.page)) await smoothWheel(dx, dy);
|
|
515
|
+
else await state.page.mouse.wheel(dx, dy);
|
|
516
|
+
out.log(`Turned the wheel by ${dx}, ${dy}`);
|
|
517
|
+
},
|
|
518
|
+
};
|
|
519
|
+
|
|
427
520
|
const commands = {
|
|
428
521
|
async click(args) {
|
|
429
|
-
const { selector, shown, options } = clickArgs(args, 'click <selector> [left|right|middle] [--button=<button>] [--click-count=<n>] [--modifiers=<key>[,<key>]]', true);
|
|
522
|
+
const { selector, shown, options } = clickArgs(args, 'click <selector> [left|right|middle] [--button=<button>] [--click-count=<n>] [--modifiers=<key>[,<key>]] [--force]', true);
|
|
430
523
|
const which = await pointerAction(selector, (on, sel, o) => (sel ? on.click(sel, o) : on.click(o)), options, options.clickCount || 1);
|
|
431
524
|
out.log(`Clicked: ${shown}${which}`);
|
|
432
525
|
},
|
|
433
526
|
|
|
434
527
|
async dblclick(args) {
|
|
435
|
-
const { selector, shown, options } = clickArgs(args, 'dblclick <selector> [left|right|middle] [--button=<button>] [--modifiers=<key>[,<key>]]');
|
|
528
|
+
const { selector, shown, options } = clickArgs(args, 'dblclick <selector> [left|right|middle] [--button=<button>] [--modifiers=<key>[,<key>]] [--force]');
|
|
436
529
|
const which = await pointerAction(selector, (on, sel, o) => (sel ? on.dblclick(sel, o) : on.dblclick(o)), options, 2);
|
|
437
530
|
out.log(`Double-clicked: ${shown}${which}`);
|
|
438
531
|
},
|
|
@@ -530,79 +623,11 @@ const commands = {
|
|
|
530
623
|
pauseToShow(state.page);
|
|
531
624
|
},
|
|
532
625
|
|
|
533
|
-
//
|
|
534
|
-
async
|
|
535
|
-
const [
|
|
536
|
-
if (
|
|
537
|
-
|
|
538
|
-
await glideTo(state.page, x, y);
|
|
539
|
-
await state.page.mouse.move(x, y);
|
|
540
|
-
tabState(state.page).mouseAt = { x, y };
|
|
541
|
-
out.log(`Moved the mouse to ${x}, ${y}`);
|
|
542
|
-
},
|
|
543
|
-
|
|
544
|
-
// A click at a point, as Playwright's mouse.click: for what has no selector, such as a marker drawn on a canvas.
|
|
545
|
-
async mouseclick(line) {
|
|
546
|
-
const usage = 'mouseclick <x> <y> [left|right|middle] [--button=<button>] [--click-count=<n>] [--delay=<ms>]';
|
|
547
|
-
const { words, found, button } = mouseArgs(line, usage, { 'click-count': 'value', delay: 'value' });
|
|
548
|
-
const clickCount = clickCountOf(found, usage);
|
|
549
|
-
const delay = found.find(o => o.name === 'delay');
|
|
550
|
-
if (delay && !/^\d+$/.test(delay.value || '')) throw new Error(`Usage: ${usage}, the ms between press and release, e.g. mouseclick 400 300 --delay=500`);
|
|
551
|
-
const held = delay ? Number(delay.value) : 0;
|
|
552
|
-
const [x, y, extra] = numbers(words.join(' '), usage);
|
|
553
|
-
if (y === undefined || extra !== undefined) throw new Error(`Usage: ${usage}, in CSS pixels from the top left of the viewport`);
|
|
554
|
-
stepTarget(state.page, null, { x, y });
|
|
555
|
-
await glideTo(state.page, x, y);
|
|
556
|
-
await state.page.mouse.move(x, y);
|
|
557
|
-
tabState(state.page).mouseAt = { x, y };
|
|
558
|
-
// Each press counts up, as Playwright's mouse.click does it. The ring shows at the first press, as
|
|
559
|
-
// mousedown's does, while a --delay holds the button.
|
|
560
|
-
for (let count = 1; count <= clickCount; count += 1) {
|
|
561
|
-
await state.page.mouse.down({ button, clickCount: count });
|
|
562
|
-
if (count === 1) await showClick(state.page, clickCount);
|
|
563
|
-
if (held) await new Promise(resolve => setTimeout(resolve, held));
|
|
564
|
-
await state.page.mouse.up({ button, clickCount: count });
|
|
565
|
-
}
|
|
566
|
-
out.log(`Clicked the ${button} button at ${x}, ${y}${clickCount > 1 ? ` ${clickCount} times` : ''}`);
|
|
567
|
-
},
|
|
568
|
-
|
|
569
|
-
// Two mouseclicks are two single clicks (each event's detail is 1), so no dblclick fires: this is one.
|
|
570
|
-
async mousedblclick(line) {
|
|
571
|
-
const usage = 'mousedblclick <x> <y> [left|right|middle] [--button=<button>]';
|
|
572
|
-
const { words, button } = mouseArgs(line, usage);
|
|
573
|
-
const [x, y, extra] = numbers(words.join(' '), usage);
|
|
574
|
-
if (y === undefined || extra !== undefined) throw new Error(`Usage: ${usage}, in CSS pixels from the top left of the viewport`);
|
|
575
|
-
stepTarget(state.page, null, { x, y });
|
|
576
|
-
await glideTo(state.page, x, y);
|
|
577
|
-
await state.page.mouse.dblclick(x, y, { button });
|
|
578
|
-
tabState(state.page).mouseAt = { x, y };
|
|
579
|
-
await showClick(state.page, 2);
|
|
580
|
-
out.log(`Double-clicked the ${button} button at ${x}, ${y}`);
|
|
581
|
-
},
|
|
582
|
-
|
|
583
|
-
async mousedown(args) {
|
|
584
|
-
const button = mouseButton(args, 'mousedown');
|
|
585
|
-
await glideToMouse();
|
|
586
|
-
await state.page.mouse.down({ button });
|
|
587
|
-
await showClick(state.page);
|
|
588
|
-
out.log(`Pressed the ${button} button`);
|
|
589
|
-
},
|
|
590
|
-
|
|
591
|
-
async mouseup(args) {
|
|
592
|
-
const button = mouseButton(args, 'mouseup');
|
|
593
|
-
await glideToMouse();
|
|
594
|
-
await state.page.mouse.up({ button });
|
|
595
|
-
out.log(`Released the ${button} button`);
|
|
596
|
-
},
|
|
597
|
-
|
|
598
|
-
// Wheel events where the mouse is (mousemove puts it there): a map zooms around that point.
|
|
599
|
-
async mousewheel(args) {
|
|
600
|
-
const [dx, dy, extra] = numbers(args, 'mousewheel <dx> <dy>');
|
|
601
|
-
if (dy === undefined || extra !== undefined) throw new Error('Usage: mousewheel <dx> <dy>, e.g. mousewheel 0 300 scrolls down (negative dy up)');
|
|
602
|
-
await glideToMouse();
|
|
603
|
-
if (recordingOf(state.page)) await smoothWheel(dx, dy);
|
|
604
|
-
else await state.page.mouse.wheel(dx, dy);
|
|
605
|
-
out.log(`Turned the wheel by ${dx}, ${dy}`);
|
|
626
|
+
// mouse <action>, as Playwright's page.mouse: at a point, or where the mouse is.
|
|
627
|
+
async mouse(line) {
|
|
628
|
+
const [action, ...rest] = (line || '').trim().split(/\s+/);
|
|
629
|
+
if (!Object.hasOwn(MOUSE, action)) throw new Error(`Usage: mouse ${Object.keys(MOUSE).join('|')} ...; help mouse says what each takes`);
|
|
630
|
+
await MOUSE[action](rest.join(' '));
|
|
606
631
|
},
|
|
607
632
|
|
|
608
633
|
async fill(line) {
|
package/lib/help.js
CHANGED
|
@@ -27,11 +27,12 @@ const TASKS = [
|
|
|
27
27
|
['see where I am', 'tab, info'],
|
|
28
28
|
['see what is on the page', 'snapshot, screenshot'],
|
|
29
29
|
['do something on it', 'click, fill, type, press, drag'],
|
|
30
|
-
['click what has no selector (a canvas)', '
|
|
30
|
+
['click what has no selector (a canvas)', 'mouse click <x> <y>, mouse dblclick <x> <y>'],
|
|
31
31
|
['run some JavaScript in the page', 'eval document.title; eval --file=check.js for a long one'],
|
|
32
32
|
['wait for a page or element', 'wait load, wait <selector> [--gone], wait text <text>'],
|
|
33
33
|
['choose a file in a file input', 'upload <selector> <file>'],
|
|
34
34
|
['see it as a phone, or in dark mode', 'emulate mobile, emulate dark'],
|
|
35
|
+
['tap, swipe or pinch as a finger', 'touch tap <x> <y>, touch pinch <x> <y> <scale>'],
|
|
35
36
|
['see what the page requested', 'requests, then body <#> for what one got back'],
|
|
36
37
|
['see console messages and errors', 'console'],
|
|
37
38
|
['show an agent what I do', 'watch on, click around, then watch'],
|
|
@@ -85,9 +86,9 @@ tab on its own lists them all.`,
|
|
|
85
86
|
interact: {
|
|
86
87
|
intro: `Selectors are Playwright selectors (CSS, text=..., role=...) or snapshot refs such as e5.
|
|
87
88
|
Commands use the first match (click, dblclick and hover the first they can use: help click); see help
|
|
88
|
-
fill for selectors with spaces.
|
|
89
|
+
fill for selectors with spaces. mouse and touch act at a point instead. An action
|
|
89
90
|
scrolls its element into view first, as Playwright does; profile counts that scroll in the action's step.`,
|
|
90
|
-
commands: ['click', 'dblclick', 'hover', 'drag', 'fill', 'type', 'press', 'select', 'check', 'uncheck', 'upload', '
|
|
91
|
+
commands: ['click', 'dblclick', 'hover', 'drag', 'fill', 'type', 'press', 'select', 'check', 'uncheck', 'upload', 'mouse', 'touch'],
|
|
91
92
|
},
|
|
92
93
|
inspect: {
|
|
93
94
|
intro: `Output is capped; --all right after the command, or at the end, shows everything (text --all body).`,
|
|
@@ -216,6 +217,7 @@ afresh.`,
|
|
|
216
217
|
['--button=<button>', 'the button, after any selector: click #menu --button=right. Over send, the shell takes the quotes, so pw-repl send click \'#menu\' right clicks a selector "#menu right": use --button, or quote it twice (\'"#menu"\').'],
|
|
217
218
|
['--click-count=<n>', 'clicks this many times at once, as Playwright\'s clickCount: 2 is a double-click (as dblclick), 3 a triple-click, which selects a line of text or all of a field\'s. 1 to 3. Default: 1.'],
|
|
218
219
|
['--modifiers=<keys>', 'keys held down meanwhile, comma-separated: Alt, Control, ControlOrMeta, Meta, Shift.'],
|
|
220
|
+
['--force', 'skips Playwright\'s actionability checks (enabled, stable, not covered), as Playwright\'s force. For an element marked aria-disabled, which Playwright counts as disabled, that a page keeps clickable (a greyed menu item that says why). Several matches are still chosen from as below; a covered ref or single match is clicked where it is, so what covers it gets the click.'],
|
|
219
221
|
],
|
|
220
222
|
detail: `Several matches: hidden ones and ones covered by something else (a dialog's overlay) are passed over,
|
|
221
223
|
and it says which it used: Clicked: text=Buy (match 3 of 4; ...), or names the first when it was the
|
|
@@ -223,8 +225,10 @@ one: (the first of 2 matches: <h1> Buy), as it may not be the one meant. If none
|
|
|
223
225
|
says why for each and does nothing. A ref, or a single match, is clicked as it is, waiting up to 5s for
|
|
224
226
|
it to be ready.
|
|
225
227
|
|
|
226
|
-
Timed out
|
|
227
|
-
|
|
228
|
+
Timed out, it says what stopped it, and Playwright's full call log goes to the REPL's pane or log. Stopped by
|
|
229
|
+
one of Playwright's checks before the click is sent, it did nothing: the element is not visible, enabled
|
|
230
|
+
or stable, or <div class="overlay"> intercepts pointer events (something covers it). Stopped after it was
|
|
231
|
+
sent, as when the page stops answering, the outcome is unknown.`,
|
|
228
232
|
},
|
|
229
233
|
dblclick: {
|
|
230
234
|
usage: 'dblclick <selector> [button]',
|
|
@@ -241,61 +245,51 @@ events) and that the outcome is unknown; Playwright's full call log goes to the
|
|
|
241
245
|
detail: `As Playwright's dragTo: it hovers the first match of <from>, presses the left button, hovers the
|
|
242
246
|
first match of <to>, and releases, waiting up to 5s in all for each to be ready. An HTML5 drag (a
|
|
243
247
|
draggable element) gets dragstart, dragover and drop; any other gets pointer and mouse events. A drag
|
|
244
|
-
through points of its own:
|
|
248
|
+
through points of its own: mouse move, mouse down, mouse move, mouse up.`,
|
|
245
249
|
},
|
|
246
|
-
|
|
247
|
-
usage: '
|
|
248
|
-
summary: '
|
|
250
|
+
touch: {
|
|
251
|
+
usage: 'touch <gesture> <x> <y> ...',
|
|
252
|
+
summary: 'tap, swipe or pinch at a point, as a finger',
|
|
249
253
|
args: [
|
|
250
|
-
['<x> <y>', '
|
|
254
|
+
['tap <x> <y>', 'a tap: the page gets pointer and touch events, then click.'],
|
|
255
|
+
['doubletap <x> <y>', 'two taps: a dblclick after them.'],
|
|
256
|
+
['longpress <x> <y> [time]', 'held down this long, then lifted. Default: 800ms. In headless Chromium it is still a tap, with click and no contextmenu; a page\'s own timer on pointerdown sees it held.'],
|
|
257
|
+
['swipe <x> <y> <dx> <dy>', 'one finger down at x,y, moved by dx,dy in small steps about a frame apart, then lifted.'],
|
|
258
|
+
['pinch <x> <y> <scale>', 'two fingers side by side about x,y, 100px apart, moved apart until they are scale times that far apart, then lifted: 2 zooms in, under 1 (0.5) pinches in.'],
|
|
251
259
|
],
|
|
252
|
-
detail:
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
260
|
+
detail: `<x> <y> are CSS pixels from the top left of the viewport, as for mouse commands. It needs a touch screen:
|
|
261
|
+
emulate mobile, then reload, so the page sees one; without it, touch refuses.
|
|
262
|
+
|
|
263
|
+
Chromium takes a tap sent soon after a swipe or pinch as part of it, with no click: up to a few seconds
|
|
264
|
+
after (sleep 3s). A page that pans or zooms itself on touch (no touch-action: none) ends a swipe with
|
|
265
|
+
pointercancel, not pointerup, as it would for a finger.
|
|
266
|
+
|
|
267
|
+
For what these do not make, cdp Input.dispatchTouchEvent, one event at a time (help cdp). Each finger has an id,
|
|
268
|
+
and touchEnd lists the fingers lifted; the others stay down. One finger lifted mid-pinch, the other moving on:
|
|
269
|
+
a touchStart with two points (ids 0 and 1), then cdp Input.dispatchTouchEvent
|
|
270
|
+
{"type":"touchEnd","touchPoints":[{"x":150,"y":300,"id":0}]}, then touchMove listing only id 1, then touchEnd
|
|
271
|
+
with no points, which lifts the rest.`,
|
|
257
272
|
},
|
|
258
|
-
|
|
259
|
-
usage: '
|
|
260
|
-
summary: 'click at a point
|
|
273
|
+
mouse: {
|
|
274
|
+
usage: 'mouse <action> ...',
|
|
275
|
+
summary: 'move, click or press at a point, or turn the wheel',
|
|
261
276
|
args: [
|
|
262
|
-
['<x> <y>', 'CSS pixels from the top left of the tab\'s viewport, as
|
|
263
|
-
['[button]', 'left, right or middle
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
['
|
|
267
|
-
['--click-count=<n>', 'as click\'s: 2 is a double-click (as mousedblclick), 3 a triple-click. 1 to 3. Default: 1.'],
|
|
268
|
-
['--delay=<ms>', 'holds the button down this long between press and release, as Playwright\'s mouse.click delay. Default: 0.'],
|
|
277
|
+
['move <x> <y>', 'moves the mouse there: CSS pixels from the top left of the tab\'s viewport (not the page), as mouse events have them.'],
|
|
278
|
+
['click <x> <y> [button]', 'clicks there: left, right or middle (Default: left), or --button=<button>; --click-count=<n> as click\'s (2 a double-click, 3 a triple-click); --delay=<ms> holds the button that long, as Playwright\'s mouse.click delay.'],
|
|
279
|
+
['dblclick <x> <y> [button]', 'mouse click --click-count=2, as Playwright\'s mouse.dblclick: two clicks and a dblclick. Two mouse clicks are two single clicks, which fire no dblclick.'],
|
|
280
|
+
['down [button] | up [button]', 'presses or releases a button where the mouse is. A drag: mouse down, mouse move to another point, mouse up.'],
|
|
281
|
+
['wheel <dx> <dy>', 'turns the wheel where the mouse is; positive dy scrolls down. A map zooms around the mouse: mouse move 400 300, then mouse wheel 0 -300 zooms in. It returns once the events are sent, not once the page has redrawn; while recording, it scrolls in eased steps over up to 1s (help record).'],
|
|
269
282
|
],
|
|
270
283
|
detail: `For what has no selector, such as a marker drawn on a canvas; anything a selector finds is better
|
|
271
|
-
clicked with click, which waits for it.
|
|
272
|
-
it. The cursor (help cursor) moves there first and shows a ring at
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
],
|
|
281
|
-
options: [
|
|
282
|
-
['--button=<button>', 'the button, as click\'s.'],
|
|
283
|
-
],
|
|
284
|
-
detail: `mouseclick --click-count=2, as Playwright's mouse.dblclick is: the page gets two clicks and a
|
|
285
|
-
dblclick. Two mouseclicks are two single clicks, which fire no dblclick. For a triple-click: mouseclick
|
|
286
|
-
<x> <y> --click-count=3.`,
|
|
287
|
-
},
|
|
288
|
-
mousedown: { usage: 'mousedown [button]', summary: 'press a mouse button at the mouse (left, right, middle)', detail: 'The button is a word or --button=<button>, as for mouseclick.' },
|
|
289
|
-
mouseup: { usage: 'mouseup [button]', summary: 'release a mouse button where the mouse is', detail: 'The button is a word or --button=<button>, as for mouseclick.' },
|
|
290
|
-
mousewheel: {
|
|
291
|
-
usage: 'mousewheel <dx> <dy>',
|
|
292
|
-
summary: 'turn the wheel where the mouse is',
|
|
293
|
-
args: [
|
|
294
|
-
['<dx> <dy>', 'CSS pixels; positive dy scrolls down.'],
|
|
295
|
-
],
|
|
296
|
-
detail: `The events go to the mouse's point (mousemove puts it there): a map zooms around it, so mousemove 400
|
|
297
|
-
300, then mousewheel 0 -300 zooms in. It returns once the events are sent, not once the page has
|
|
298
|
-
scrolled or redrawn. While recording, it scrolls in eased steps over up to 1s (help record).`,
|
|
284
|
+
clicked with click, which waits for it. The mouse stays where it went, for a mouse wheel or mouse down
|
|
285
|
+
after it. The cursor (help cursor) moves there first and shows a ring at a press. Under emulate mobile it is
|
|
286
|
+
still the mouse; touch sends a finger's events (help touch).
|
|
287
|
+
|
|
288
|
+
A snapshot has no coordinates; eval of getBoundingClientRect gives an element's centre:
|
|
289
|
+
eval (r => [r.x + r.width / 2, r.y + r.height / 2])(document.querySelector("#map")
|
|
290
|
+
.getBoundingClientRect())
|
|
291
|
+
|
|
292
|
+
playwright-cli's mouse move, mouse down, mouse up and mouse wheel run these.`,
|
|
299
293
|
},
|
|
300
294
|
fill: {
|
|
301
295
|
usage: 'fill <selector> <value> [--submit]',
|
|
@@ -533,7 +527,7 @@ circle). That changes the page, so put it back afterwards, as it was if it had a
|
|
|
533
527
|
summary: 'save a PNG of the viewport, --full page or an element',
|
|
534
528
|
args: [
|
|
535
529
|
['[<ref>]', 'only that element, from a snapshot ref: screenshot e5.'],
|
|
536
|
-
['[name]', 'saved as screenshot-<name>.png; letters, digits, underscores and hyphens. Default: a timestamp.'],
|
|
530
|
+
['[name]', 'saved as screenshot-<name>.png; letters, digits, underscores and hyphens. Default: a timestamp. A word with a / or ending in .png, .jpg or .jpeg is a file instead, as --filename: x.png is the file x.png, not screenshot-x.png. At the prompt, a path with a space goes in --filename="<file>"; through pw-repl send, either works.'],
|
|
537
531
|
],
|
|
538
532
|
options: [
|
|
539
533
|
['--full', 'the whole page, not only the viewport; --full-page too.'],
|
|
@@ -591,14 +585,14 @@ shows in screenshots and recordings. Selectors, snapshot and text don't see it,
|
|
|
591
585
|
detail: `The REPL's clicks and typing don't move a pointer on screen, so a recording shows things happening
|
|
592
586
|
with nothing pointing at them. With the cursor on, these commands move it smoothly to their element or
|
|
593
587
|
point before they act: click, dblclick, hover, drag (to each end), fill, type <selector>, press <selector>, select, check,
|
|
594
|
-
uncheck, upload,
|
|
595
|
-
records), and the command waits for it. Clicks (click, dblclick, check, uncheck,
|
|
588
|
+
uncheck, upload, mouse move, mouse click and mouse dblclick. Each move takes 150 to 600ms by distance (300 to 1000ms while the tab
|
|
589
|
+
records), and the command waits for it. Clicks (click, dblclick, check, uncheck, mouse click, mouse dblclick, mouse down, and upload
|
|
596
590
|
when it clicks a button) show a ring where they land.
|
|
597
591
|
|
|
598
592
|
Only the drawing moves: no mouse events are sent along the way, so the page gets the same input as with
|
|
599
593
|
the cursor off. It shows only the REPL's input, from every client, never the user's own mouse. fill,
|
|
600
|
-
select and the like move the drawing but leave the real mouse where it was;
|
|
601
|
-
|
|
594
|
+
select and the like move the drawing but leave the real mouse where it was; mouse down, mouse up and
|
|
595
|
+
mouse wheel act at the real mouse, so the pointer moves back there first.
|
|
602
596
|
|
|
603
597
|
It is a mode of its tab: modes lists it and modes off turns it off. It stays on across page loads, at
|
|
604
598
|
the same spot, and shows in screenshots and recordings. Selectors, snapshot and text don't see it, it
|
|
@@ -626,7 +620,7 @@ or headless tab keeps recording. It brings the tab to the front first, as screen
|
|
|
626
620
|
owner-only, as a screenshot is, and never replaces one. One recording per tab; another client with the
|
|
627
621
|
tab selected can stop it, and both are told whose it was.
|
|
628
622
|
|
|
629
|
-
While recording: type types at a pace the video can show (help type),
|
|
623
|
+
While recording: type types at a pace the video can show (help type), mouse wheel scrolls in eased steps,
|
|
630
624
|
an element out of view is scrolled to smoothly, each action and each highlight drawn is followed by --pause, and with the cursor
|
|
631
625
|
on it rests 300ms on the element before acting. fill still sets a value at once: use type for text the
|
|
632
626
|
viewer should see typed. Send the take as a file of commands (pw-repl send --file take.txt), or the time
|
|
@@ -915,7 +909,7 @@ window.__done until it is set.`,
|
|
|
915
909
|
},
|
|
916
910
|
cdp: {
|
|
917
911
|
usage: 'cdp [--all] <method> <JSON object>',
|
|
918
|
-
summary: 'send one CDP command
|
|
912
|
+
summary: 'send one CDP command to the selected tab',
|
|
919
913
|
args: [
|
|
920
914
|
['<method>', 'a CDP method: Network.getCookies.'],
|
|
921
915
|
['<JSON object>', 'its parameters: {} for none.'],
|
|
@@ -923,8 +917,21 @@ window.__done until it is set.`,
|
|
|
923
917
|
options: [
|
|
924
918
|
['--all', 'the whole result, not capped.'],
|
|
925
919
|
],
|
|
926
|
-
detail: `
|
|
927
|
-
Target.closeTarget are refused, and so is Page.handleJavaScriptDialog: dialog
|
|
920
|
+
detail: `A command goes through a temporary session, detached afterwards, so subscriptions and settings do not
|
|
921
|
+
persist (input aside, below). Browser.close and Target.closeTarget are refused, and so is Page.handleJavaScriptDialog: dialog
|
|
922
|
+
answers one.
|
|
923
|
+
|
|
924
|
+
Input (Input.dispatchTouchEvent, dispatchMouseEvent, the synthesize gestures): through a session kept for
|
|
925
|
+
the tab, so a sequence sent one call at a time stays one sequence (Chrome drops it when the session that
|
|
926
|
+
began it detaches). End one before other commands (touchEnd, or touchCancel to drop it): left open, the
|
|
927
|
+
next click lands mid-touch. Input.setIgnoreInputEvents and setInterceptDrags go through a temporary session,
|
|
928
|
+
as they would otherwise stay on and break later clicks.
|
|
929
|
+
|
|
930
|
+
Touch (under emulate mobile, for pointerType touch): touchStart and touchEnd sent this way make a tap, with
|
|
931
|
+
click, and two in a row a dblclick; held, it is still a tap, with no contextmenu. Chrome takes a tap sent
|
|
932
|
+
soon after another gesture as part of it, with no click: up to a few seconds after a drag (sleep 3s). The synthesize gestures
|
|
933
|
+
(Input.synthesizeTapGesture, synthesizePinchGesture, synthesizeScrollGesture) send pointer and touch events
|
|
934
|
+
but no click, dblclick or contextmenu.`,
|
|
928
935
|
},
|
|
929
936
|
clipboard: {
|
|
930
937
|
usage: 'clipboard [--all] [--shared-clipboard]',
|
|
@@ -1087,11 +1094,6 @@ Different on purpose, since the browser may be someone else's too:
|
|
|
1087
1094
|
const asCommand = /^(\S+)\s+--command$/.exec(topicOrCommand);
|
|
1088
1095
|
if (asCommand && Object.hasOwn(COMMANDS, asCommand[1])) return renderCommand(asCommand[1]);
|
|
1089
1096
|
if (Object.hasOwn(COMMANDS, topicOrCommand)) return renderCommand(topicOrCommand);
|
|
1090
|
-
// Not a topic: the mouse commands are in interact, with the others that act on the page.
|
|
1091
|
-
if (topicOrCommand === 'mouse') {
|
|
1092
|
-
const mice = TOPICS.interact.commands.filter(c => c.startsWith('mouse'));
|
|
1093
|
-
return ['The mouse commands, which act at a point (part of help interact):', '', ...commandLines(mice)].join('\n');
|
|
1094
|
-
}
|
|
1095
1097
|
const full = /^(\S+)\s+--all$/.exec(topicOrCommand);
|
|
1096
1098
|
if (full && Object.hasOwn(COMMANDS, full[1])) return renderCommand(full[1], true);
|
|
1097
1099
|
// A playwright-cli name: what it runs here, with that command's help.
|
|
@@ -1108,9 +1110,13 @@ Different on purpose, since the browser may be someone else's too:
|
|
|
1108
1110
|
// pw-repl's own commands are run at the shell, so their help is the usage.
|
|
1109
1111
|
const SHELL_COMMANDS = new Set(['run', 'serve', 'send', 'attach', 'stop', 'where', 'skill']);
|
|
1110
1112
|
|
|
1113
|
+
// Commands that were renamed, so the old name says where it went.
|
|
1114
|
+
const RENAMED = { mouseclick: 'mouse click', mousedblclick: 'mouse dblclick' };
|
|
1115
|
+
|
|
1111
1116
|
function notFound(topic) {
|
|
1117
|
+
if (Object.hasOwn(RENAMED, topic)) return `${topic} is now ${RENAMED[topic]}: help ${RENAMED[topic].split(' ')[0]}`;
|
|
1112
1118
|
if (SHELL_COMMANDS.has(topic)) return `${topic} is run at the shell (pw-repl ${topic}), not in the REPL; pw-repl --help has its options`;
|
|
1113
1119
|
return `No help for ${topic}. Topics: ${Object.keys(TOPICS).join(', ')}`;
|
|
1114
1120
|
}
|
|
1115
1121
|
|
|
1116
|
-
module.exports = { render, notFound, TOPICS, COMMANDS, TASKS, MODES };
|
|
1122
|
+
module.exports = { render, notFound, TOPICS, COMMANDS, TASKS, MODES, RENAMED };
|
package/lib/inspect.js
CHANGED
|
@@ -5,7 +5,7 @@ const { state, withTimeout } = require('./state');
|
|
|
5
5
|
const out = require('./output');
|
|
6
6
|
const { printOutput } = out;
|
|
7
7
|
const { devices } = require('playwright-core');
|
|
8
|
-
const { toSelector, REF, unquote } = require('./syntax');
|
|
8
|
+
const { toSelector, REF, SCREENSHOT_FILE, unquote } = require('./syntax');
|
|
9
9
|
const { regexOf, COMMAND_TIMEOUT, BUSY_PAGE, TIME, parseDuration, durationText } = require('./util');
|
|
10
10
|
const { tabState } = require('./tabstate');
|
|
11
11
|
const { noteWritten } = require('./written');
|
|
@@ -316,7 +316,7 @@ const commands = {
|
|
|
316
316
|
},
|
|
317
317
|
|
|
318
318
|
async screenshot(args) {
|
|
319
|
-
const usage = 'Usage: screenshot [<ref>] [--full] [--delay|-d <time>] [name | --filename=<file>]\nNames may contain letters, digits, underscores, and hyphens.';
|
|
319
|
+
const usage = 'Usage: screenshot [<ref>] [--full] [--delay|-d <time>] [name | <file> | --filename=<file>]\nNames may contain letters, digits, underscores, and hyphens; a word with a / or ending in .png, .jpg or .jpeg is a file.';
|
|
320
320
|
// The file first: a path may be quoted, with spaces in it.
|
|
321
321
|
let file = null;
|
|
322
322
|
const rest = (args || '').replace(/(?:^|\s)--filename(?:=|\s+)("(?:[^"\\]|\\.)*"|'[^']*'|\S+)/, (_, value) => { file = unquote(value); return ' '; });
|
|
@@ -339,6 +339,11 @@ const commands = {
|
|
|
339
339
|
if (delay === null || delay > SCREENSHOT_DELAY_MAX) throw new Error(`Usage: screenshot --delay <time> (ms, or 5s; at most ${durationText(SCREENSHOT_DELAY_MAX)})`);
|
|
340
340
|
continue;
|
|
341
341
|
}
|
|
342
|
+
if (SCREENSHOT_FILE.test(token)) {
|
|
343
|
+
if (file) throw new Error(usage);
|
|
344
|
+
file = token;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
342
347
|
names.push(token);
|
|
343
348
|
}
|
|
344
349
|
if (names.length > 1 || (names[0] && !/^[a-zA-Z0-9_-]+$/.test(names[0])) || (file && names.length) || (ref && full)) throw new Error(usage);
|
|
@@ -599,6 +604,13 @@ const commands = {
|
|
|
599
604
|
if (match[1] === 'Page.handleJavaScriptDialog') throw new Error(CDP_REFUSED);
|
|
600
605
|
const params = JSON.parse(match[2]);
|
|
601
606
|
if (!params || Array.isArray(params) || typeof params !== 'object') throw new Error('Parameters must be a JSON object');
|
|
607
|
+
// Input sent through the tab's kept session: Chrome drops a touch or mouse sequence when the session
|
|
608
|
+
// that began it detaches. Not Input's two switches, which would stay on and break later clicks.
|
|
609
|
+
if (/^Input\./.test(match[1]) && !['Input.setIgnoreInputEvents', 'Input.setInterceptDrags'].includes(match[1])) {
|
|
610
|
+
const kept = await keptSession(state.page);
|
|
611
|
+
printOutput(await withTimeout(kept.send(match[1], params), 'CDP operation', COMMAND_TIMEOUT), all);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
602
614
|
const operation = async () => {
|
|
603
615
|
const cdp = await state.page.context().newCDPSession(state.page);
|
|
604
616
|
try {
|
package/lib/record.js
CHANGED
|
@@ -393,13 +393,13 @@ function videoTime(rec) {
|
|
|
393
393
|
// What a step is, for its line: the command and what it acts on, never what it types or chooses, the
|
|
394
394
|
// code it runs or the URL it goes to, which can hold a password or a token. A steps file goes where the
|
|
395
395
|
// video goes, and the video shows a password as dots.
|
|
396
|
-
const WHOLE = new Set(['click', 'dblclick', 'hover', 'drag', 'check', 'uncheck', '
|
|
396
|
+
const WHOLE = new Set(['click', 'dblclick', 'hover', 'drag', 'check', 'uncheck', 'mouse']);
|
|
397
397
|
const TARGET_FIRST = new Set(['fill', 'type', 'select', 'press', 'upload']);
|
|
398
398
|
function stepLabel(cmd, args) {
|
|
399
399
|
if (WHOLE.has(cmd)) {
|
|
400
400
|
const words = args.replace(/\s+--modifiers=\S+/, '').trim();
|
|
401
401
|
// An element's selector as written, without the quotes it needed on the command line (click's button after it).
|
|
402
|
-
const parsed =
|
|
402
|
+
const parsed = cmd === 'mouse' ? null : splitSelector(words);
|
|
403
403
|
return `${cmd} ${parsed ? [parsed.word, parsed.rest].filter(Boolean).join(' ') : words}`.trim();
|
|
404
404
|
}
|
|
405
405
|
// A toast's text is what it said in the video, for whoever edits it.
|
package/lib/runner.js
CHANGED
|
@@ -7,6 +7,7 @@ const { commands, cliCommands, needsTab } = require('./commands');
|
|
|
7
7
|
const { dialogCommand, CDP_ANSWER, CDP_REFUSED } = require('./dialogs');
|
|
8
8
|
const { activeModes } = require('./modes');
|
|
9
9
|
const { NO_TAB } = require('./util');
|
|
10
|
+
const { RENAMED } = require('./help');
|
|
10
11
|
const { stepStarted, stepEnded, stepLabel, pauseAfter } = require('./record');
|
|
11
12
|
const cliNames = require('./cli-names');
|
|
12
13
|
const { expandVariables } = require('./syntax');
|
|
@@ -108,6 +109,10 @@ async function executeCommand(text) {
|
|
|
108
109
|
out.error(`Error: ${cmd} is a pw-repl subcommand, run at the shell: pw-repl ${cmd}${args ? ' ...' : ''}`);
|
|
109
110
|
return { status: 'error', cmd };
|
|
110
111
|
}
|
|
112
|
+
if (Object.hasOwn(RENAMED, cmd)) {
|
|
113
|
+
out.error(`Error: ${cmd} is now ${RENAMED[cmd]}: help ${RENAMED[cmd].split(' ')[0]}`);
|
|
114
|
+
return { status: 'error', cmd };
|
|
115
|
+
}
|
|
111
116
|
out.log(`Unknown command: ${cmd}. Type 'help' for commands.`);
|
|
112
117
|
return { status: 'error', cmd };
|
|
113
118
|
}
|
package/lib/send.js
CHANGED
|
@@ -6,7 +6,7 @@ const fs = require('fs');
|
|
|
6
6
|
const crypto = require('crypto');
|
|
7
7
|
const { execFileSync } = require('child_process');
|
|
8
8
|
const client = require('./client');
|
|
9
|
-
const { shellWords, variablesIn } = require('./syntax');
|
|
9
|
+
const { shellWords, variablesIn, SCREENSHOT_FILE } = require('./syntax');
|
|
10
10
|
|
|
11
11
|
// The prompt, after the modes on in the selected tab if any: (watch network:off) pw[serve]>
|
|
12
12
|
const PROMPT = /^(?:\([^()]*\) )?pw(\[serve\])?>\s*$/;
|
|
@@ -139,7 +139,8 @@ function commandLine(words) {
|
|
|
139
139
|
if (file) resolved = [...words.slice(0, at), `--file=${resolve(file)}`, ...rest];
|
|
140
140
|
}
|
|
141
141
|
if (words[0] === 'screenshot') {
|
|
142
|
-
|
|
142
|
+
// A file as a word of its own is sent as --filename, so a space in it stays in it.
|
|
143
|
+
resolved = words.map((w, n) => (w.startsWith('--filename=') && w.length > 11 ? `--filename=${resolve(w.slice(11))}` : words[n - 1] === '--filename' ? resolve(w) : n && !w.startsWith('-') && SCREENSHOT_FILE.test(w) ? `--filename=${resolve(w)}` : w));
|
|
143
144
|
}
|
|
144
145
|
const quoted = requote ? resolved.map(w => (w === '' || /[\s"']/.test(w) ? doubleQuote(w) : w)) : resolved;
|
|
145
146
|
// An option's value the shell kept whole stays whole too: --device='iPhone 15'.
|
package/lib/syntax.js
CHANGED
|
@@ -89,4 +89,7 @@ function expandVariables(line, values) {
|
|
|
89
89
|
});
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
|
|
92
|
+
// A screenshot's file given as a word of its own: a name has neither a / nor an extension.
|
|
93
|
+
const SCREENSHOT_FILE = /\/|\.(?:png|jpe?g)$/i;
|
|
94
|
+
|
|
95
|
+
module.exports = { SELECTOR_FIRST, REF, SCREENSHOT_FILE, toSelector, unescapeDouble, unquote, shellWords, splitSelector, keyName, variablesIn, expandVariables };
|
package/lib/tabs.js
CHANGED
|
@@ -21,6 +21,12 @@ async function navigating(go) {
|
|
|
21
21
|
for (let waited = 0; !find() && waited < 1000; waited += 50) await new Promise(resolve => setTimeout(resolve, 50));
|
|
22
22
|
const navigation = find();
|
|
23
23
|
if (!navigation) throw error;
|
|
24
|
+
// Chrome commits its error page just after the request fails: waited for, so info right after reads it.
|
|
25
|
+
// Capped at 1s, for a failure with no error page; a tab already on one matches at once, which info
|
|
26
|
+
// still reads right, from the log.
|
|
27
|
+
if (!stayedPut(navigation) && !/ERR_ABORTED/.test(`${navigation.status} ${navigation.failed}`)) {
|
|
28
|
+
for (let waited = 0; !state.page.url().startsWith('chrome-error://') && waited < 1000; waited += 50) await new Promise(resolve => setTimeout(resolve, 50));
|
|
29
|
+
}
|
|
24
30
|
throw new Error(notLoaded(navigation));
|
|
25
31
|
}
|
|
26
32
|
}
|
package/lib/touch.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// touch: gestures at points in the viewport, as a finger makes them, for a tab under emulate mobile.
|
|
2
|
+
// Raw touch events through the tab's kept CDP session, not Chrome's synthesize gestures, which send
|
|
3
|
+
// no click or dblclick.
|
|
4
|
+
const { state } = require('./state');
|
|
5
|
+
const out = require('./output');
|
|
6
|
+
const { numbers, parseDuration, durationText } = require('./util');
|
|
7
|
+
const { tabState } = require('./tabstate');
|
|
8
|
+
const { keptSession } = require('./cdp');
|
|
9
|
+
|
|
10
|
+
const GESTURES = ['tap', 'doubletap', 'longpress', 'swipe', 'pinch'];
|
|
11
|
+
const LONGPRESS = 800;
|
|
12
|
+
// A move every frame or so: one jump would read as no swipe to a page that measures its speed.
|
|
13
|
+
const STEP_MS = 16;
|
|
14
|
+
const STEPS = 12;
|
|
15
|
+
// Where pinch's two fingers start: this far apart, side by side about the point.
|
|
16
|
+
const PINCH_SPAN = 100;
|
|
17
|
+
|
|
18
|
+
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
19
|
+
|
|
20
|
+
async function send(type, points) {
|
|
21
|
+
const session = await keptSession(state.page);
|
|
22
|
+
await session.send('Input.dispatchTouchEvent', { type, touchPoints: points.map(([x, y], id) => ({ x, y, id })) });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function tap(x, y) {
|
|
26
|
+
await send('touchStart', [[x, y]]);
|
|
27
|
+
await send('touchEnd', []);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function usageOf(gesture) {
|
|
31
|
+
return {
|
|
32
|
+
tap: 'touch tap <x> <y>',
|
|
33
|
+
doubletap: 'touch doubletap <x> <y>',
|
|
34
|
+
longpress: 'touch longpress <x> <y> [time]',
|
|
35
|
+
swipe: 'touch swipe <x> <y> <dx> <dy>',
|
|
36
|
+
pinch: 'touch pinch <x> <y> <scale>',
|
|
37
|
+
}[gesture];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const commands = {
|
|
41
|
+
async touch(args) {
|
|
42
|
+
const [gesture, ...rest] = (args || '').trim().split(/\s+/);
|
|
43
|
+
if (!GESTURES.includes(gesture)) throw new Error(`Usage: touch ${GESTURES.join('|')} <x> <y> ...; help touch says what each takes`);
|
|
44
|
+
const usage = `Usage: ${usageOf(gesture)}`;
|
|
45
|
+
// Without it the page gets touch events but no touch screen: pointerType and the like say otherwise.
|
|
46
|
+
if (!tabState(state.page).emulation?.device) throw new Error('touch needs a touch screen: emulate mobile first, then reload');
|
|
47
|
+
const counts = { tap: 2, doubletap: 2, longpress: 3, swipe: 4, pinch: 3 }[gesture];
|
|
48
|
+
if (gesture === 'longpress' && rest.length === 3) {
|
|
49
|
+
const held = parseDuration(rest[2]);
|
|
50
|
+
if (held === null) throw new Error(`${usage}, the time in ms or seconds (1.5s)`);
|
|
51
|
+
rest[2] = String(held);
|
|
52
|
+
}
|
|
53
|
+
const values = numbers(rest.join(' '), usageOf(gesture));
|
|
54
|
+
if (values.length < (gesture === 'longpress' ? 2 : counts) || values.length > counts) throw new Error(usage);
|
|
55
|
+
const [x, y] = values;
|
|
56
|
+
if (gesture === 'tap') {
|
|
57
|
+
await tap(x, y);
|
|
58
|
+
out.log(`Tapped at ${x}, ${y}`);
|
|
59
|
+
} else if (gesture === 'doubletap') {
|
|
60
|
+
await tap(x, y);
|
|
61
|
+
await tap(x, y);
|
|
62
|
+
out.log(`Double-tapped at ${x}, ${y}`);
|
|
63
|
+
} else if (gesture === 'longpress') {
|
|
64
|
+
const held = values[2] ?? LONGPRESS;
|
|
65
|
+
await send('touchStart', [[x, y]]);
|
|
66
|
+
await wait(held);
|
|
67
|
+
await send('touchEnd', []);
|
|
68
|
+
out.log(`Long-pressed at ${x}, ${y} for ${durationText(held)}`);
|
|
69
|
+
} else if (gesture === 'swipe') {
|
|
70
|
+
const [, , dx, dy] = values;
|
|
71
|
+
await send('touchStart', [[x, y]]);
|
|
72
|
+
for (let i = 1; i <= STEPS; i++) {
|
|
73
|
+
await wait(STEP_MS);
|
|
74
|
+
await send('touchMove', [[x + dx * i / STEPS, y + dy * i / STEPS]]);
|
|
75
|
+
}
|
|
76
|
+
await send('touchEnd', []);
|
|
77
|
+
out.log(`Swiped from ${x}, ${y} to ${x + dx}, ${y + dy}`);
|
|
78
|
+
} else {
|
|
79
|
+
const scale = values[2];
|
|
80
|
+
if (!(scale > 0)) throw new Error(`${usage}: a scale above 0; under 1 pinches in`);
|
|
81
|
+
const fingers = span => [[x - span / 2, y], [x + span / 2, y]];
|
|
82
|
+
await send('touchStart', fingers(PINCH_SPAN));
|
|
83
|
+
for (let i = 1; i <= STEPS; i++) {
|
|
84
|
+
await wait(STEP_MS);
|
|
85
|
+
await send('touchMove', fingers(PINCH_SPAN * (1 + (scale - 1) * i / STEPS)));
|
|
86
|
+
}
|
|
87
|
+
await send('touchEnd', []);
|
|
88
|
+
out.log(`Pinched at ${x}, ${y} to ${scale}x`);
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
module.exports = { commands, GESTURES };
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pw-repl
|
|
3
|
-
description: Inspect and drive a Chromium browser, possibly one the user is using, through the pw-repl REPL - tabs, page snapshots, clicks and typing, the mouse at a point (moves, buttons, wheel), requests and their bodies, console messages, what a page copied to the clipboard, waiting for pages and elements, choosing files, faking, patching or delaying responses, cutting or slowing the network, emulating a phone, dark mode, a locale or a timezone, recording what the user does, profiling what makes a page slow (long tasks, forced layouts, input delay, dropped frames), and recording a video of a tab, with a pointer and on-screen captions. Use when asked to look at, debug or test something in a web page, in an existing Chromium with remote debugging or in one it starts itself.
|
|
3
|
+
description: Inspect and drive a Chromium browser, possibly one the user is using, through the pw-repl REPL - tabs, page snapshots, clicks and typing, the mouse at a point (moves, buttons, wheel), touch gestures on an emulated phone (tap, swipe, pinch), requests and their bodies, console messages, what a page copied to the clipboard, waiting for pages and elements, choosing files, faking, patching or delaying responses, cutting or slowing the network, emulating a phone, dark mode, a locale or a timezone, recording what the user does, profiling what makes a page slow (long tasks, forced layouts, input delay, dropped frames), and recording a video of a tab, with a pointer and on-screen captions. Use when asked to look at, debug or test something in a web page, in an existing Chromium with remote debugging or in one it starts itself.
|
|
4
4
|
allowed-tools: Bash(pw-repl:*) Bash(npx pw-repl@latest:*)
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -183,8 +183,9 @@ blindly retry a change), `64` usage or the REPL is not reachable. `pw-repl --hel
|
|
|
183
183
|
Run `pw-repl send help`. It lists seven topics; `help <topic>` lists their commands, `help <command>`
|
|
184
184
|
gives usage and caveats: read it before you use a command, since this file does not repeat them. When a
|
|
185
185
|
command fails or does something you did not expect, read its `help <command>` before working around it.
|
|
186
|
-
`help --all` prints everything
|
|
187
|
-
|
|
186
|
+
`help --all` prints everything, tens of kilobytes: a last resort, when the topics and commands have not
|
|
187
|
+
answered. pw-repl answers help itself: it needs no running REPL, and never reaches one. If you know
|
|
188
|
+
playwright-cli, its command names work too (`help playwright-cli` lists them).
|
|
188
189
|
|
|
189
190
|
### Coming from playwright-cli
|
|
190
191
|
|