pw-repl 0.12.8 → 0.14.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 CHANGED
@@ -23,7 +23,7 @@ If you hit a limitation or write a workaround, consider adding the capability to
23
23
  `lib/routes.js`, `lib/network.js`, `lib/emulation.js` (and viewport), `lib/capture.js`, `lib/profile.js` (performance traces;
24
24
  `lib/trace.js` reads and sums them up), `lib/record.js`
25
25
  (video, through ffmpeg, and the steps file saved with it), `lib/dialogs.js` and `lib/modes.js`. Beneath them, `lib/tabstate.js` is the one record kept per tab, `lib/cdp.js` the CDP
26
- session kept per tab, and `lib/util.js` small shared helpers. No facility requires `lib/modes.js` or
26
+ session kept per tab, `lib/util.js` small shared helpers, and `lib/written.js` the files written and for whom. No facility requires `lib/modes.js` or
27
27
  `lib/commands.js`, and a unit test fails on a require cycle, which CommonJS would not report.
28
28
  - `bin/pw-repl.js` is the command line; `lib/start.js` connects and runs the prompt; `lib/runner.js`
29
29
  runs commands one at a time (`quit` and `dialog` skip its queue); `lib/server.js` is the command
@@ -39,7 +39,7 @@ If you hit a limitation or write a workaround, consider adding the capability to
39
39
  accepts theirs too (`lib/cli-names.js`, `help playwright-cli`). Deviate only where it is necessary, as
40
40
  where sharing a browser with a user needs it, and say why in the help.
41
41
  - Comment the *why* when it isn't obvious from the code.
42
- - `npm test` runs the suite (under two minutes): a private headless Chromium, a local test site
42
+ - `npm test` runs the suite (about two and a half minutes): a private headless Chromium, a local test site
43
43
  (`test/harness.js`), and the real REPL with its server. Nothing is mocked, and the shared browser is
44
44
  never touched. It finds Chromium through `PW_TEST_CHROME`, or where `--launch` looks (Playwright's
45
45
  browsers, then the `PATH`), and skips the browser tests if there is none. Add a test with each new
@@ -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 --grep='npm test:'`): a run that grew by more than
61
- a few seconds means a slow test crept in, usually one waiting out a real timeout (5s for a click).
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:`,
@@ -105,13 +81,19 @@ Commits follow [Conventional Commits](https://www.conventionalcommits.org/): `fe
105
81
  1.0, a change that breaks how the REPL is used is a minor bump (0.2.0 to 0.3.0), anything else a patch.
106
82
 
107
83
  1. `npm test` passes and the working tree is clean.
108
- 2. `npm outdated` and `npm audit` show nothing that needs doing first. The lockfile holds playwright-core
109
- at 1.63: on 1.64 (and 1.64.1), a click on a link to a 204 waits out its timeout instead of returning (the test
110
- "waits for the page to load, counting a navigation that just began" fails). Update it once a version
111
- passes `npm test`.
84
+ 2. `npm outdated` and `npm audit` show nothing that needs doing first. Dependencies are always the newest
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
88
+ on a link to a 204 hangs at "waiting for scheduled navigations to finish" until its timeout, in plain
89
+ Playwright too (chromium.launch, a page with a link to a 204, page.click on it); 1.65's alphas fix it.
90
+ The test "waits for the page to load, counting a navigation that just began" shows it: move up once it
91
+ passes on the newest release.
112
92
  3. `npm version <patch|minor|major> -m "chore: release %s"` sets the version in `package.json` and
113
93
  `package-lock.json`, commits it as `chore: release X.Y.Z`, and tags that commit `vX.Y.Z`.
114
94
  4. `git push --follow-tags` pushes the commits and the tag.
115
95
  5. `npm publish` publishes it; `prepublishOnly` runs the tests again first.
116
96
 
97
+ A release cut too early and not pushed yet is undone (its commit and tag) and cut again after the fixes.
98
+
117
99
  Pushing and publishing need the maintainer's GitHub and npm credentials.
package/README.md CHANGED
@@ -83,7 +83,7 @@ With `npx`, use `npx pw-repl@latest` the same way every time, so every command r
83
83
  whole tab's network.
84
84
  - **Emulate:** a phone, dark mode, a locale, a timezone, a viewport size.
85
85
  - **Run code:** `eval` any JavaScript in the page, a line or a file of it, or send a raw CDP command with
86
- `cdp`.
86
+ `cdp`; read what a Copy button put on the clipboard (`clipboard`).
87
87
  - **Wait:** for an element, some text, a response or a page load.
88
88
  - **Record:** what someone does in a tab (`watch`), a timeline of requests and console (`capture`), or a
89
89
  video of the tab (`record`), with a virtual pointer (`cursor`), captions (`toast`) and highlights
@@ -154,7 +154,7 @@ example the Docker bridge gateway, `ip route | awk '/default/ {print $3}'`).
154
154
  | record requests and console together | `capture on`, then `capture off` |
155
155
  | find what makes the page slow | `profile on`, then `profile off`; `profile reload` for a load |
156
156
  | fake a backend response | `route <glob> <status> <json>`, `route <glob> abort` |
157
- | change or slow an API response | `route <glob> patch <json>`, `route <glob> delay <secs>` |
157
+ | change or slow an API response | `route <glob> patch <json>`, `route <glob> delay <time>` |
158
158
  | cut or slow the whole network | `network off`, `network slow`, then `network on` |
159
159
  | record a video of the page | `record on`, then `record off` (`help video` for a take) |
160
160
  | show a pointer where the agent acts | `cursor on`, then `cursor off` |
@@ -199,7 +199,10 @@ pw-repl where # which REPL send would reach
199
199
 
200
200
  `pw-repl send` sends one command and prints its result, through the server when one is running, and
201
201
  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.
202
+ fails or is not confirmed (exit 1 or 2), and `;` carries on. `-t` is how long it waits for the answer
203
+ (default 20s). Times, there and in every command, are in milliseconds, as in Playwright, or in seconds
204
+ with an `s`: `-t 90000` or `-t 90s`. A send that gives up cancels its command: one still queued never
205
+ runs, and one running is no longer waited for.
203
206
 
204
207
  Several agents can share one REPL. `pw-repl send -c <name>` (or `PW_CLIENT=<name>`) sends as a client
205
208
  with a selected tab of its own, shown in the REPL as `[server:<name>]`. `modes` says which client turned
@@ -227,7 +230,8 @@ The body may also have `"client": "<name>"`, and `"vars": {"PW_NAME": "<value>"}
227
230
  "error", "output": "..."}`, plus `"unconfirmed": true` when the command may or may not have done what it
228
231
  was sent to do (it timed out, or the REPL quit while it ran and it was not read-only). While the REPL
229
232
  starts, `/run` answers 503 with `"starting": true`: retry. `GET /health` says `"status": "starting"` until
230
- it serves, then `"ok"`. `pw-repl serve <port>` serves TCP on 127.0.0.1; other addresses are refused.
233
+ it serves, then `"ok"`. A client that closes its connection before the answer cancels the command, as
234
+ `send` does when it gives up. `pw-repl serve <port>` serves TCP on 127.0.0.1; other addresses are refused.
231
235
 
232
236
  ## How it differs from playwright-cli
233
237
 
@@ -237,6 +241,7 @@ playwright-cli's command names and most of their options work here too (`help pl
237
241
  them). Where pw-repl differs, it is mostly because someone else may be using the browser:
238
242
 
239
243
  - `open` and `close` open a tab and turn off your modes; they never start, close or wipe a browser.
244
+ `close` also lists the files you wrote that are still there.
240
245
  - Routes, network and emulation apply to one tab, not the whole browser.
241
246
  - File pickers are not caught, since they may be the user's: `upload` names the input instead.
242
247
  - There is no snapshot after every command, since the user reads the REPL too; `snapshot` shows one.
package/bin/pw-repl.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // The one command: pw-repl run | serve | attach | stop | send | where | help | skill.
3
3
 
4
4
  const { looksLikeEndpoint } = require('../lib/client');
5
+ const { longDuration } = require('../lib/util');
5
6
 
6
7
  const USAGE = `Usage:
7
8
  pw-repl run [--launch [--headed]] [start-url] [-- <chromium flags>]
@@ -19,7 +20,7 @@ const USAGE = `Usage:
19
20
  stop a background REPL, and say what became of each recording it ended; its socket is removed, its
20
21
  log stays
21
22
 
22
- pw-repl send [-e endpoint | -s session] [-c client] [-t seconds] [--no-vars]
23
+ pw-repl send [-e endpoint | -s session] [-c client] [-t time] [--no-vars]
23
24
  <command...> | --file <file>
24
25
  run one command in a running REPL and print its output; --file runs a file of them, one per line
25
26
 
@@ -56,12 +57,14 @@ serves on that same one: $PW_ENDPOINT, then $PW_SOCKET, then the default.
56
57
  send -c <client> (or $PW_CLIENT) sends as a client of the REPL's with a selected tab of its own, so
57
58
  several agents can share one REPL; pw-repl help session has the rest.
58
59
 
59
- send's options can also follow the command, as in pw-repl send open <url> -t 30; after a -- before the
60
- command, every word is the command's, -t 30 included.
60
+ send's options can also follow the command, as in pw-repl send open <url> -t 30s; after a -- before the
61
+ command, every word is the command's, -t 30s included.
61
62
 
62
63
  Exit status: 0 ok, 1 command error, 2 completion not confirmed, 64 usage or unreachable. -t is how long
63
- send waits for the answer (default 20s); it does not lengthen a command's own limits, such as the 5s an
64
- action waits for its element.
64
+ send waits for the answer, in milliseconds or with s for seconds (default 20s: 20000); it does not
65
+ lengthen a command's own limits, such as the 5s an action waits for its element. Over the server, giving
66
+ up cancels the command: one still queued is never run, and one running is no longer waited for, though
67
+ what it began in the page may go on.
65
68
 
66
69
  --file <file> runs each line of a file as a command, in order, as if typed at the REPL's prompt: quoted
67
70
  as the prompt reads it (no shell), with a file it records, uploads or saves relative to this folder, as
@@ -105,10 +108,10 @@ function shellLine(words) {
105
108
  }
106
109
 
107
110
  // -e, -s, -c and -t, then the command words (unquoted words are one command), and send's options again
108
- // at the end, as an agent types them (send open <url> -t 30); after --, every word is the command.
111
+ // at the end, as an agent types them (send open <url> -t 30s); after --, every word is the command.
109
112
  function parseSendArgs(args, subcommand) {
110
113
  const allowCommand = subcommand === 'send';
111
- const options = { endpoint: null, session: null, client: null, timeout: 20, command: '', file: null };
114
+ const options = { endpoint: null, session: null, client: null, timeout: 20000, command: '', file: null };
112
115
  const given = new Set();
113
116
  const take = (arg, value, after) => {
114
117
  if (value === undefined) usage(`${arg} needs a value`, subcommand);
@@ -120,8 +123,7 @@ function parseSendArgs(args, subcommand) {
120
123
  if (arg === '-s') options.session = value;
121
124
  if (arg === '-c') options.client = value;
122
125
  if (arg === '-t') {
123
- if (!/^\d+$/.test(value) || Number(value) < 1) usage(`-t takes whole seconds, 1 or more (got ${value})`, subcommand);
124
- options.timeout = Number(value);
126
+ try { options.timeout = longDuration(value, '-t'); } catch (error) { usage(error.message, subcommand); }
125
127
  }
126
128
  };
127
129
  let i = 0;
@@ -296,7 +298,7 @@ async function main() {
296
298
  case '--help':
297
299
  return console.log(`${USAGE}\n\n${REPL_HELP}`);
298
300
  default: {
299
- // An option before the subcommand (pw-repl -t 30 send ...): say where it goes, with the line fixed.
301
+ // An option before the subcommand (pw-repl -t 30s send ...): say where it goes, with the line fixed.
300
302
  if (subcommand.startsWith('-')) {
301
303
  const all = [subcommand, ...args];
302
304
  const named = all.find(word => SUBCOMMANDS.includes(word));
package/lib/background.js CHANGED
@@ -5,7 +5,7 @@ const path = require('path');
5
5
  const readline = require('readline');
6
6
  const { spawn } = require('child_process');
7
7
  const client = require('./client');
8
- const { STOPPED_RECORDING, ENDED_UNSEEN } = require('./util');
8
+ const { STOPPED_RECORDING, ENDED_UNSEEN, FILES_LEFT } = require('./util');
9
9
 
10
10
  const BIN = path.join(__dirname, '..', 'bin', 'pw-repl.js');
11
11
  const START_TIMEOUT = 20000;
@@ -162,7 +162,7 @@ async function stop(options) {
162
162
  fs.closeSync(fd);
163
163
  logged = buffer.toString('utf8');
164
164
  } catch {}
165
- for (const line of logged.split('\n')) if (line.startsWith(STOPPED_RECORDING) || line.startsWith(ENDED_UNSEEN)) console.log(line);
165
+ for (const line of logged.split('\n')) if (line.startsWith(STOPPED_RECORDING) || line.startsWith(ENDED_UNSEEN) || line.startsWith(FILES_LEFT)) console.log(line);
166
166
  return 0;
167
167
  }
168
168
 
package/lib/capture.js CHANGED
@@ -2,7 +2,7 @@
2
2
  const { state, onShutdown } = require('./state');
3
3
  const out = require('./output');
4
4
  const { printOutput } = out;
5
- const { numbers, MAX_EVENT_TEXT, NO_TAB, clock, hints } = require('./util');
5
+ const { numbers, MAX_EVENT_TEXT, NO_TAB, clock, hints, TIME, longDuration, durationText } = require('./util');
6
6
  const { consoleText, requestEntries } = require('./requestlog');
7
7
 
8
8
  const MAX_CAPTURE_EVENTS = 10000;
@@ -17,16 +17,15 @@ function currentCapture() {
17
17
  }
18
18
 
19
19
  // Records the selected tab's requests and console messages together, in time
20
- // order, until capture off, or for a set number of seconds.
20
+ // order, until capture off, or for a set time.
21
21
  async function startCapture(tokens) {
22
22
  if (cap) { out.log('Already capturing; capture off stops it.'); return; }
23
23
  if (!state.page || state.page.isClosed()) throw new Error(NO_TAB);
24
- const usage = 'Usage: capture on [requests|console] [seconds] (seconds from 1 to 3600)';
24
+ const usage = 'Usage: capture on [requests|console] [time] (ms, or 30s)';
25
25
  const kinds = tokens.filter(t => t === 'requests' || t === 'console');
26
- const numbers = tokens.filter(t => /^\d+$/.test(t));
27
- if (kinds.length > 1 || numbers.length > 1 || kinds.length + numbers.length !== tokens.length) throw new Error(usage);
28
- const seconds = numbers.length ? Number(numbers[0]) : null;
29
- if (seconds !== null && (seconds < 1 || seconds > 3600)) throw new Error(usage);
26
+ const times = tokens.filter(t => TIME.test(t));
27
+ if (kinds.length > 1 || times.length > 1 || kinds.length + times.length !== tokens.length) throw new Error(usage);
28
+ const ms = times.length ? longDuration(times[0], 'capture on\'s time') : null;
30
29
  const label = kinds[0] || 'requests and console';
31
30
  const startedAt = Date.now();
32
31
  const events = [];
@@ -61,11 +60,11 @@ async function startCapture(tokens) {
61
60
  get shortened() { return shortened; },
62
61
  };
63
62
  cap = capture;
64
- if (seconds) {
65
- out.log(`Capturing ${label} for ${seconds}s...`);
63
+ if (ms) {
64
+ out.log(`Capturing ${label} for ${durationText(ms)}...`);
66
65
  // Also ends early if the capture does, e.g. because its tab closed.
67
66
  await new Promise(resolve => {
68
- const timer = setTimeout(resolve, seconds * 1000);
67
+ const timer = setTimeout(resolve, ms);
69
68
  capture.wake = () => { clearTimeout(timer); resolve(); };
70
69
  });
71
70
  if (cap === capture) stopCapture();
@@ -120,7 +119,7 @@ const commands = {
120
119
  const [sub, ...rest] = (args || '').trim().split(/\s+/).filter(Boolean);
121
120
  if (sub === 'on') return startCapture(rest);
122
121
  if (sub === 'off' && !rest.length) return stopCapture(all);
123
- if (sub) throw new Error('Usage: capture on [requests|console] [seconds] | capture off');
122
+ if (sub) throw new Error('Usage: capture on [requests|console] [time] | capture off');
124
123
  if (cap) {
125
124
  out.log(`Capturing ${cap.label} on ${cap.page.url()} since ${clock(cap.startedAt)} (${cap.events.length} so far)`);
126
125
  out.log(hints([['capture off', 'stop and print what it recorded']]));
@@ -132,7 +131,7 @@ const commands = {
132
131
  } else {
133
132
  out.log('Not capturing.');
134
133
  }
135
- out.log(hints([['capture on [requests|console] [seconds]', 'record the selected tab\'s requests and console messages in time order']]));
134
+ out.log(hints([['capture on [requests|console] [time]', 'record the selected tab\'s requests and console messages in time order']]));
136
135
  },
137
136
  };
138
137
 
package/lib/cli-names.js CHANGED
@@ -15,7 +15,7 @@ const NAMES = {
15
15
  'tab-close': [a => (a ? null : 'tab close'), 'tab close [url-part], or tab <index> then tab close'],
16
16
  open: [a => `tab new ${a}`.trim(), 'tab new [url] (goto <url> navigates); --mobile, --device=<name>: emulate mobile'],
17
17
  // It ends playwright-cli's own browser; this one is shared, so tabs stay open.
18
- close: [() => 'close', 'modes off --mine, and tabs stay open'],
18
+ close: [() => 'close', 'modes off --mine; tabs stay open; lists the files you wrote'],
19
19
  'go-back': [() => 'back', 'back'],
20
20
  'go-forward': [() => 'forward', 'forward'],
21
21
  find: [a => `snapshot --grep ${a}`, 'snapshot --grep <text>'],
package/lib/commands.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // The REPL's commands, gathered from each facility's module, and what the prompt and the runner
2
2
  // need besides: playwright-cli's own commands, the hooks every page gets, and tab completion.
3
3
  const { state, shutdown } = require('./state');
4
+ const { ownFilesText } = require('./written');
4
5
  const out = require('./output');
5
6
  const HELP = require('./help');
6
7
  const { tabState } = require('./tabstate');
@@ -80,6 +81,8 @@ const cliCommands = {
80
81
  await allModesOff(true);
81
82
  const own = state.browser.contexts().flatMap(c => c.pages()).filter(p => tabState(p).owner === state.client);
82
83
  if (own.length) out.log(`Tabs you opened are still open: ${own.map(p => p.url()).join(', ')}; tab close <url-part> closes one, or select it and tab close.`);
84
+ const files = ownFilesText(state.client);
85
+ if (files) out.log(files);
83
86
  },
84
87
  };
85
88
 
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 options = { ...(button ? { button } : {}), ...(modifiers.length ? { modifiers } : {}), ...(clickCount > 1 ? { clickCount } : {}) };
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
- // Worked out from the whole log: the summary no longer says whether the element was found.
108
- summary.uncertain = !/waiting for locator/.test(message) || /resolved to/.test(message);
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());
@@ -426,13 +441,13 @@ function dateInputHolds(kind, value, what, selector) {
426
441
 
427
442
  const commands = {
428
443
  async click(args) {
429
- const { selector, shown, options } = clickArgs(args, 'click <selector> [left|right|middle] [--button=<button>] [--click-count=<n>] [--modifiers=<key>[,<key>]]', true);
444
+ const { selector, shown, options } = clickArgs(args, 'click <selector> [left|right|middle] [--button=<button>] [--click-count=<n>] [--modifiers=<key>[,<key>]] [--force]', true);
430
445
  const which = await pointerAction(selector, (on, sel, o) => (sel ? on.click(sel, o) : on.click(o)), options, options.clickCount || 1);
431
446
  out.log(`Clicked: ${shown}${which}`);
432
447
  },
433
448
 
434
449
  async dblclick(args) {
435
- const { selector, shown, options } = clickArgs(args, 'dblclick <selector> [left|right|middle] [--button=<button>] [--modifiers=<key>[,<key>]]');
450
+ const { selector, shown, options } = clickArgs(args, 'dblclick <selector> [left|right|middle] [--button=<button>] [--modifiers=<key>[,<key>]] [--force]');
436
451
  const which = await pointerAction(selector, (on, sel, o) => (sel ? on.dblclick(sel, o) : on.dblclick(o)), options, 2);
437
452
  out.log(`Double-clicked: ${shown}${which}`);
438
453
  },