verikun 0.21.0 → 0.22.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 +14 -3
- package/CHANGELOG.md +118 -0
- package/README.md +2 -2
- package/dist/bin/verikun.js +0 -0
- package/dist/cli.js +117 -10
- package/dist/companion/dump-match.js +100 -0
- package/dist/companion/manager.js +220 -44
- package/dist/companion/protocol.js +10 -4
- package/dist/device/claims.js +594 -0
- package/dist/drivers/adb.js +40 -6
- package/dist/drivers/ios.js +30 -7
- package/dist/errors.js +2 -2
- package/dist/run.js +10 -0
- package/dist/server.js +33 -0
- package/dist/suite.js +1 -0
- package/dist/version.js +1 -1
- package/package.json +2 -1
- package/tools/verikun-companion/prebuilt/verikun-companion.jar +0 -0
|
@@ -275,7 +275,13 @@ to set it up, then every read after is fast. Nothing to enable.
|
|
|
275
275
|
It holds the device's single `UiAutomation` connection while it runs, so Appium and Layout
|
|
276
276
|
Inspector cannot attach. If the user needs those, tell them `VERIKUN_COMPANION=0` or
|
|
277
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.
|
|
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.
|
|
279
285
|
|
|
280
286
|
**Remember identifiers across runs.** After a flow succeeds, save the selectors
|
|
281
287
|
you found to memory — the mapping from human intent to selector, plus the screen
|
|
@@ -476,7 +482,7 @@ draft-first flow.
|
|
|
476
482
|
- `0` success / found / assertion passed
|
|
477
483
|
- `1` not found / assertion failed / wait timeout
|
|
478
484
|
- `2` usage error **or ambiguous selector** (refine it or add `--index N`)
|
|
479
|
-
- `3` environment error (no device, adb/idb missing, hierarchy dump failed) — for
|
|
485
|
+
- `3` environment error (no usable device, adb/idb missing, hierarchy dump failed) — for
|
|
480
486
|
`ai`/`suite`/`install`/`server` the toolchain is verified up front, so this arrives
|
|
481
487
|
immediately with an install hint rather than mid-flow
|
|
482
488
|
|
|
@@ -492,7 +498,12 @@ draft-first flow.
|
|
|
492
498
|
- **`vk device set` from a plain shell stays applied.** Inside `batch`/`ai`/`suite` it is
|
|
493
499
|
restored automatically even if the flow dies, but a one-off `vk device set airplane=on`
|
|
494
500
|
is yours to `vk device reset` — don't strand someone's phone offline.
|
|
495
|
-
- **
|
|
501
|
+
- **The device picks itself, and won't collide with another agent.** With several attached,
|
|
502
|
+
`vk` takes one no other job is driving and notes it on stderr. Exit `2` means every device
|
|
503
|
+
is genuinely busy (the message names each holder) — pass `-d <serial>`, wait, or
|
|
504
|
+
`vk device release <serial>` if that job is gone. `vk devices` shows who holds what.
|
|
505
|
+
Naming a busy device with `-d` is refused, on purpose: two jobs on one phone corrupt both
|
|
506
|
+
runs, and it surfaces as a bogus assertion failure. `VERIKUN_NO_CLAIM=1` opts out entirely.
|
|
496
507
|
- **`vk text` opens the keyboard.** Use `--enter` to submit, or `vk back` to
|
|
497
508
|
dismiss it before re-inspecting (it can cover elements).
|
|
498
509
|
- **Unicode/emoji** may not type via `adb input text` (an Android limitation);
|
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,124 @@ All notable changes to this project are documented here. The format is based on
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.22.1] - 2026-08-15
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- **The Flutter fixture raises a real system permission dialog** (`@vk_permission`, Android
|
|
13
|
+
only), so [#79] reproduces on demand.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- **Hierarchy reads now see a window another app opens on top** — a runtime-permission dialog is
|
|
17
|
+
no longer invisible to the companion. ([#79])
|
|
18
|
+
|
|
19
|
+
[#79]: https://github.com/ddikman/verikun/issues/79
|
|
20
|
+
|
|
21
|
+
## [0.22.0] - 2026-08-15
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
- **Device claims**: `vk` now picks a device no other job is driving; a busy one is refused
|
|
25
|
+
with exit `2`. Writes `~/.verikun/devices/`; `VERIKUN_NO_CLAIM=1` opts out. ([#49])
|
|
26
|
+
- **`vk devices`**: a `USED BY` column names the job holding each device; `--json` gains
|
|
27
|
+
`claim`. ([#49])
|
|
28
|
+
- **`vk device release [serial]`**: hands a device back without waiting for its claim to go
|
|
29
|
+
stale. ([#49])
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
- **Several attached devices no longer exits `2`** — a free one is auto-selected. Pass
|
|
33
|
+
`--device` to pin it, as before. ([#49])
|
|
34
|
+
- **Changelog entries are now one line each**, per the new `changelog-entry` skill — root cause,
|
|
35
|
+
benchmarks and rationale move to the PR body and `docs/`.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
- **Failure evidence**: a step that could not resolve a device printed its error three times;
|
|
39
|
+
capture is now skipped when there is no screen to photograph.
|
|
40
|
+
|
|
41
|
+
[#49]: https://github.com/ddikman/verikun/issues/49
|
|
42
|
+
|
|
43
|
+
## [0.21.1] - 2026-08-13
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
- **`npm link` produced a `vk` that died with "permission denied".** `tsc` writes
|
|
47
|
+
`dist/bin/verikun.js` as 0644 and nothing set the executable bit, so the global symlink
|
|
48
|
+
`npm link` creates pointed straight at a non-executable file. It never showed up for
|
|
49
|
+
`npm install -g verikun`, because npm chmods `bin` targets itself while unpacking a
|
|
50
|
+
tarball — only a source checkout was affected, which is to say only contributors, on a
|
|
51
|
+
fresh clone, with an error that reads like a broken install rather than a missing mode bit.
|
|
52
|
+
|
|
53
|
+
A `postbuild` step (`scripts/chmod-bin.mjs`) now chmods every path named in `package.json`'s
|
|
54
|
+
`bin`, reading them from there so a renamed entry point cannot silently be left behind. It
|
|
55
|
+
is silent on success on purpose: `npm pack --json` runs the `prepare` hook and then expects
|
|
56
|
+
its own JSON on stdout, so a chatty build step is parsed as part of the pack result and
|
|
57
|
+
takes the release gate down with it.
|
|
58
|
+
|
|
59
|
+
`scripts/check-package-contents.mjs` now also asserts the packed tarball's `bin` is
|
|
60
|
+
executable, not merely present — the mode is exactly the kind of thing that regresses
|
|
61
|
+
without anyone noticing, since `npm install -g` keeps working either way.
|
|
62
|
+
|
|
63
|
+
(Shipping in this release rather than one of its own — it landed on `main` unreleased.)
|
|
64
|
+
|
|
65
|
+
- **The companion no longer switches itself off for the rest of a long-lived process** — which
|
|
66
|
+
is why `vk server` got no speedup from 0.21.0 at all ([#77]). It engages there exactly as it
|
|
67
|
+
does locally; it just stopped at the first hiccup and never started again.
|
|
68
|
+
|
|
69
|
+
Measured on a Pixel 3a, one `vk server` process, the same 12-command flow each time:
|
|
70
|
+
|
|
71
|
+
| | assert avg | tap avg | flow total |
|
|
72
|
+
|---|--:|--:|--:|
|
|
73
|
+
| companion healthy | 0.33s | 0.89s | **7.7s** |
|
|
74
|
+
| companion dead — **was** | 3.35s | 3.52s | 42.6s |
|
|
75
|
+
| companion healthy again, same server — **was** | 3.33s | 3.49s | 42.5s |
|
|
76
|
+
| either case — **now** | 0.33s | 0.89s | **7.9s** |
|
|
77
|
+
|
|
78
|
+
The third row is the bug: a separate process had restarted the companion and `vk companion
|
|
79
|
+
status` read `ready app held` throughout, while the server sat next to it on the 2.4s path.
|
|
80
|
+
A stand-down was latched for the life of the process and `dims` was cached, so
|
|
81
|
+
`ensureReady()` — the only path that restarts the companion from its device note — became
|
|
82
|
+
unreachable after the first successful read. That is invisible when every command is its own
|
|
83
|
+
process (a fresh one recovers: measured 3.03s then 0.28s) and permanent in a daemon.
|
|
84
|
+
|
|
85
|
+
Three routine things reached it, all now retried after a minute rather than never:
|
|
86
|
+
the companion's **own 15-minute idle shutdown**, which any idle CI server outlives; a
|
|
87
|
+
**calibration mismatch**, which `calibrate()` already documents as usually just the screen
|
|
88
|
+
moving between two dumps; and a **released connection**, which the stock fallback causes by
|
|
89
|
+
design. Only facts that cannot change while the process runs — the device note saying
|
|
90
|
+
`unsupported`, or no jar to push — still stand it down for good.
|
|
91
|
+
|
|
92
|
+
- **A stale `UiAutomation` connection no longer reports an empty screen forever.** After
|
|
93
|
+
`vk launch` force-stops and restarts an app, the companion could return "null root" for a
|
|
94
|
+
window that was plainly there — measured at 30s+ on a Pixel 3a while a stock `uiautomator
|
|
95
|
+
dump` read the same screen fine. Since a null root is deliberately *not* a stand-down (it is
|
|
96
|
+
normally the device mid-launch, and releasing the connection for it would drop every
|
|
97
|
+
`launch --clear` onto the slow path), nothing recovered it: every selector command burned its
|
|
98
|
+
full auto-wait and exited 1 on a readable screen. This one failed tests rather than slowing
|
|
99
|
+
them.
|
|
100
|
+
|
|
101
|
+
A run of null roots now escalates by duration — propagate, then recycle the connection once
|
|
102
|
+
(release + re-acquire, ~1.05s, the thing measured to clear it), then fall back to the stock
|
|
103
|
+
path if that did not help. Being slow always beats failing a selector on a screen that is
|
|
104
|
+
there. The escalation is per-process, so it recovers inside any command that auto-waits
|
|
105
|
+
(`tap`, `assert`, `text`, `find`, `wait`) and inside `vk server`; a bare single-shot `vk ui`
|
|
106
|
+
has only one read and cannot, so it still reports no window until the next such command.
|
|
107
|
+
|
|
108
|
+
- **`vk server` hands the `UiAutomation` connection back on shutdown.** The companion outlives
|
|
109
|
+
the process that started it, so Ctrl-C used to leave Appium, Layout Inspector and TalkBack
|
|
110
|
+
locked out on that host for up to the full 15-minute idle window — with no obvious cause, and
|
|
111
|
+
no way to stop it from a `--server` client (`vk companion` has no `--server` form).
|
|
112
|
+
|
|
113
|
+
### Added
|
|
114
|
+
- **`vk server` says which read path it is using**, on startup (`[server] reads: companion
|
|
115
|
+
(ready app held)`) and as a `reads` field on `/v1/health`; a `--server` client echoes it once
|
|
116
|
+
at run start. Reads execute server-side, so this was the one end of the connection that knew
|
|
117
|
+
— and without it a companion that had silently stood down was indistinguishable from one that
|
|
118
|
+
never engaged, for a whole suite. Requested in [#77]; the field is optional, so an older
|
|
119
|
+
server simply omits it.
|
|
120
|
+
- **A `--server` suite index records the server's verikun version and read path** (`server: {
|
|
121
|
+
url, verikun, reads }` in `index.json`). It previously recorded only the client's version, so
|
|
122
|
+
a remote artifact could not say which verikun actually drove the device — the first thing you
|
|
123
|
+
need to explain a suite that got slower after a server upgrade.
|
|
124
|
+
|
|
125
|
+
[#77]: https://github.com/ddikman/verikun/issues/77
|
|
126
|
+
|
|
9
127
|
## [0.21.0] - 2026-08-13
|
|
10
128
|
|
|
11
129
|
### Added
|
package/README.md
CHANGED
|
@@ -62,10 +62,10 @@ There is also no need for an MCP here: verikun runs locally with all its depende
|
|
|
62
62
|
|---|---|
|
|
63
63
|
| **Getting started** | [Installation](https://ddikman.github.io/verikun/getting-started/installation/) · [Your first test](https://ddikman.github.io/verikun/getting-started/your-first-test/) · [Using it from an AI agent](https://ddikman.github.io/verikun/getting-started/using-from-an-agent/) |
|
|
64
64
|
| **Guides** | [Writing test cases](https://ddikman.github.io/verikun/guides/writing-test-cases/) · [Natural-language tests](https://ddikman.github.io/verikun/guides/natural-language-tests/) · [Suites](https://ddikman.github.io/verikun/guides/suites/) · [Remote devices & CI](https://ddikman.github.io/verikun/guides/remote-devices-and-ci/) · [iOS setup](https://ddikman.github.io/verikun/guides/ios-setup/) · [Platform support](https://ddikman.github.io/verikun/guides/platform-support/) · [Troubleshooting](https://ddikman.github.io/verikun/guides/troubleshooting/) |
|
|
65
|
-
| **Reference** | [Commands](https://ddikman.github.io/verikun/reference/commands/) · [Selectors](https://ddikman.github.io/verikun/reference/selectors/) · [Auto-wait](https://ddikman.github.io/verikun/reference/auto-wait/) · [Global flags](https://ddikman.github.io/verikun/reference/global-flags/) · [Exit codes](https://ddikman.github.io/verikun/reference/exit-codes/) · [Environment variables](https://ddikman.github.io/verikun/reference/environment-variables/) · [Reports & test runs](https://ddikman.github.io/verikun/reference/reports-and-test-runs/) · [Device state](https://ddikman.github.io/verikun/reference/device-state/) · [Screenshots](https://ddikman.github.io/verikun/reference/screenshots/) · [AI plans & models](https://ddikman.github.io/verikun/reference/ai-plans/) |
|
|
65
|
+
| **Reference** | [Commands](https://ddikman.github.io/verikun/reference/commands/) · [Selectors](https://ddikman.github.io/verikun/reference/selectors/) · [Auto-wait](https://ddikman.github.io/verikun/reference/auto-wait/) · [Global flags](https://ddikman.github.io/verikun/reference/global-flags/) · [Exit codes](https://ddikman.github.io/verikun/reference/exit-codes/) · [Environment variables](https://ddikman.github.io/verikun/reference/environment-variables/) · [Reports & test runs](https://ddikman.github.io/verikun/reference/reports-and-test-runs/) · [Device state](https://ddikman.github.io/verikun/reference/device-state/) · [Device claims](https://ddikman.github.io/verikun/reference/device-claims/) · [Screenshots](https://ddikman.github.io/verikun/reference/screenshots/) · [AI plans & models](https://ddikman.github.io/verikun/reference/ai-plans/) |
|
|
66
66
|
| **Internals** | [Architecture](https://ddikman.github.io/verikun/internals/architecture/) · [Core principles](https://ddikman.github.io/verikun/internals/core-principles/) · [Plan IR & the replay engine](https://ddikman.github.io/verikun/internals/plan-ir-and-engine/) · [Contracts](https://ddikman.github.io/verikun/internals/contracts/) · [Contributing](https://ddikman.github.io/verikun/internals/contributing/) |
|
|
67
67
|
|
|
68
|
-
**Exit codes**, since they are the machine contract everything else rests on: `0` success · `1` not found / assertion failed / timeout · `2` usage error
|
|
68
|
+
**Exit codes**, since they are the machine contract everything else rests on: `0` success · `1` not found / assertion failed / timeout · `2` usage error, ambiguous selector, or a device another job is driving · `3` environment error. Data goes to stdout; diagnostics to stderr. [Full contract](https://ddikman.github.io/verikun/reference/exit-codes/). Parallel agents share a host-level [device claim](https://ddikman.github.io/verikun/reference/device-claims/) so two jobs do not silently land on the same phone.
|
|
69
69
|
|
|
70
70
|
## Feedback — help improve verikun
|
|
71
71
|
|
package/dist/bin/verikun.js
CHANGED
|
File without changes
|
package/dist/cli.js
CHANGED
|
@@ -61,6 +61,7 @@ const exec_1 = require("./exec");
|
|
|
61
61
|
const drivers_1 = require("./drivers");
|
|
62
62
|
const adb_1 = require("./drivers/adb");
|
|
63
63
|
const settings_1 = require("./device/settings");
|
|
64
|
+
const claims_1 = require("./device/claims");
|
|
64
65
|
const selector_1 = require("./ui/selector");
|
|
65
66
|
const state_support_1 = require("./ui/state-support");
|
|
66
67
|
const format_1 = require("./ui/format");
|
|
@@ -404,6 +405,15 @@ function cmdDevices(ctx) {
|
|
|
404
405
|
catch (e) {
|
|
405
406
|
(0, output_1.err)(`devices: iOS backend unavailable (${e.message})`);
|
|
406
407
|
}
|
|
408
|
+
// Who is driving what. Read-only: listing the pool must never claim a device, which is
|
|
409
|
+
// what makes `vk devices` (and `vk doctor`) safe to run while surveying a busy host.
|
|
410
|
+
if ((0, claims_1.claimsEnabled)()) {
|
|
411
|
+
for (const d of allDevices) {
|
|
412
|
+
const claim = (0, claims_1.summarize)(d.serial);
|
|
413
|
+
if (claim)
|
|
414
|
+
d.claim = claim;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
407
417
|
if ((0, args_1.flagBool)(ctx.flags, 'json')) {
|
|
408
418
|
(0, output_1.json)(allDevices);
|
|
409
419
|
return 0;
|
|
@@ -431,6 +441,10 @@ function formatDeviceTable(devices) {
|
|
|
431
441
|
{ header: 'STATE', get: (d) => d.state },
|
|
432
442
|
{ header: 'MODEL', get: (d) => d.model ?? '', optional: true },
|
|
433
443
|
{ header: 'PRODUCT', get: (d) => d.product ?? '', optional: true },
|
|
444
|
+
// Optional like the rest, which is load-bearing twice over: nothing changes for a
|
|
445
|
+
// single-user host where no device is ever claimed, and `VERIKUN_NO_CLAIM=1` renders
|
|
446
|
+
// exactly the table it always did.
|
|
447
|
+
{ header: 'USED BY', get: (d) => d.claim?.by ?? '', optional: true },
|
|
434
448
|
{ header: 'NOTE', get: (d) => d.note ?? '', optional: true },
|
|
435
449
|
];
|
|
436
450
|
// Drop optional columns that no device populates (e.g. NOTE for an Android-only list).
|
|
@@ -493,17 +507,39 @@ async function cmdDoctor(ctx) {
|
|
|
493
507
|
return 3;
|
|
494
508
|
const devices = ctx.driver.listDevices();
|
|
495
509
|
const usable = devices.filter((d) => d.state === 'device');
|
|
510
|
+
const claims = (0, claims_1.claimsEnabled)();
|
|
511
|
+
// Read-only, like `vk devices`: doctor surveys the host, it never takes a device.
|
|
512
|
+
const heldByOther = (serial) => {
|
|
513
|
+
const c = claims ? (0, claims_1.summarize)(serial) : undefined;
|
|
514
|
+
return c && !c.mine ? c : undefined;
|
|
515
|
+
};
|
|
496
516
|
(0, output_1.out)(`devices: ${devices.length} attached, ${usable.length} usable`);
|
|
497
|
-
for (const d of devices)
|
|
498
|
-
(0,
|
|
517
|
+
for (const d of devices) {
|
|
518
|
+
const claim = claims ? (0, claims_1.summarize)(d.serial) : undefined;
|
|
519
|
+
(0, output_1.out)(` ${d.serial} ${d.state}${d.model ? ` (${d.model})` : ''}${claim ? ` [${claim.by}]` : ''}`);
|
|
520
|
+
}
|
|
499
521
|
let ok = true;
|
|
500
|
-
|
|
501
|
-
|
|
522
|
+
const free = usable.filter((d) => !heldByOther(d.serial));
|
|
523
|
+
if (!usable.length) {
|
|
524
|
+
(0, output_1.err)(' -> no usable device');
|
|
525
|
+
ok = false;
|
|
526
|
+
}
|
|
527
|
+
else if (!ctx.device && !free.length) {
|
|
528
|
+
// Every device is busy. Advisory-shaped but genuinely blocking, so it still fails:
|
|
529
|
+
// an interaction command run right now would exit 2, and doctor exists to say so first.
|
|
530
|
+
(0, output_1.err)(' -> every attached device is claimed by another job — `verikun devices` shows who');
|
|
502
531
|
ok = false;
|
|
503
532
|
}
|
|
504
|
-
if (usable.length
|
|
533
|
+
else if (!ctx.device && !claims && usable.length > 1) {
|
|
534
|
+
(0, output_1.err)(' -> multiple devices: pass --device for interaction commands');
|
|
535
|
+
ok = false;
|
|
536
|
+
}
|
|
537
|
+
// Check the device an interaction command would actually land on: the one named, else
|
|
538
|
+
// the first FREE one (which is what auto-selection picks), not merely the first attached.
|
|
539
|
+
const target = ctx.device || free[0]?.serial;
|
|
540
|
+
if (target) {
|
|
505
541
|
try {
|
|
506
|
-
const serial =
|
|
542
|
+
const serial = target;
|
|
507
543
|
const keys = ['window_animation_scale', 'transition_animation_scale', 'animator_duration_scale'];
|
|
508
544
|
const get = (k) => (0, exec_1.runText)(adb, ['-s', serial, 'shell', 'settings', 'get', 'global', k]).stdout.trim();
|
|
509
545
|
const vals = keys.map(get);
|
|
@@ -979,7 +1015,7 @@ function cmdClear(ctx) {
|
|
|
979
1015
|
// of applying dark mode and then dying — a half-applied device is worse than none.
|
|
980
1016
|
// - SNAPSHOT FIRST. The pre-change value is persisted before the change is made, so
|
|
981
1017
|
// `device reset` can undo it even from a later process.
|
|
982
|
-
const DEVICE_USAGE = 'Usage: verikun device set <key>=<value> [<key>=<value> ...] | device get [key] | device reset [key ...] | device caps\n' +
|
|
1018
|
+
const DEVICE_USAGE = 'Usage: verikun device set <key>=<value> [<key>=<value> ...] | device get [key] | device reset [key ...] | device caps | device release [serial]\n' +
|
|
983
1019
|
`Keys: ${settings_1.SETTING_KEYS.join(', ')}`;
|
|
984
1020
|
function cmdDevice(ctx) {
|
|
985
1021
|
const sub = (ctx.positionals[0] ?? '').toLowerCase();
|
|
@@ -994,10 +1030,43 @@ function cmdDevice(ctx) {
|
|
|
994
1030
|
return deviceReset(ctx, rest);
|
|
995
1031
|
case 'caps':
|
|
996
1032
|
return deviceCaps(ctx);
|
|
1033
|
+
case 'release':
|
|
1034
|
+
return deviceRelease(ctx, rest);
|
|
997
1035
|
default:
|
|
998
1036
|
throw new errors_1.CliError((sub ? `Unknown 'device' subcommand '${sub}'.\n` : '') + DEVICE_USAGE, 2);
|
|
999
1037
|
}
|
|
1000
1038
|
}
|
|
1039
|
+
/**
|
|
1040
|
+
* Hand a device back to the pool. The break-glass named in every "in use by" refusal:
|
|
1041
|
+
* a crashed job's claim expires on its own, but waiting out the TTL is the one thing an
|
|
1042
|
+
* operator should never be forced to do just to get on with their run.
|
|
1043
|
+
*
|
|
1044
|
+
* Deliberately releases someone ELSE's claim too, naming them — you had to type the
|
|
1045
|
+
* serial, and refusing here would leave no way to recover a stuck device at all. Resolves
|
|
1046
|
+
* the device WITHOUT going through the driver, so it works even when the claim being
|
|
1047
|
+
* cleared is the very thing that would refuse the resolve.
|
|
1048
|
+
*/
|
|
1049
|
+
function deviceRelease(ctx, args) {
|
|
1050
|
+
const serial = args[0] || ctx.device;
|
|
1051
|
+
if (!serial) {
|
|
1052
|
+
throw new errors_1.CliError('Usage: verikun device release <serial> (or pass --device / set VERIKUN_DEVICE)', 2);
|
|
1053
|
+
}
|
|
1054
|
+
if (!(0, claims_1.claimsEnabled)()) {
|
|
1055
|
+
(0, output_1.err)('device claims are disabled (VERIKUN_NO_CLAIM) — nothing to release');
|
|
1056
|
+
return 0;
|
|
1057
|
+
}
|
|
1058
|
+
const released = (0, claims_1.releaseClaim)(serial);
|
|
1059
|
+
if ((0, args_1.flagBool)(ctx.flags, 'json')) {
|
|
1060
|
+
(0, output_1.json)({ serial, released: !!released, ...(released ? { was: released } : {}) });
|
|
1061
|
+
}
|
|
1062
|
+
else if (released) {
|
|
1063
|
+
(0, output_1.out)(`released ${serial} (was held by ${(0, claims_1.describeClaim)(released)})`);
|
|
1064
|
+
}
|
|
1065
|
+
else {
|
|
1066
|
+
(0, output_1.out)(`${serial} was not claimed`);
|
|
1067
|
+
}
|
|
1068
|
+
return 0;
|
|
1069
|
+
}
|
|
1001
1070
|
/** Render a setting value for humans/JSON; a platform that cannot answer says so. */
|
|
1002
1071
|
const showValue = (v) => v ?? 'n/a';
|
|
1003
1072
|
function deviceSet(ctx, args) {
|
|
@@ -1197,6 +1266,10 @@ function cmdRun(positionals, flags, platform, device) {
|
|
|
1197
1266
|
case 'stop':
|
|
1198
1267
|
case 'discard': {
|
|
1199
1268
|
const cleared = run_1.Recorder.clear();
|
|
1269
|
+
// The run is over, so the device goes back to the pool now rather than in TTL
|
|
1270
|
+
// minutes. `mineOnly` because a run can only ever hand back its own device.
|
|
1271
|
+
if (cleared?.device)
|
|
1272
|
+
(0, claims_1.releaseClaim)(cleared.device, { mineOnly: true });
|
|
1200
1273
|
if (asJson)
|
|
1201
1274
|
(0, output_1.json)({ cleared: cleared?.id ?? null });
|
|
1202
1275
|
else
|
|
@@ -1226,6 +1299,9 @@ function cmdRun(positionals, flags, platform, device) {
|
|
|
1226
1299
|
}
|
|
1227
1300
|
}
|
|
1228
1301
|
const { dir, xmlPath, htmlPath, state } = run_1.Recorder.archive(positionals[1], { noLogs, fetchLogs });
|
|
1302
|
+
// Archived means finished: release after log capture, which still needs the device.
|
|
1303
|
+
if (state.device)
|
|
1304
|
+
(0, claims_1.releaseClaim)(state.device, { mineOnly: true });
|
|
1229
1305
|
const { passed, failed } = tally(state.steps);
|
|
1230
1306
|
if (asJson) {
|
|
1231
1307
|
(0, output_1.json)({
|
|
@@ -1374,6 +1450,9 @@ async function cmdBatch(positionals, batchFlags) {
|
|
|
1374
1450
|
(0, output_1.err)('[verikun] batch: no commands to run');
|
|
1375
1451
|
return 0;
|
|
1376
1452
|
}
|
|
1453
|
+
// One process drives the whole batch, so its pid is exact evidence that the device is
|
|
1454
|
+
// still in use — a `kill -9` hands it straight back instead of parking it for the TTL.
|
|
1455
|
+
(0, claims_1.setProcessScoped)(true);
|
|
1377
1456
|
// The finally is what makes `device set` safe to use in a batch: a line that fails
|
|
1378
1457
|
// (or a ^C) still puts the device back, instead of leaving it offline or rotated.
|
|
1379
1458
|
try {
|
|
@@ -1413,6 +1492,7 @@ async function cmdBatch(positionals, batchFlags) {
|
|
|
1413
1492
|
/* the device may be exactly why we are unwinding — never mask the real error */
|
|
1414
1493
|
}
|
|
1415
1494
|
}
|
|
1495
|
+
(0, claims_1.releaseOwnClaims)();
|
|
1416
1496
|
}
|
|
1417
1497
|
}
|
|
1418
1498
|
function parseAiOptions(flags) {
|
|
@@ -1603,6 +1683,12 @@ async function resolveBackend(platform, device, flags) {
|
|
|
1603
1683
|
const health = await (0, remote_1.pingServer)(opts); // fails fast (exit 3) on a bad URL or key
|
|
1604
1684
|
runCtx = { platform: health.platform, device: health.serial };
|
|
1605
1685
|
(0, output_1.err)(`[verikun] server ${server}: ${health.platform} · device ${health.serial} · verikun ${health.version}`);
|
|
1686
|
+
// Say the read path once, here. Reads execute server-side, so this is the only end of the
|
|
1687
|
+
// connection that knows it — and without it a companion that had silently stood down was
|
|
1688
|
+
// indistinguishable from one that never engaged, for a whole suite (issue #77). An older
|
|
1689
|
+
// server omits the field; saying nothing is better than guessing.
|
|
1690
|
+
if (health.reads)
|
|
1691
|
+
(0, output_1.err)(`[verikun] server reads: ${health.reads.path} (${health.reads.detail})`);
|
|
1606
1692
|
const remote = (0, remote_1.createRemoteBackend)(opts, health);
|
|
1607
1693
|
return {
|
|
1608
1694
|
backend: {
|
|
@@ -1630,7 +1716,7 @@ async function resolveBackend(platform, device, flags) {
|
|
|
1630
1716
|
},
|
|
1631
1717
|
platform: health.platform,
|
|
1632
1718
|
device: health.serial,
|
|
1633
|
-
remote: { url: server, version: health.version },
|
|
1719
|
+
remote: { url: server, version: health.version, reads: health.reads },
|
|
1634
1720
|
};
|
|
1635
1721
|
}
|
|
1636
1722
|
/**
|
|
@@ -1849,6 +1935,7 @@ async function cmdAi(positionals, flags) {
|
|
|
1849
1935
|
throw new errors_1.CliError('Usage: verikun ai <file> [--model m] [--max-cost-usd n] [--timeout dur] [--server url] [--show-plan] [--recompile]', 2);
|
|
1850
1936
|
}
|
|
1851
1937
|
const opts = parseAiOptions(flags);
|
|
1938
|
+
(0, claims_1.setProcessScoped)(true); // one process for the whole run — see claims.ts's isLive
|
|
1852
1939
|
// --show-plan: compile (or cache-hit) and print the IR — no device, no backend.
|
|
1853
1940
|
if ((0, args_1.flagBool)(flags, 'show-plan')) {
|
|
1854
1941
|
const nl = readAiTest(file);
|
|
@@ -1869,6 +1956,7 @@ async function cmdAi(positionals, flags) {
|
|
|
1869
1956
|
// otherwise an unattended run leaves the phone offline or in dark mode.
|
|
1870
1957
|
await restoreDeviceOverrides(backend);
|
|
1871
1958
|
await backend.close?.(); // frees a remote server's device lock for the next command
|
|
1959
|
+
(0, claims_1.releaseOwnClaims)(); // and the host-level claim, so the next job can have the device
|
|
1872
1960
|
}
|
|
1873
1961
|
if ((0, args_1.flagBool)(flags, 'json')) {
|
|
1874
1962
|
(0, output_1.json)({
|
|
@@ -1928,13 +2016,14 @@ async function cmdSuiteEntry(positionals, flags) {
|
|
|
1928
2016
|
if (!dirArg)
|
|
1929
2017
|
throw new errors_1.CliError('Usage: verikun suite <dir> [--app <id>] [--server url] [--name n] [--retries n] [--json]', 2);
|
|
1930
2018
|
const opts = parseAiOptions(flags);
|
|
2019
|
+
(0, claims_1.setProcessScoped)(true); // one process for the whole suite — see claims.ts's isLive
|
|
1931
2020
|
// Pre-flight the provider BEFORE touching any device/server: every test needs it
|
|
1932
2021
|
// to compile (on a cache miss) or to repair at runtime.
|
|
1933
2022
|
if (!providerAvailable(opts.model)) {
|
|
1934
2023
|
throw new errors_1.CliError(`${providerRequirement(opts.model)} — needed to compile/repair tests (model ${opts.model}).`, 3);
|
|
1935
2024
|
}
|
|
1936
2025
|
const reqPlatform = platformFromFlags(flags);
|
|
1937
|
-
const { backend, platform, device } = await resolveBackend(reqPlatform, deviceFromFlags(flags, reqPlatform), flags);
|
|
2026
|
+
const { backend, platform, device, remote } = await resolveBackend(reqPlatform, deviceFromFlags(flags, reqPlatform), flags);
|
|
1938
2027
|
const app = (0, args_1.flagStr)(flags, 'app');
|
|
1939
2028
|
if (app)
|
|
1940
2029
|
assertSafeAppId(app);
|
|
@@ -1942,6 +2031,9 @@ async function cmdSuiteEntry(positionals, flags) {
|
|
|
1942
2031
|
return await (0, suite_1.cmdSuite)(dirArg, flags, {
|
|
1943
2032
|
platform,
|
|
1944
2033
|
device,
|
|
2034
|
+
...(remote
|
|
2035
|
+
? { server: { url: remote.url, verikun: remote.version, reads: remote.reads?.path } }
|
|
2036
|
+
: {}),
|
|
1945
2037
|
runTest: (file) => runAiTest(file, opts, backend, platform, device),
|
|
1946
2038
|
// Reset app state between tests only when the app id is known; without --app,
|
|
1947
2039
|
// each test is responsible for its own isolation (e.g. `launch --clear`).
|
|
@@ -1952,6 +2044,7 @@ async function cmdSuiteEntry(positionals, flags) {
|
|
|
1952
2044
|
finally {
|
|
1953
2045
|
await restoreDeviceOverrides(backend);
|
|
1954
2046
|
await backend.close?.();
|
|
2047
|
+
(0, claims_1.releaseOwnClaims)();
|
|
1955
2048
|
}
|
|
1956
2049
|
}
|
|
1957
2050
|
// ---------------------------------------------------------------------------
|
|
@@ -2104,6 +2197,12 @@ async function executeOutcome(command, positionals, flags, sharedDriver) {
|
|
|
2104
2197
|
catch {
|
|
2105
2198
|
/* surfaced by the command handler below */
|
|
2106
2199
|
}
|
|
2200
|
+
// Keep this job's device claim alive. Deliberately NOT gated on the recorder:
|
|
2201
|
+
// `Recorder.beginStep` returns null under VERIKUN_NO_RUN=1 (which the e2e suite
|
|
2202
|
+
// sets), and hanging the heartbeat off it would silently stop claims from being
|
|
2203
|
+
// refreshed for exactly the runs that exercise them.
|
|
2204
|
+
if (serial)
|
|
2205
|
+
(0, claims_1.touchClaim)(serial, platform);
|
|
2107
2206
|
recorder = run_1.Recorder.beginStep(command, positionals, flags, platform, device, serial, driver);
|
|
2108
2207
|
}
|
|
2109
2208
|
}
|
|
@@ -2144,6 +2243,10 @@ async function executeForServer(command, positionals, flags, driver, platform) {
|
|
|
2144
2243
|
catch {
|
|
2145
2244
|
/* surfaced by the command handler below */
|
|
2146
2245
|
}
|
|
2246
|
+
// A server holds one device for its whole life, but its claim still has to look alive
|
|
2247
|
+
// to everyone else on the host — refresh it per request, the same as a local step.
|
|
2248
|
+
if (serial)
|
|
2249
|
+
(0, claims_1.touchClaim)(serial, platform);
|
|
2147
2250
|
// Sample the device clock up front so the caller's run can set logStart (the
|
|
2148
2251
|
// ephemeral recorder never persists RunState). Best-effort — empty/unavailable
|
|
2149
2252
|
// just means archive / vk log fall back to last-N.
|
|
@@ -2281,6 +2384,9 @@ DEVICE STATE (change the device the app runs on, then put it back)
|
|
|
2281
2384
|
equivalent where it doesn't
|
|
2282
2385
|
Refuses \`airplane=on\` over wireless adb (it would cut
|
|
2283
2386
|
this very connection); --allow-wireless overrides.
|
|
2387
|
+
device release [serial] [--json] Hand a claimed device back to the pool. Claims
|
|
2388
|
+
expire on their own; this is for when you don't
|
|
2389
|
+
want to wait. See \`devices\` for who holds what.
|
|
2284
2390
|
|
|
2285
2391
|
BATCH (script many commands in one process)
|
|
2286
2392
|
batch [--file path] [--quiet] Run newline-separated commands — from --file,
|
|
@@ -2342,7 +2448,8 @@ SERVER (expose a locally-connected device to remote verikun clients)
|
|
|
2342
2448
|
VERIKUN_SERVER_AUTH_KEY) to ai/suite/install. The server's device+platform apply.
|
|
2343
2449
|
|
|
2344
2450
|
ENVIRONMENT
|
|
2345
|
-
devices [--json] List attached devices/simulators
|
|
2451
|
+
devices [--json] List attached devices/simulators, and which job is
|
|
2452
|
+
already driving each (USED BY)
|
|
2346
2453
|
doctor [--fix] Diagnose adb/device, and warn if this CLI or the
|
|
2347
2454
|
Claude Code plugin is out of date (a warning only —
|
|
2348
2455
|
it never changes the exit code). --fix disables
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Does the companion's dump agree with the platform's own?
|
|
4
|
+
*
|
|
5
|
+
* This is the standard `calibrate()` holds the companion to before trusting a single one of
|
|
6
|
+
* its reads. It used to be plain string equality, and that is still the fast path — but
|
|
7
|
+
* equality turned out to be stricter than the property it was standing in for, and strictly
|
|
8
|
+
* enough to reject a companion that was right.
|
|
9
|
+
*
|
|
10
|
+
* The property that actually matters is TAP SAFETY: verikun taps the centre of a node the
|
|
11
|
+
* dump reported, so every node the companion serves must be one the platform would have
|
|
12
|
+
* served, with the same bounds. Byte equality implies that. It also demands something extra
|
|
13
|
+
* and unrelated — that the companion reproduce every node the platform emitted, including
|
|
14
|
+
* decor nobody can touch — and that is what broke.
|
|
15
|
+
*
|
|
16
|
+
* MEASURED (issue #79). Asking the accessibility layer for window information is what stops
|
|
17
|
+
* a long-lived connection serving a stale window, but it also makes Android clamp node
|
|
18
|
+
* bounds to the app window rather than the display. On a Pixel 6 emulator (Android 14) that
|
|
19
|
+
* drops exactly one node from the dump, `android:id/navigationBarBackground` at
|
|
20
|
+
* [0,2274][1080,2400] — not clickable, not focusable, and below every pixel the app owns.
|
|
21
|
+
* Every other node, root included, stayed byte-identical. Equality still said no, so the
|
|
22
|
+
* companion was benched and reads went from ~165ms to ~33s.
|
|
23
|
+
*
|
|
24
|
+
* So the rule is a subsequence with two guards:
|
|
25
|
+
*
|
|
26
|
+
* 1. every companion node is byte-identical to a platform node, in document order — this
|
|
27
|
+
* is the tap-safety property, and it is not weakened at all;
|
|
28
|
+
* 2. every platform node the companion lacks is a LEAF and is neither clickable nor
|
|
29
|
+
* focusable — so the companion can never quietly drop something tappable, nor a
|
|
30
|
+
* container whose absence would re-nest the nodes under it.
|
|
31
|
+
*
|
|
32
|
+
* Guard 2 is deliberately stricter than the measurement requires: an unmatched container
|
|
33
|
+
* would still leave its children matchable, but the shapes that produces have not been seen
|
|
34
|
+
* on a real device, and the cost of refusing one is a slow read rather than a wrong tap.
|
|
35
|
+
*
|
|
36
|
+
* What this does NOT still guarantee is that a non-interactive leaf's bounds agree — such a
|
|
37
|
+
* node can differ and be treated as missing. That is the deliberate give: it cannot move a
|
|
38
|
+
* tap, because nothing reads it.
|
|
39
|
+
*/
|
|
40
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
41
|
+
exports.dumpsAgree = dumpsAgree;
|
|
42
|
+
const NODE_TAG = /<node\b[^>]*?\/?>/g;
|
|
43
|
+
// A leading \s so `long-clickable="true"` cannot be read as `clickable="true"`.
|
|
44
|
+
const CLICKABLE = /\sclickable="true"/;
|
|
45
|
+
const FOCUSABLE = /\sfocusable="true"/;
|
|
46
|
+
function nodes(xml) {
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const m of xml.matchAll(NODE_TAG)) {
|
|
49
|
+
const raw = m[0];
|
|
50
|
+
out.push({
|
|
51
|
+
raw,
|
|
52
|
+
leaf: raw.endsWith('/>'),
|
|
53
|
+
interactive: CLICKABLE.test(raw) || FOCUSABLE.test(raw),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
/** The `<hierarchy …>` element itself — its `rotation` is part of the contract. */
|
|
59
|
+
function header(xml) {
|
|
60
|
+
return /<hierarchy\b[^>]*>/.exec(xml)?.[0] ?? '';
|
|
61
|
+
}
|
|
62
|
+
function dumpsAgree(stock, companion) {
|
|
63
|
+
const a = stock.trim();
|
|
64
|
+
const b = companion.trim();
|
|
65
|
+
if (a === b)
|
|
66
|
+
return { agree: true };
|
|
67
|
+
if (header(a) !== header(b)) {
|
|
68
|
+
return { agree: false, reason: 'the hierarchy header differs (rotation changed mid-capture?)' };
|
|
69
|
+
}
|
|
70
|
+
const stockNodes = nodes(a);
|
|
71
|
+
const compNodes = nodes(b);
|
|
72
|
+
let i = 0; // stock
|
|
73
|
+
for (const want of compNodes) {
|
|
74
|
+
// Skip platform nodes the companion did not emit, proving each is droppable as we go.
|
|
75
|
+
while (i < stockNodes.length && stockNodes[i].raw !== want.raw) {
|
|
76
|
+
const skipped = stockNodes[i];
|
|
77
|
+
if (skipped.interactive) {
|
|
78
|
+
return { agree: false, reason: 'the companion is missing a node the platform reports as tappable' };
|
|
79
|
+
}
|
|
80
|
+
if (!skipped.leaf) {
|
|
81
|
+
return { agree: false, reason: 'the companion is missing a container the platform reports' };
|
|
82
|
+
}
|
|
83
|
+
i++;
|
|
84
|
+
}
|
|
85
|
+
if (i === stockNodes.length) {
|
|
86
|
+
return { agree: false, reason: 'the companion reports a node the platform does not' };
|
|
87
|
+
}
|
|
88
|
+
i++; // consume the match
|
|
89
|
+
}
|
|
90
|
+
// Anything left over in the platform's dump must clear the same bar.
|
|
91
|
+
for (; i < stockNodes.length; i++) {
|
|
92
|
+
if (stockNodes[i].interactive) {
|
|
93
|
+
return { agree: false, reason: 'the companion is missing a node the platform reports as tappable' };
|
|
94
|
+
}
|
|
95
|
+
if (!stockNodes[i].leaf) {
|
|
96
|
+
return { agree: false, reason: 'the companion is missing a container the platform reports' };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { agree: true };
|
|
100
|
+
}
|