verikun 0.20.0 → 0.21.1
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/.claude/skills/verikun/SKILL.md +31 -4
- package/CHANGELOG.md +227 -0
- package/dist/bin/verikun.js +0 -0
- package/dist/capture.js +29 -0
- package/dist/cli.js +94 -16
- package/dist/companion/manager.js +568 -0
- package/dist/companion/protocol.js +111 -0
- package/dist/companion/sock-client.js +22 -0
- package/dist/drivers/adb.js +117 -1
- package/dist/errors.js +23 -1
- package/dist/image.js +40 -0
- package/dist/run.js +4 -1
- package/dist/server.js +29 -0
- package/dist/suite.js +1 -0
- package/dist/version.js +1 -1
- package/package.json +4 -2
- package/tools/verikun-companion/prebuilt/verikun-companion.jar +0 -0
|
@@ -251,10 +251,37 @@ outweigh dozens of `vk ui` calls. When you do, `vk` already downscales the PNG
|
|
|
251
251
|
**Two uses of a screenshot — keep them apart.** The cost above is about *reading a
|
|
252
252
|
screenshot back into context* to decide your next move; that is what to avoid (perceive
|
|
253
253
|
and verify with the hierarchy instead). A screenshot taken purely as **report evidence
|
|
254
|
-
and never read back** costs
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
254
|
+
and never read back** costs no tokens. So when you drive a flow to produce a report, **do**
|
|
255
|
+
`vk screenshot` around each significant transition (and before a risky or verification step)
|
|
256
|
+
— then leave it in the report, don't read the PNG back. A visual trail makes post-run review
|
|
257
|
+
far easier, and a failing step already auto-captures its own screen.
|
|
258
|
+
|
|
259
|
+
It is not free in wall clock, though: a capture is ~1.1s on a physical Android phone. That is
|
|
260
|
+
cheap next to a hierarchy read (~2.4s — see below) but it is not zero, so screenshot the
|
|
261
|
+
transitions worth reviewing rather than every step.
|
|
262
|
+
|
|
263
|
+
**Most of a run's time is reading the UI hierarchy.** Every selector command (`tap`, `text`,
|
|
264
|
+
`find`, `assert`, `swipe --on`) costs one read; measured on a physical mid-range Android
|
|
265
|
+
phone, one read is ~2.4s, nearly all of it fixed per-invocation cost inside `uiautomator`
|
|
266
|
+
that does not depend on how complex the screen is. iOS is ~10x cheaper. So prefer one
|
|
267
|
+
`vk assert` over a `vk ui` you have to scan, batch a known flow with `vk batch` rather than
|
|
268
|
+
re-checking between every step, and don't add a redundant `vk ui` just to confirm what an
|
|
269
|
+
`assert` already proved.
|
|
270
|
+
|
|
271
|
+
On Android this is handled for you: verikun keeps an accessibility connection alive on the
|
|
272
|
+
device (the *companion*), which cuts a read to ~0.2s. The first read on a device costs ~5.8s
|
|
273
|
+
to set it up, then every read after is fast. Nothing to enable.
|
|
274
|
+
|
|
275
|
+
It holds the device's single `UiAutomation` connection while it runs, so Appium and Layout
|
|
276
|
+
Inspector cannot attach. If the user needs those, tell them `VERIKUN_COMPANION=0` or
|
|
277
|
+
`vk companion stop` — don't disable it pre-emptively. A failure never breaks a run: verikun
|
|
278
|
+
falls back to the slower stock read on its own, and retries the fast path a minute later.
|
|
279
|
+
|
|
280
|
+
Over `--server` the same applies, but the setting lives on the **server**: reads execute
|
|
281
|
+
there, so `VERIKUN_COMPANION` is read in the server's environment and `vk companion` has no
|
|
282
|
+
`--server` form. If a remote run feels slow (~2.4s a step on Android), don't guess — ask:
|
|
283
|
+
`curl -s "$VERIKUN_SERVER/v1/health" | jq .reads` reports the read path and why. Every
|
|
284
|
+
`--server` run also prints it once at start.
|
|
258
285
|
|
|
259
286
|
**Remember identifiers across runs.** After a flow succeeds, save the selectors
|
|
260
287
|
you found to memory — the mapping from human intent to selector, plus the screen
|
package/CHANGELOG.md
CHANGED
|
@@ -6,7 +6,156 @@ All notable changes to this project are documented here. The format is based on
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.21.1] - 2026-08-13
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- **`npm link` produced a `vk` that died with "permission denied".** `tsc` writes
|
|
13
|
+
`dist/bin/verikun.js` as 0644 and nothing set the executable bit, so the global symlink
|
|
14
|
+
`npm link` creates pointed straight at a non-executable file. It never showed up for
|
|
15
|
+
`npm install -g verikun`, because npm chmods `bin` targets itself while unpacking a
|
|
16
|
+
tarball — only a source checkout was affected, which is to say only contributors, on a
|
|
17
|
+
fresh clone, with an error that reads like a broken install rather than a missing mode bit.
|
|
18
|
+
|
|
19
|
+
A `postbuild` step (`scripts/chmod-bin.mjs`) now chmods every path named in `package.json`'s
|
|
20
|
+
`bin`, reading them from there so a renamed entry point cannot silently be left behind. It
|
|
21
|
+
is silent on success on purpose: `npm pack --json` runs the `prepare` hook and then expects
|
|
22
|
+
its own JSON on stdout, so a chatty build step is parsed as part of the pack result and
|
|
23
|
+
takes the release gate down with it.
|
|
24
|
+
|
|
25
|
+
`scripts/check-package-contents.mjs` now also asserts the packed tarball's `bin` is
|
|
26
|
+
executable, not merely present — the mode is exactly the kind of thing that regresses
|
|
27
|
+
without anyone noticing, since `npm install -g` keeps working either way.
|
|
28
|
+
|
|
29
|
+
(Shipping in this release rather than one of its own — it landed on `main` unreleased.)
|
|
30
|
+
|
|
31
|
+
- **The companion no longer switches itself off for the rest of a long-lived process** — which
|
|
32
|
+
is why `vk server` got no speedup from 0.21.0 at all ([#77]). It engages there exactly as it
|
|
33
|
+
does locally; it just stopped at the first hiccup and never started again.
|
|
34
|
+
|
|
35
|
+
Measured on a Pixel 3a, one `vk server` process, the same 12-command flow each time:
|
|
36
|
+
|
|
37
|
+
| | assert avg | tap avg | flow total |
|
|
38
|
+
|---|--:|--:|--:|
|
|
39
|
+
| companion healthy | 0.33s | 0.89s | **7.7s** |
|
|
40
|
+
| companion dead — **was** | 3.35s | 3.52s | 42.6s |
|
|
41
|
+
| companion healthy again, same server — **was** | 3.33s | 3.49s | 42.5s |
|
|
42
|
+
| either case — **now** | 0.33s | 0.89s | **7.9s** |
|
|
43
|
+
|
|
44
|
+
The third row is the bug: a separate process had restarted the companion and `vk companion
|
|
45
|
+
status` read `ready app held` throughout, while the server sat next to it on the 2.4s path.
|
|
46
|
+
A stand-down was latched for the life of the process and `dims` was cached, so
|
|
47
|
+
`ensureReady()` — the only path that restarts the companion from its device note — became
|
|
48
|
+
unreachable after the first successful read. That is invisible when every command is its own
|
|
49
|
+
process (a fresh one recovers: measured 3.03s then 0.28s) and permanent in a daemon.
|
|
50
|
+
|
|
51
|
+
Three routine things reached it, all now retried after a minute rather than never:
|
|
52
|
+
the companion's **own 15-minute idle shutdown**, which any idle CI server outlives; a
|
|
53
|
+
**calibration mismatch**, which `calibrate()` already documents as usually just the screen
|
|
54
|
+
moving between two dumps; and a **released connection**, which the stock fallback causes by
|
|
55
|
+
design. Only facts that cannot change while the process runs — the device note saying
|
|
56
|
+
`unsupported`, or no jar to push — still stand it down for good.
|
|
57
|
+
|
|
58
|
+
- **A stale `UiAutomation` connection no longer reports an empty screen forever.** After
|
|
59
|
+
`vk launch` force-stops and restarts an app, the companion could return "null root" for a
|
|
60
|
+
window that was plainly there — measured at 30s+ on a Pixel 3a while a stock `uiautomator
|
|
61
|
+
dump` read the same screen fine. Since a null root is deliberately *not* a stand-down (it is
|
|
62
|
+
normally the device mid-launch, and releasing the connection for it would drop every
|
|
63
|
+
`launch --clear` onto the slow path), nothing recovered it: every selector command burned its
|
|
64
|
+
full auto-wait and exited 1 on a readable screen. This one failed tests rather than slowing
|
|
65
|
+
them.
|
|
66
|
+
|
|
67
|
+
A run of null roots now escalates by duration — propagate, then recycle the connection once
|
|
68
|
+
(release + re-acquire, ~1.05s, the thing measured to clear it), then fall back to the stock
|
|
69
|
+
path if that did not help. Being slow always beats failing a selector on a screen that is
|
|
70
|
+
there. The escalation is per-process, so it recovers inside any command that auto-waits
|
|
71
|
+
(`tap`, `assert`, `text`, `find`, `wait`) and inside `vk server`; a bare single-shot `vk ui`
|
|
72
|
+
has only one read and cannot, so it still reports no window until the next such command.
|
|
73
|
+
|
|
74
|
+
- **`vk server` hands the `UiAutomation` connection back on shutdown.** The companion outlives
|
|
75
|
+
the process that started it, so Ctrl-C used to leave Appium, Layout Inspector and TalkBack
|
|
76
|
+
locked out on that host for up to the full 15-minute idle window — with no obvious cause, and
|
|
77
|
+
no way to stop it from a `--server` client (`vk companion` has no `--server` form).
|
|
78
|
+
|
|
9
79
|
### Added
|
|
80
|
+
- **`vk server` says which read path it is using**, on startup (`[server] reads: companion
|
|
81
|
+
(ready app held)`) and as a `reads` field on `/v1/health`; a `--server` client echoes it once
|
|
82
|
+
at run start. Reads execute server-side, so this was the one end of the connection that knew
|
|
83
|
+
— and without it a companion that had silently stood down was indistinguishable from one that
|
|
84
|
+
never engaged, for a whole suite. Requested in [#77]; the field is optional, so an older
|
|
85
|
+
server simply omits it.
|
|
86
|
+
- **A `--server` suite index records the server's verikun version and read path** (`server: {
|
|
87
|
+
url, verikun, reads }` in `index.json`). It previously recorded only the client's version, so
|
|
88
|
+
a remote artifact could not say which verikun actually drove the device — the first thing you
|
|
89
|
+
need to explain a suite that got slower after a server upgrade.
|
|
90
|
+
|
|
91
|
+
[#77]: https://github.com/ddikman/verikun/issues/77
|
|
92
|
+
|
|
93
|
+
## [0.21.0] - 2026-08-13
|
|
94
|
+
|
|
95
|
+
### Added
|
|
96
|
+
- **An on-device companion that makes an Android UI-hierarchy read ~12x faster** — `vk ui`
|
|
97
|
+
goes from **2.44s to 0.18s** end to end on a physical SM-A415F, with **identical** output.
|
|
98
|
+
Reading the hierarchy is the dominant cost of any suite, so this moves the whole runtime,
|
|
99
|
+
not a rounding error. Every selector command benefits, not just `vk ui`: `find` 12.3x,
|
|
100
|
+
`assert` 12.0x, `wait` 11.5x, `tap` 8.1x, `text` 3.6x (the remainder is `adb shell input`,
|
|
101
|
+
which does not go through the companion yet).
|
|
102
|
+
|
|
103
|
+
**On by default; `VERIKUN_COMPANION=0` opts out.** It shipped opt-in first and that was
|
|
104
|
+
wrong — nobody discovers an environment variable they were never told about, and the
|
|
105
|
+
people who most need the speedup are the least likely to go looking for it.
|
|
106
|
+
|
|
107
|
+
It also makes auto-wait honour its window: a 5s wait for an element that never appears takes
|
|
108
|
+
~6.4s rather than ~10.8s, because the stock path's final 2.4s read starts just before the
|
|
109
|
+
deadline and overshoots.
|
|
110
|
+
|
|
111
|
+
The first read on a device costs ~6s to push, start and calibrate. The verdict is then
|
|
112
|
+
remembered **on the device** (`/data/local/tmp/verikun-companion.note`, keyed by verikun
|
|
113
|
+
version), so a restart after the 15-minute idle shutdown reuses it and costs ~2.1s. The
|
|
114
|
+
same note records a device the companion could not start on at all, so that phone falls
|
|
115
|
+
straight through to the stock read instead of paying a doomed startup on every command —
|
|
116
|
+
which is what makes being on by default safe rather than merely fast.
|
|
117
|
+
|
|
118
|
+
Profiling `adb shell uiautomator dump` showed only ~0.10s of its ~2.4s is work. The rest is
|
|
119
|
+
paid fresh every call: ~1.22s starting ART and loading `uiautomator.jar`, and ~1.00s in
|
|
120
|
+
`waitForIdle(1000, 10000)`. That second one is not a flat sleep — it waits for the
|
|
121
|
+
accessibility event stream to have been *quiet* for a second, and a freshly connected
|
|
122
|
+
bridge has no history of quiet, so it must observe one. A long-lived connection already
|
|
123
|
+
does. The companion therefore keeps the full idle semantics and still returns in
|
|
124
|
+
milliseconds; it is not trading safety for speed.
|
|
125
|
+
|
|
126
|
+
It is Java compiled to dex (4KB), pushed to `/data/local/tmp` and run by the phone's own
|
|
127
|
+
runtime via `app_process` — scrcpy's approach. **Nothing is installed**: no APK, no root,
|
|
128
|
+
package list untouched. It shuts itself down after 15 minutes idle. It does **not** cache
|
|
129
|
+
the hierarchy — every read walks the live tree; only the *connection* is kept, so the
|
|
130
|
+
"re-capture fresh every command" rule is intact. It borrows the platform's own serialiser,
|
|
131
|
+
so its XML is byte-identical to `uiautomator dump`'s.
|
|
132
|
+
|
|
133
|
+
**A device has exactly one `UiAutomation` connection and the companion holds it** — while
|
|
134
|
+
it runs, `uiautomator dump` is SIGKILLed, and Appium, Layout Inspector and TalkBack cannot
|
|
135
|
+
attach. That is the one real cost, and the reason for the opt-out: `VERIKUN_COMPANION=0`,
|
|
136
|
+
or `vk companion stop` to hand it back once. `vk companion status` reports it.
|
|
137
|
+
|
|
138
|
+
It cannot fail a test. Every failure route releases the connection *before* falling back,
|
|
139
|
+
because the stock path is not merely slower while the companion holds it — it is
|
|
140
|
+
unavailable. A dead companion frees the connection by dying; one that cannot be asked
|
|
141
|
+
nicely is killed. A fallback read costs ~3.4s against 2.4s if the companion had never
|
|
142
|
+
existed.
|
|
143
|
+
|
|
144
|
+
Before trusting it, verikun takes **one** real `uiautomator dump` and checks the companion
|
|
145
|
+
reproduces it byte for byte, then remembers the answer on the device. The dumper clips node
|
|
146
|
+
bounds to a display size, and **which size the platform uses changed between Android
|
|
147
|
+
versions** — measured on three devices: a Samsung SM-A415F and a Google Pixel 3a, both on
|
|
148
|
+
Android 12, clip to the app window; a Pixel 6 emulator on Android 14 clips to the physical
|
|
149
|
+
display. The boundary is in AOSP itself — `DumpCommand` reads `getSize()` on the
|
|
150
|
+
`android12-release` and `android13-release` branches and `getRealSize()` from
|
|
151
|
+
`android14-release` onward — so it is the platform version, not the vendor, and a
|
|
152
|
+
hard-coded choice is wrong on one side of it whichever side you pick. The gap (44–254px)
|
|
153
|
+
would not fail loudly: it would shift every element near the bottom of the screen and land
|
|
154
|
+
taps elsewhere while still reporting success. If neither candidate
|
|
155
|
+
matches, the companion is declined and the stock path is used.
|
|
156
|
+
|
|
157
|
+
New: `vk companion <status|stop>`, `VERIKUN_COMPANION`, `tools/` for on-device programs,
|
|
158
|
+
and a [companion guide](https://ddikman.github.io/verikun/guides/companion/).
|
|
10
159
|
- **A `Self-healing in CI` docs page, and a plan-cache step in the reference workflow.** The
|
|
11
160
|
question it answers came from a team running verikun in CI: *should it be allowed to heal a
|
|
12
161
|
drifted step, or should that just fail the build?* The answer was spread across five pages,
|
|
@@ -44,7 +193,85 @@ All notable changes to this project are documented here. The format is based on
|
|
|
44
193
|
|
|
45
194
|
No version bump: no CLI behaviour changed.
|
|
46
195
|
|
|
196
|
+
### Changed
|
|
197
|
+
- **Android screenshots are about twice as fast**, and byte-for-byte identical. `screencap -p`
|
|
198
|
+
makes the *phone* PNG-encode a full-resolution image, which we then immediately decode and
|
|
199
|
+
shrink — so the encode was pure waste. Captures now come off the device as raw pixels
|
|
200
|
+
(`screencap`, no `-p`) and are encoded here, at the size we actually keep. Measured on a
|
|
201
|
+
physical SM-A415F: **`vk screenshot` 2.60s → 1.12s**, and `--full` 2.60s → 1.19s. The larger
|
|
202
|
+
transfer is not the problem it looks like — 10MB of RGBA box-downscales in ~10ms and deflates
|
|
203
|
+
in ~110ms on the host, against ~1.4s of on-device encoding avoided. Verified on-device: the
|
|
204
|
+
PNG the old and new paths produce is identical byte-for-byte.
|
|
205
|
+
|
|
206
|
+
This is a new **optional** `Driver.screenshotRaw()`, and `null` is a first-class answer: a
|
|
207
|
+
backend without a raw path (iOS — `simctl` is already ~0.2s, so there is nothing to win) or a
|
|
208
|
+
capture whose header we do not recognise falls back to `screenshot()`. Failure-evidence
|
|
209
|
+
captures take the same route and stay full-resolution. An OEM laying the framebuffer out
|
|
210
|
+
differently gets the old speed, never a wrong image.
|
|
211
|
+
|
|
47
212
|
### Fixed
|
|
213
|
+
- **Several `vk` processes starting on one device at once could fail outright.** Calibration
|
|
214
|
+
works by *releasing* the device's single `UiAutomation` connection to take a real
|
|
215
|
+
`uiautomator dump` — so two processes calibrating at once SIGKILL each other's dump.
|
|
216
|
+
MEASURED: five concurrent first-ever reads on one device gave exit codes `[3,3,0,3,3]`,
|
|
217
|
+
each reporting `Killed`.
|
|
218
|
+
|
|
219
|
+
Calibration is now claimed, and the claim is granted by the **companion itself**, whose
|
|
220
|
+
single-threaded accept loop makes it genuinely atomic. A process that does not get the
|
|
221
|
+
claim waits for the verdict and never touches the connection meanwhile — waiting processes
|
|
222
|
+
used to "helpfully" acquire it during the window the holder had deliberately released it,
|
|
223
|
+
killing the very dump they were waiting for. A claim from a process that then dies goes
|
|
224
|
+
stale after 45s, so nothing can wedge a device permanently.
|
|
225
|
+
|
|
226
|
+
The obvious host-side lock does **not** work and is worth recording: Android's toybox
|
|
227
|
+
`mkdir` **succeeds on an existing directory** (exit `0`, unlike POSIX), so a `mkdir` mutex
|
|
228
|
+
silently grants itself to every caller. Five concurrent attempts all reported success.
|
|
229
|
+
After the fix, five concurrent first-ever reads are `[0,0,0,0,0]`.
|
|
230
|
+
|
|
231
|
+
- **A transient or local problem could permanently mark a device as unable to run the
|
|
232
|
+
companion.** That verdict is sticky — every later command on that phone would skip to the
|
|
233
|
+
2.4s path — and two things could write it wrongly: a checkout that had not built the jar
|
|
234
|
+
(a fault of *this* working copy, not of the device), and a startup collision (transient,
|
|
235
|
+
and usually leaving a perfectly good companion running). It is now only recorded when the
|
|
236
|
+
jar exists *and* a second look still finds nothing. `start()` also re-probes before
|
|
237
|
+
stopping anything, so two racing processes can no longer kill each other's companion.
|
|
238
|
+
|
|
239
|
+
- **A command with a 120-second wait budget could abort after 20, because the app had not
|
|
240
|
+
drawn yet.** `launch` force-stops the app before starting it (and `--clear` wipes its data
|
|
241
|
+
too), leaving a gap of a second or two with no window at all. The platform correctly
|
|
242
|
+
reports a null root for that gap, and the capture layer escalated it to a fatal environment
|
|
243
|
+
error (exit `3`) after three attempts — throwing away a budget the caller had explicitly
|
|
244
|
+
asked for. Measured before the fix: `wait --timeout 120000` aborted at ~20s on roughly half
|
|
245
|
+
of the runs, with ~100 seconds unspent.
|
|
246
|
+
|
|
247
|
+
A null root is now `NoWindowError`: an observation about the screen, not a broken machine.
|
|
248
|
+
Every polling caller — `wait`, `find`, `assert`, `tap`, `text`, and the `vk ai` engine's
|
|
249
|
+
guards — treats it as "nothing on screen yet" and keeps polling to its own deadline. Every
|
|
250
|
+
*other* capture failure still surfaces immediately, because a missing adb or a wedged
|
|
251
|
+
dumper is a machine to fix and polling it for two minutes helps nobody. A caller with no
|
|
252
|
+
wait budget (a bare `vk ui`) still gets exit `3`, unchanged.
|
|
253
|
+
|
|
254
|
+
The companion made this visible rather than causing it: with the stock dump's 2.4s latency
|
|
255
|
+
the three attempts spanned 7-14s and usually outlasted the gap by accident, so the bug was
|
|
256
|
+
being masked by being slow. It also no longer stands the companion *down* for a null root —
|
|
257
|
+
that is the device's state, not a companion fault, and releasing the connection dropped the
|
|
258
|
+
whole process onto the slow path for the rest of its life. After the fix: 0/5 aborts, and
|
|
259
|
+
the companion stays connected across `launch --clear`.
|
|
260
|
+
|
|
261
|
+
- **A failed `uiautomator dump` could silently return the PREVIOUS screen.** `dumpXml` ran
|
|
262
|
+
`uiautomator dump <path>` and then `cat <path>`, accepting anything containing
|
|
263
|
+
`<hierarchy>` — but it never checked that *this* dump had written the file. The dump writes
|
|
264
|
+
to a fixed path and leaves the last successful result there whenever it fails, and it fails
|
|
265
|
+
without saying so in its exit code: AOSP's `DumpCommand` prints `ERROR: could not get idle
|
|
266
|
+
state` and returns normally when `waitForIdle` times out on an animating screen. The stale
|
|
267
|
+
XML is perfectly well-formed, so every check passed and the caller resolved selectors —
|
|
268
|
+
and tapped coordinates — from a screen that could be minutes old. Measured: a dump killed
|
|
269
|
+
at 18:46 happily served the 18:44 hierarchy.
|
|
270
|
+
|
|
271
|
+
The file is now removed in the same device shell immediately before the dump, so `cat` can
|
|
272
|
+
only succeed if this dump produced it; a genuinely failed capture now fails loudly (exit
|
|
273
|
+
`3`) instead of quietly lying. Pre-existing — the companion below made it easy to hit, and
|
|
274
|
+
is how it was found.
|
|
48
275
|
- **The release gate stopped understanding `npm pack --json`, which blocked the 0.20.0
|
|
49
276
|
publish.** `scripts/check-package-contents.mjs` read the pack result as
|
|
50
277
|
`JSON.parse(raw)[0]`, but **npm 12 returns an object keyed by package name** where npm ≤ 11
|
package/dist/bin/verikun.js
CHANGED
|
File without changes
|
package/dist/capture.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Screen capture: the one place that decides HOW a screenshot is obtained.
|
|
3
|
+
//
|
|
4
|
+
// Sits between the driver (device I/O) and image.ts (pure image math) because it
|
|
5
|
+
// needs both, and lives apart from cli.ts so run.ts's failure-evidence capture can
|
|
6
|
+
// share it without an import cycle.
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.capturePng = capturePng;
|
|
9
|
+
const image_1 = require("./image");
|
|
10
|
+
/**
|
|
11
|
+
* Capture the screen as a PNG whose longest edge is at most `maxEdge` px
|
|
12
|
+
* (`null` = full size). `buf` is always the image to write.
|
|
13
|
+
*
|
|
14
|
+
* Prefers the driver's raw path — pixels straight off the device, PNG-encoded here —
|
|
15
|
+
* which avoids an on-device encode of an image we are about to shrink anyway, and is
|
|
16
|
+
* roughly 2x faster end to end on Android. A backend without a raw path, or one whose
|
|
17
|
+
* capture came back in a shape we do not recognize, falls back to `screenshot()`, so
|
|
18
|
+
* the only observable difference is how long it took.
|
|
19
|
+
*/
|
|
20
|
+
function capturePng(driver, maxEdge) {
|
|
21
|
+
const raw = driver.screenshotRaw?.() ?? null;
|
|
22
|
+
if (raw)
|
|
23
|
+
return (0, image_1.pngFromRaw)(raw, maxEdge);
|
|
24
|
+
const png = driver.screenshot();
|
|
25
|
+
if (maxEdge === null) {
|
|
26
|
+
return { buf: png, width: 0, height: 0, scaled: false, origWidth: 0, origHeight: 0, reason: 'full size requested' };
|
|
27
|
+
}
|
|
28
|
+
return (0, image_1.downscalePng)(png, maxEdge);
|
|
29
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -67,7 +67,8 @@ const format_1 = require("./ui/format");
|
|
|
67
67
|
const viewport_1 = require("./ui/viewport");
|
|
68
68
|
const output_1 = require("./output");
|
|
69
69
|
const run_1 = require("./run");
|
|
70
|
-
const
|
|
70
|
+
const capture_1 = require("./capture");
|
|
71
|
+
const manager_1 = require("./companion/manager");
|
|
71
72
|
const engine_1 = require("./agent/engine");
|
|
72
73
|
const lint_1 = require("./agent/lint");
|
|
73
74
|
const claude_1 = require("./agent/claude");
|
|
@@ -182,6 +183,29 @@ function pollStep(flags, deadline) {
|
|
|
182
183
|
const interval = (0, args_1.flagNum)(flags, 'interval') ?? DEFAULT_POLL_MS;
|
|
183
184
|
return Math.min(interval, Math.max(0, deadline - Date.now()));
|
|
184
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* Read the hierarchy for a caller that is polling, treating "no window yet" as "nothing on
|
|
188
|
+
* screen yet" rather than a fatal environment error.
|
|
189
|
+
*
|
|
190
|
+
* A `NoWindowError` means the device genuinely had nothing to show — `launch --clear` and
|
|
191
|
+
* `launch` both leave a gap where the app has been stopped and has not drawn. That clears in
|
|
192
|
+
* a second or two, so a caller that has a wait budget should keep polling; escalating to
|
|
193
|
+
* exit 3 throws away the budget it was explicitly given. MEASURED: a `wait --timeout 120000`
|
|
194
|
+
* used to abort at ~20s with 100 seconds unspent.
|
|
195
|
+
*
|
|
196
|
+
* Every OTHER capture failure still propagates untouched — a missing adb, an unauthorised
|
|
197
|
+
* device or a wedged dumper is a machine to fix, and polling it for two minutes helps nobody.
|
|
198
|
+
*/
|
|
199
|
+
function readForPoll(ctx, opts = {}) {
|
|
200
|
+
try {
|
|
201
|
+
return ctx.driver.getElements(opts);
|
|
202
|
+
}
|
|
203
|
+
catch (e) {
|
|
204
|
+
if (e instanceof errors_1.NoWindowError)
|
|
205
|
+
return [];
|
|
206
|
+
throw e;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
185
209
|
/**
|
|
186
210
|
* matchElements with auto-wait: re-capture + re-match until at least one element
|
|
187
211
|
* matches or the window elapses. Returns the final result either way (empty on miss).
|
|
@@ -189,7 +213,7 @@ function pollStep(flags, deadline) {
|
|
|
189
213
|
async function matchWaiting(ctx, sel, opts = {}) {
|
|
190
214
|
const deadline = Date.now() + waitWindowMs(ctx.flags);
|
|
191
215
|
for (;;) {
|
|
192
|
-
const res = (0, selector_1.matchElements)(ctx
|
|
216
|
+
const res = (0, selector_1.matchElements)(readForPoll(ctx, opts), sel);
|
|
193
217
|
if (res.matches.length > 0 || Date.now() >= deadline)
|
|
194
218
|
return res;
|
|
195
219
|
await sleep(pollStep(ctx.flags, deadline));
|
|
@@ -205,7 +229,7 @@ async function resolveOneWaiting(ctx, sel, opts = {}) {
|
|
|
205
229
|
const start = Date.now();
|
|
206
230
|
const deadline = start + windowMs;
|
|
207
231
|
for (;;) {
|
|
208
|
-
const els = ctx
|
|
232
|
+
const els = readForPoll(ctx, opts);
|
|
209
233
|
if ((0, selector_1.matchElements)(els, sel).matches.length >= 1) {
|
|
210
234
|
const { element, tier } = (0, selector_1.resolveOne)(els, sel); // 1 → resolved; >1 → throws ambiguity
|
|
211
235
|
// The snapshot rides along: scroll-into-view needs the scrollable containers
|
|
@@ -731,32 +755,31 @@ function assertSafeAppId(appId) {
|
|
|
731
755
|
return appId;
|
|
732
756
|
}
|
|
733
757
|
function cmdScreenshot(ctx) {
|
|
734
|
-
const raw = ctx.driver.screenshot();
|
|
735
758
|
// Precedence: --full (original) > --max <px> (explicit) > --more (preset) > default.
|
|
736
759
|
const maxEdge = (0, args_1.flagNum)(ctx.flags, 'max') ?? ((0, args_1.flagBool)(ctx.flags, 'more') ? MORE_SHOT_MAX_EDGE : shotMaxEdge());
|
|
737
|
-
const res = (0, args_1.flagBool)(ctx.flags, 'full') ? null :
|
|
738
|
-
const buf = res
|
|
760
|
+
const res = (0, capture_1.capturePng)(ctx.driver, (0, args_1.flagBool)(ctx.flags, 'full') ? null : maxEdge);
|
|
761
|
+
const buf = res.buf;
|
|
739
762
|
const outFlag = (0, args_1.flagStr)(ctx.flags, 'out');
|
|
740
763
|
const path = outFlag ? confineToCwd(outFlag) : (0, output_1.defaultScreenshotPath)();
|
|
741
764
|
(0, node_fs_1.writeFileSync)(path, buf);
|
|
742
765
|
ctx.record?.attachImage(buf);
|
|
743
|
-
ctx.record?.note({ message: res
|
|
766
|
+
ctx.record?.note({ message: res.scaled ? `${path} (${res.width}×${res.height})` : path });
|
|
744
767
|
// Surface the one case worth knowing about: we wanted to shrink but couldn't.
|
|
745
|
-
if (
|
|
768
|
+
if (!res.scaled && res.reason?.startsWith('unsupported')) {
|
|
746
769
|
(0, output_1.err)(`screenshot not downscaled: ${res.reason}`);
|
|
747
770
|
}
|
|
748
771
|
if ((0, args_1.flagBool)(ctx.flags, 'json')) {
|
|
749
772
|
(0, output_1.json)({
|
|
750
773
|
path,
|
|
751
774
|
bytes: buf.length,
|
|
752
|
-
...(res
|
|
775
|
+
...(res.scaled
|
|
753
776
|
? { width: res.width, height: res.height, scaledFrom: { width: res.origWidth, height: res.origHeight } }
|
|
754
777
|
: {}),
|
|
755
778
|
});
|
|
756
779
|
}
|
|
757
780
|
else {
|
|
758
781
|
(0, output_1.out)(path);
|
|
759
|
-
if (res
|
|
782
|
+
if (res.scaled)
|
|
760
783
|
(0, output_1.err)(`scaled ${res.origWidth}×${res.origHeight} -> ${res.width}×${res.height} (max edge ${maxEdge}px; --more for detail, --full for original)`);
|
|
761
784
|
}
|
|
762
785
|
return 0;
|
|
@@ -819,7 +842,7 @@ async function cmdWait(ctx) {
|
|
|
819
842
|
const interval = (0, args_1.flagNum)(ctx.flags, 'interval') ?? 400;
|
|
820
843
|
const deadline = Date.now() + timeout;
|
|
821
844
|
while (Date.now() < deadline) {
|
|
822
|
-
const { matches, tier } = (0, selector_1.matchElements)(ctx
|
|
845
|
+
const { matches, tier } = (0, selector_1.matchElements)(readForPoll(ctx), sel);
|
|
823
846
|
if (gone ? matches.length === 0 : matches.length > 0) {
|
|
824
847
|
ctx.record?.note({ selector: sel, tier, element: matches[0], message: gone ? 'gone' : `${matches.length} match(es)` });
|
|
825
848
|
if (gone)
|
|
@@ -877,10 +900,10 @@ async function cmdAssert(ctx) {
|
|
|
877
900
|
// Auto-wait subsumes the common "wait then assert": poll until the assertion
|
|
878
901
|
// passes or the window elapses. `--gone` therefore waits for disappearance.
|
|
879
902
|
const deadline = Date.now() + waitWindowMs(ctx.flags);
|
|
880
|
-
let result = evalAssert(ctx
|
|
903
|
+
let result = evalAssert(readForPoll(ctx), sel, ctx.flags);
|
|
881
904
|
while (!result.pass && Date.now() < deadline) {
|
|
882
905
|
await sleep(pollStep(ctx.flags, deadline));
|
|
883
|
-
result = evalAssert(ctx
|
|
906
|
+
result = evalAssert(readForPoll(ctx), sel, ctx.flags);
|
|
884
907
|
}
|
|
885
908
|
const { pass, reason, matches } = result;
|
|
886
909
|
ctx.record?.note({ selector: sel, element: matches[0], message: `${pass ? 'PASS' : 'FAIL'} — ${reason}` });
|
|
@@ -1550,7 +1573,7 @@ async function resolveBackend(platform, device, flags) {
|
|
|
1550
1573
|
// vice versa), and neither is allowed to derail recording the failure.
|
|
1551
1574
|
const out = {};
|
|
1552
1575
|
try {
|
|
1553
|
-
out.png =
|
|
1576
|
+
out.png = (0, capture_1.capturePng)(driver, null).buf;
|
|
1554
1577
|
}
|
|
1555
1578
|
catch {
|
|
1556
1579
|
/* device may be gone — that may be why we failed */
|
|
@@ -1580,6 +1603,12 @@ async function resolveBackend(platform, device, flags) {
|
|
|
1580
1603
|
const health = await (0, remote_1.pingServer)(opts); // fails fast (exit 3) on a bad URL or key
|
|
1581
1604
|
runCtx = { platform: health.platform, device: health.serial };
|
|
1582
1605
|
(0, output_1.err)(`[verikun] server ${server}: ${health.platform} · device ${health.serial} · verikun ${health.version}`);
|
|
1606
|
+
// Say the read path once, here. Reads execute server-side, so this is the only end of the
|
|
1607
|
+
// connection that knows it — and without it a companion that had silently stood down was
|
|
1608
|
+
// indistinguishable from one that never engaged, for a whole suite (issue #77). An older
|
|
1609
|
+
// server omits the field; saying nothing is better than guessing.
|
|
1610
|
+
if (health.reads)
|
|
1611
|
+
(0, output_1.err)(`[verikun] server reads: ${health.reads.path} (${health.reads.detail})`);
|
|
1583
1612
|
const remote = (0, remote_1.createRemoteBackend)(opts, health);
|
|
1584
1613
|
return {
|
|
1585
1614
|
backend: {
|
|
@@ -1607,7 +1636,7 @@ async function resolveBackend(platform, device, flags) {
|
|
|
1607
1636
|
},
|
|
1608
1637
|
platform: health.platform,
|
|
1609
1638
|
device: health.serial,
|
|
1610
|
-
remote: { url: server, version: health.version },
|
|
1639
|
+
remote: { url: server, version: health.version, reads: health.reads },
|
|
1611
1640
|
};
|
|
1612
1641
|
}
|
|
1613
1642
|
/**
|
|
@@ -1911,7 +1940,7 @@ async function cmdSuiteEntry(positionals, flags) {
|
|
|
1911
1940
|
throw new errors_1.CliError(`${providerRequirement(opts.model)} — needed to compile/repair tests (model ${opts.model}).`, 3);
|
|
1912
1941
|
}
|
|
1913
1942
|
const reqPlatform = platformFromFlags(flags);
|
|
1914
|
-
const { backend, platform, device } = await resolveBackend(reqPlatform, deviceFromFlags(flags, reqPlatform), flags);
|
|
1943
|
+
const { backend, platform, device, remote } = await resolveBackend(reqPlatform, deviceFromFlags(flags, reqPlatform), flags);
|
|
1915
1944
|
const app = (0, args_1.flagStr)(flags, 'app');
|
|
1916
1945
|
if (app)
|
|
1917
1946
|
assertSafeAppId(app);
|
|
@@ -1919,6 +1948,9 @@ async function cmdSuiteEntry(positionals, flags) {
|
|
|
1919
1948
|
return await (0, suite_1.cmdSuite)(dirArg, flags, {
|
|
1920
1949
|
platform,
|
|
1921
1950
|
device,
|
|
1951
|
+
...(remote
|
|
1952
|
+
? { server: { url: remote.url, verikun: remote.version, reads: remote.reads?.path } }
|
|
1953
|
+
: {}),
|
|
1922
1954
|
runTest: (file) => runAiTest(file, opts, backend, platform, device),
|
|
1923
1955
|
// Reset app state between tests only when the app id is known; without --app,
|
|
1924
1956
|
// each test is responsible for its own isolation (e.g. `launch --clear`).
|
|
@@ -1933,6 +1965,48 @@ async function cmdSuiteEntry(positionals, flags) {
|
|
|
1933
1965
|
}
|
|
1934
1966
|
// ---------------------------------------------------------------------------
|
|
1935
1967
|
// Dispatch
|
|
1968
|
+
/**
|
|
1969
|
+
* Inspect or stop the on-device companion (`tools/verikun-companion`).
|
|
1970
|
+
*
|
|
1971
|
+
* `stop` exists because the companion holds the device's ONE UiAutomation connection for as
|
|
1972
|
+
* long as it runs, which locks out Appium, Layout Inspector and a second verikun. Without a
|
|
1973
|
+
* way to hand that back, the only recourse would be `adb shell pkill`.
|
|
1974
|
+
*/
|
|
1975
|
+
function cmdCompanion(ctx) {
|
|
1976
|
+
const action = ctx.positionals[0] ?? 'status';
|
|
1977
|
+
if (action !== 'status' && action !== 'stop') {
|
|
1978
|
+
throw new errors_1.CliError(`Usage: verikun companion <status|stop>`, 2);
|
|
1979
|
+
}
|
|
1980
|
+
if (ctx.platform !== 'android') {
|
|
1981
|
+
throw new errors_1.CliError('The companion is Android-only; iOS reads the hierarchy through idb, which is already fast.', 3);
|
|
1982
|
+
}
|
|
1983
|
+
const companion = new manager_1.Companion({
|
|
1984
|
+
adb: process.env.ADB || 'adb',
|
|
1985
|
+
serial: ctx.driver.resolvedSerial(),
|
|
1986
|
+
// `status`/`stop` never calibrate, so this is unreachable — but a throwing stub is
|
|
1987
|
+
// honest about that, where a silent no-op would hide a future miswiring.
|
|
1988
|
+
stockDump: () => {
|
|
1989
|
+
throw new errors_1.CliError('the companion command never calibrates', 3);
|
|
1990
|
+
},
|
|
1991
|
+
});
|
|
1992
|
+
if (action === 'stop') {
|
|
1993
|
+
companion.stop();
|
|
1994
|
+
if ((0, args_1.flagBool)(ctx.flags, 'json'))
|
|
1995
|
+
(0, output_1.json)({ companion: 'stopped' });
|
|
1996
|
+
else
|
|
1997
|
+
(0, output_1.out)('companion stopped');
|
|
1998
|
+
return 0;
|
|
1999
|
+
}
|
|
2000
|
+
const state = companion.describe();
|
|
2001
|
+
if ((0, args_1.flagBool)(ctx.flags, 'json'))
|
|
2002
|
+
(0, output_1.json)({ companion: state, enabled: (0, manager_1.companionEnabled)() });
|
|
2003
|
+
else {
|
|
2004
|
+
(0, output_1.out)(state);
|
|
2005
|
+
if (!(0, manager_1.companionEnabled)())
|
|
2006
|
+
(0, output_1.err)('note: disabled by VERIKUN_COMPANION — hierarchy reads use the slower stock dump');
|
|
2007
|
+
}
|
|
2008
|
+
return 0;
|
|
2009
|
+
}
|
|
1936
2010
|
// ---------------------------------------------------------------------------
|
|
1937
2011
|
async function executeCommand(command, ctx) {
|
|
1938
2012
|
switch (command) {
|
|
@@ -1940,6 +2014,8 @@ async function executeCommand(command, ctx) {
|
|
|
1940
2014
|
return cmdDevices(ctx);
|
|
1941
2015
|
case 'doctor':
|
|
1942
2016
|
return cmdDoctor(ctx);
|
|
2017
|
+
case 'companion':
|
|
2018
|
+
return cmdCompanion(ctx);
|
|
1943
2019
|
case 'ui':
|
|
1944
2020
|
case 'dump':
|
|
1945
2021
|
return cmdUi(ctx);
|
|
@@ -2280,6 +2356,8 @@ ENVIRONMENT
|
|
|
2280
2356
|
Claude Code plugin is out of date (a warning only —
|
|
2281
2357
|
it never changes the exit code). --fix disables
|
|
2282
2358
|
animations. VERIKUN_NO_UPDATE_CHECK skips the check
|
|
2359
|
+
companion <status|stop> [--json] On-device hierarchy reader (Android, on by default;
|
|
2360
|
+
VERIKUN_COMPANION=0 opts out)
|
|
2283
2361
|
|
|
2284
2362
|
TEST RUNS (actions are recorded; a run auto-starts on first action)
|
|
2285
2363
|
run start [name] [--force] Begin a named run (else one starts implicitly)
|