verikun 0.21.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 +7 -1
- package/CHANGELOG.md +84 -0
- package/dist/bin/verikun.js +0 -0
- package/dist/cli.js +11 -2
- package/dist/companion/manager.js +213 -42
- package/dist/drivers/adb.js +12 -0
- package/dist/server.js +29 -0
- package/dist/suite.js +1 -0
- package/dist/version.js +1 -1
- package/package.json +2 -1
|
@@ -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
|
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,90 @@ 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
|
+
|
|
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
|
+
|
|
9
93
|
## [0.21.0] - 2026-08-13
|
|
10
94
|
|
|
11
95
|
### Added
|
package/dist/bin/verikun.js
CHANGED
|
File without changes
|
package/dist/cli.js
CHANGED
|
@@ -1603,6 +1603,12 @@ async function resolveBackend(platform, device, flags) {
|
|
|
1603
1603
|
const health = await (0, remote_1.pingServer)(opts); // fails fast (exit 3) on a bad URL or key
|
|
1604
1604
|
runCtx = { platform: health.platform, device: health.serial };
|
|
1605
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})`);
|
|
1606
1612
|
const remote = (0, remote_1.createRemoteBackend)(opts, health);
|
|
1607
1613
|
return {
|
|
1608
1614
|
backend: {
|
|
@@ -1630,7 +1636,7 @@ async function resolveBackend(platform, device, flags) {
|
|
|
1630
1636
|
},
|
|
1631
1637
|
platform: health.platform,
|
|
1632
1638
|
device: health.serial,
|
|
1633
|
-
remote: { url: server, version: health.version },
|
|
1639
|
+
remote: { url: server, version: health.version, reads: health.reads },
|
|
1634
1640
|
};
|
|
1635
1641
|
}
|
|
1636
1642
|
/**
|
|
@@ -1934,7 +1940,7 @@ async function cmdSuiteEntry(positionals, flags) {
|
|
|
1934
1940
|
throw new errors_1.CliError(`${providerRequirement(opts.model)} — needed to compile/repair tests (model ${opts.model}).`, 3);
|
|
1935
1941
|
}
|
|
1936
1942
|
const reqPlatform = platformFromFlags(flags);
|
|
1937
|
-
const { backend, platform, device } = await resolveBackend(reqPlatform, deviceFromFlags(flags, reqPlatform), flags);
|
|
1943
|
+
const { backend, platform, device, remote } = await resolveBackend(reqPlatform, deviceFromFlags(flags, reqPlatform), flags);
|
|
1938
1944
|
const app = (0, args_1.flagStr)(flags, 'app');
|
|
1939
1945
|
if (app)
|
|
1940
1946
|
assertSafeAppId(app);
|
|
@@ -1942,6 +1948,9 @@ async function cmdSuiteEntry(positionals, flags) {
|
|
|
1942
1948
|
return await (0, suite_1.cmdSuite)(dirArg, flags, {
|
|
1943
1949
|
platform,
|
|
1944
1950
|
device,
|
|
1951
|
+
...(remote
|
|
1952
|
+
? { server: { url: remote.url, verikun: remote.version, reads: remote.reads?.path } }
|
|
1953
|
+
: {}),
|
|
1945
1954
|
runTest: (file) => runAiTest(file, opts, backend, platform, device),
|
|
1946
1955
|
// Reset app state between tests only when the app id is known; without --app,
|
|
1947
1956
|
// each test is responsible for its own isolation (e.g. `launch --clear`).
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// therefore gets the companion off the connection BEFORE the caller falls back.
|
|
10
10
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
11
|
exports.Companion = void 0;
|
|
12
|
+
exports.nullRootAction = nullRootAction;
|
|
12
13
|
exports.companionJarPath = companionJarPath;
|
|
13
14
|
exports.companionEnabled = companionEnabled;
|
|
14
15
|
exports.releaseCompanionOn = releaseCompanionOn;
|
|
@@ -42,6 +43,64 @@ const START_TIMEOUT_MS = 12000;
|
|
|
42
43
|
/** The companion's way of saying getRootInActiveWindow() returned null. */
|
|
43
44
|
const NULL_ROOT_REPLY = /null root node/i;
|
|
44
45
|
const START_POLL_MS = 150;
|
|
46
|
+
/**
|
|
47
|
+
* How long a TRANSIENT stand-down suppresses the companion before it is retried.
|
|
48
|
+
*
|
|
49
|
+
* The point of a stand-down is that re-probing on every read would cost more than the stock
|
|
50
|
+
* dump it avoids — but "never again" is only correct for a process that exits after one
|
|
51
|
+
* command. `vk server` runs for days, and the companion shuts ITSELF down after 15 minutes
|
|
52
|
+
* idle, so a daemon that latched off permanently spent the rest of its life on the 2.4s path
|
|
53
|
+
* next to a perfectly healthy companion (issue #77). One retry a minute is cheap enough to be
|
|
54
|
+
* invisible and frequent enough that a suite recovers within its first test.
|
|
55
|
+
*/
|
|
56
|
+
const TRANSIENT_RETRY_MS = 60000;
|
|
57
|
+
/**
|
|
58
|
+
* How long a run of null-root replies must last before the connection is suspected of being
|
|
59
|
+
* wedged rather than the screen being genuinely blank.
|
|
60
|
+
*
|
|
61
|
+
* A real mid-launch gap resolves in well under this; a stale UiAutomation connection never
|
|
62
|
+
* does. MEASURED on a Pixel 3a: after `vk launch` force-stops and restarts the app, the
|
|
63
|
+
* companion returned a null root for 30s+ while a stock dump read the same screen fine.
|
|
64
|
+
*/
|
|
65
|
+
const WEDGE_AFTER_MS = 3000;
|
|
66
|
+
/**
|
|
67
|
+
* How long a null-root fallback suppresses the companion — much shorter than a companion
|
|
68
|
+
* failure, because the cause is different in kind.
|
|
69
|
+
*
|
|
70
|
+
* A fallback here means a freshly re-acquired connection still saw no window, which is most
|
|
71
|
+
* often an app that genuinely has not drawn yet. That clears the moment it does, so the 60s
|
|
72
|
+
* used for a real companion fault would spend the rest of a launch on the 2.4s path for no
|
|
73
|
+
* reason. Long enough to let the stock dump answer without immediately re-taking the
|
|
74
|
+
* connection from under it; short enough that the companion is back within one auto-wait.
|
|
75
|
+
*/
|
|
76
|
+
const NULL_ROOT_RETRY_MS = 2000;
|
|
77
|
+
const NOT_RUNNING = 'not running';
|
|
78
|
+
/**
|
|
79
|
+
* What to do about a null root, given how long the current run of them has lasted.
|
|
80
|
+
*
|
|
81
|
+
* Pure so the escalation can be unit-tested without a device. The order matters and each step
|
|
82
|
+
* is deliberately conservative:
|
|
83
|
+
* - `propagate` — the normal case. A null root is the DEVICE having no window (mid-launch,
|
|
84
|
+
* force-stopped), not a companion fault, so it goes up to the caller's auto-wait.
|
|
85
|
+
* - `recycle` — long enough to be suspicious. Release and re-acquire the connection, which
|
|
86
|
+
* is exactly what unwedges it, and cheap (~1.05s) relative to being wrong.
|
|
87
|
+
* - `fallback` — recycling did not help. Hand the connection back and let the stock dump
|
|
88
|
+
* answer. It can read screens the wedged companion cannot, and a slow read beats a
|
|
89
|
+
* selector command failing on a screen that is plainly there.
|
|
90
|
+
*
|
|
91
|
+
* Once a recycle has happened, the NEXT null root falls back immediately rather than waiting
|
|
92
|
+
* out another interval. The recycle IS the test — a connection that was just released and
|
|
93
|
+
* re-acquired has no staleness left to explain the answer — so more elapsed time tells us
|
|
94
|
+
* nothing new, and waiting for it is not free: selector commands default to a 5s auto-wait,
|
|
95
|
+
* so any second threshold beyond the ~1.05s recycle risks the window closing first. That
|
|
96
|
+
* would fail the command with "no window" on a readable screen without ever trying the stock
|
|
97
|
+
* dump, which is the exact failure this escalation exists to prevent.
|
|
98
|
+
*/
|
|
99
|
+
function nullRootAction(runMs, alreadyRecycled) {
|
|
100
|
+
if (alreadyRecycled)
|
|
101
|
+
return 'fallback';
|
|
102
|
+
return runMs < WEDGE_AFTER_MS ? 'propagate' : 'recycle';
|
|
103
|
+
}
|
|
45
104
|
/** The packaged companion jar: shipped in the npm tarball, and present in a source checkout
|
|
46
105
|
* once `tools/verikun-companion/build.sh` has run. Absent means "no companion available",
|
|
47
106
|
* which is a normal state, not an error. */
|
|
@@ -95,15 +154,31 @@ function releaseCompanionOn(serial) {
|
|
|
95
154
|
class Companion {
|
|
96
155
|
deps;
|
|
97
156
|
port;
|
|
98
|
-
/**
|
|
99
|
-
*
|
|
100
|
-
|
|
101
|
-
|
|
157
|
+
/** Off for the rest of this process, for a reason that CANNOT change while it runs: this
|
|
158
|
+
* device's note says the companion does not work here, or there is no jar to push. */
|
|
159
|
+
declined = false;
|
|
160
|
+
/** Off until this timestamp, for a reason that might. Re-probing per read would cost more
|
|
161
|
+
* than the stock dump it avoids, but never retrying is only right for a process that
|
|
162
|
+
* exits after one command — see TRANSIENT_RETRY_MS. */
|
|
163
|
+
retryAfter = 0;
|
|
102
164
|
dims;
|
|
165
|
+
/** When the current unbroken run of null-root replies started (0 = not in one), and
|
|
166
|
+
* whether this run has already spent its one connection recycle. See nullRootAction. */
|
|
167
|
+
nullRootSince = 0;
|
|
168
|
+
nullRootRecycled = false;
|
|
103
169
|
constructor(deps) {
|
|
104
170
|
this.deps = deps;
|
|
105
171
|
this.port = (0, protocol_1.portForSerial)(deps.serial);
|
|
106
172
|
}
|
|
173
|
+
/** Why the companion is not serving reads right now, for `/v1/health` and `vk companion
|
|
174
|
+
* status`. Null when it is available (or would be on the next read). */
|
|
175
|
+
suppressedReason() {
|
|
176
|
+
if (this.declined)
|
|
177
|
+
return 'declined on this device';
|
|
178
|
+
if (Date.now() < this.retryAfter)
|
|
179
|
+
return 'standing by after a failure';
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
107
182
|
adb(args, timeout = 15000) {
|
|
108
183
|
return (0, exec_1.runText)(this.deps.adb, ['-s', this.deps.serial, ...args], { timeout });
|
|
109
184
|
}
|
|
@@ -135,37 +210,97 @@ class Companion {
|
|
|
135
210
|
* work rather than being SIGKILLed.
|
|
136
211
|
*/
|
|
137
212
|
dump(idleMs) {
|
|
138
|
-
if (this.
|
|
213
|
+
if (this.declined || Date.now() < this.retryAfter)
|
|
139
214
|
return null;
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
215
|
+
// TWO attempts, because a dead companion is routine rather than exceptional: it shuts
|
|
216
|
+
// itself down after 15 minutes idle, which any long-lived process (`vk server`, a slow
|
|
217
|
+
// suite) will outlive. The first attempt can therefore fail purely because `dims` is
|
|
218
|
+
// cached from before that shutdown; clearing it sends the second attempt through
|
|
219
|
+
// ensureReady(), which restarts from the device note in ~2.1s. Without this the process
|
|
220
|
+
// stood down permanently and never touched the companion again (issue #77).
|
|
221
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
222
|
+
try {
|
|
223
|
+
return this.attemptDump(idleMs);
|
|
145
224
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const detail = reply.toString('utf8').trim().slice(0, 200) || 'empty reply';
|
|
153
|
-
// A null root is the DEVICE having no window, not the companion malfunctioning.
|
|
154
|
-
// Standing down for it would release a perfectly healthy connection and drop the
|
|
155
|
-
// whole process onto the 2.4s path — measured after every `launch --clear`, which
|
|
156
|
-
// leaves exactly this gap.
|
|
157
|
-
if (NULL_ROOT_REPLY.test(detail)) {
|
|
158
|
-
throw new errors_1.NoWindowError('No window to read: the app has not drawn yet (force-stopped, or mid-launch).');
|
|
225
|
+
catch (e) {
|
|
226
|
+
if (e instanceof errors_1.NoWindowError)
|
|
227
|
+
throw e; // the screen's state, not the companion's
|
|
228
|
+
if (attempt === 0 && this.dims) {
|
|
229
|
+
this.dims = undefined;
|
|
230
|
+
continue;
|
|
159
231
|
}
|
|
160
|
-
|
|
232
|
+
this.standDown(`companion dump failed (${e.message})`, 'transient');
|
|
233
|
+
return null;
|
|
161
234
|
}
|
|
162
|
-
return reply.toString('utf8');
|
|
163
235
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
/** One read attempt. Throws NoWindowError for a blank screen, anything else for a
|
|
239
|
+
* companion that could not serve the read. */
|
|
240
|
+
attemptDump(idleMs) {
|
|
241
|
+
if (!this.dims) {
|
|
242
|
+
this.dims = this.ensureReady();
|
|
243
|
+
if (!this.dims)
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
const reply = (0, protocol_1.requestSync)(this.port, (0, protocol_1.dumpCommand)(idleMs, this.dims));
|
|
247
|
+
if (!(0, protocol_1.isHierarchy)(reply)) {
|
|
248
|
+
// The companion reports its own failures as plain text (`ERROR …`, `released —
|
|
249
|
+
// call acquire first`). Those must never reach the XML parser as though they were
|
|
250
|
+
// a screen: an unparseable "hierarchy" reads as zero elements, which is the
|
|
251
|
+
// "absent" lie that silently skips a guard.
|
|
252
|
+
const detail = reply.toString('utf8').trim().slice(0, 200) || 'empty reply';
|
|
253
|
+
if (NULL_ROOT_REPLY.test(detail))
|
|
254
|
+
return this.onNullRoot(idleMs);
|
|
255
|
+
throw new Error(detail);
|
|
256
|
+
}
|
|
257
|
+
// A hierarchy came back, so whatever run of null roots preceded it is over.
|
|
258
|
+
this.nullRootSince = 0;
|
|
259
|
+
this.nullRootRecycled = false;
|
|
260
|
+
return reply.toString('utf8');
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* A null root is USUALLY the device having no window — mid-launch, or force-stopped — and
|
|
264
|
+
* standing down for it would release a healthy connection and drop the whole process onto
|
|
265
|
+
* the 2.4s path after every `launch --clear`.
|
|
266
|
+
*
|
|
267
|
+
* But it is not always. A long-lived UiAutomation connection can go stale and return null
|
|
268
|
+
* forever for a window that is plainly there: MEASURED on a Pixel 3a, 30s+ of null roots
|
|
269
|
+
* after `vk launch` while a stock dump read the screen fine. Because this error propagates
|
|
270
|
+
* rather than standing down, that state never self-healed and every selector command burned
|
|
271
|
+
* its full auto-wait and failed on a readable screen. So escalate by duration.
|
|
272
|
+
*/
|
|
273
|
+
onNullRoot(idleMs) {
|
|
274
|
+
const now = Date.now();
|
|
275
|
+
if (!this.nullRootSince)
|
|
276
|
+
this.nullRootSince = now;
|
|
277
|
+
switch (nullRootAction(now - this.nullRootSince, this.nullRootRecycled)) {
|
|
278
|
+
case 'recycle':
|
|
279
|
+
this.nullRootRecycled = true;
|
|
280
|
+
(0, output_1.err)('[verikun] companion has reported no window for a while; recycling the connection');
|
|
281
|
+
if (this.recycleConnection())
|
|
282
|
+
return this.attemptDump(idleMs);
|
|
283
|
+
break;
|
|
284
|
+
case 'fallback':
|
|
285
|
+
// Recycling did not help and the stock dump may well read this screen. Being slow is
|
|
286
|
+
// always better than failing a selector on a screen that is there.
|
|
287
|
+
this.standDown('companion still reports no window after a recycle; using the stock path', 'screen');
|
|
288
|
+
return null;
|
|
289
|
+
case 'propagate':
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
throw new errors_1.NoWindowError('No window to read: the app has not drawn yet (force-stopped, or mid-launch).');
|
|
293
|
+
}
|
|
294
|
+
/** Hand the UiAutomation connection back and take it again. The one thing measured to clear
|
|
295
|
+
* a stale connection — it is what the stock path's releaseCompanionOn() does by accident. */
|
|
296
|
+
recycleConnection() {
|
|
297
|
+
try {
|
|
298
|
+
(0, protocol_1.requestSync)(this.port, 'release', 5000);
|
|
299
|
+
(0, protocol_1.requestSync)(this.port, 'acquire', 20000);
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
return false;
|
|
169
304
|
}
|
|
170
305
|
}
|
|
171
306
|
/** Get a calibrated companion running, or give up for this process. */
|
|
@@ -189,7 +324,10 @@ class Companion {
|
|
|
189
324
|
// single command a doomed start attempt now that this is on by default.
|
|
190
325
|
const note = this.readDeviceNote();
|
|
191
326
|
if (note === 'unsupported') {
|
|
192
|
-
this
|
|
327
|
+
// TERMINAL: this is a recorded fact about the phone, and it cannot become false while
|
|
328
|
+
// this process runs. Retrying it would be the doomed start attempt per command that the
|
|
329
|
+
// note exists to prevent.
|
|
330
|
+
this.declined = true;
|
|
193
331
|
return undefined;
|
|
194
332
|
}
|
|
195
333
|
return this.startAndCalibrate(note, live);
|
|
@@ -218,9 +356,17 @@ class Companion {
|
|
|
218
356
|
// - the jar is missing, which is a fault of THIS checkout, not of the device;
|
|
219
357
|
// - several `vk` processes cold-started at once and collided, which is transient and
|
|
220
358
|
// usually leaves a perfectly good companion running (started by whoever won).
|
|
221
|
-
|
|
359
|
+
const jar = companionJarPath();
|
|
360
|
+
const deviceIsAtFault = jar && !this.probeState().usable;
|
|
361
|
+
if (deviceIsAtFault)
|
|
222
362
|
this.writeDeviceNote('unsupported');
|
|
223
|
-
|
|
363
|
+
// Only the two facts that cannot change under a running process are terminal. A start
|
|
364
|
+
// that failed for any OTHER reason — most often the cold-start collision above — must
|
|
365
|
+
// stay retryable, or one unlucky moment costs a daemon every read it will ever do.
|
|
366
|
+
if (deviceIsAtFault || !jar)
|
|
367
|
+
this.declined = true;
|
|
368
|
+
else
|
|
369
|
+
this.retryAfter = Date.now() + TRANSIENT_RETRY_MS;
|
|
224
370
|
return undefined;
|
|
225
371
|
}
|
|
226
372
|
// A remembered verdict skips calibration entirely — worth ~4.7s of the cold start, and
|
|
@@ -262,7 +408,7 @@ class Companion {
|
|
|
262
408
|
return true;
|
|
263
409
|
}
|
|
264
410
|
catch (e) {
|
|
265
|
-
this.standDown(`companion could not reacquire the connection (${e.message})
|
|
411
|
+
this.standDown(`companion could not reacquire the connection (${e.message})`, 'transient');
|
|
266
412
|
return false;
|
|
267
413
|
}
|
|
268
414
|
}
|
|
@@ -347,17 +493,37 @@ class Companion {
|
|
|
347
493
|
// Neither matched. Usually the screen simply moved between the two dumps, but it
|
|
348
494
|
// could equally be a device whose bounds we would get wrong — and being slow is
|
|
349
495
|
// strictly better than tapping the wrong pixel, so decline rather than pick one.
|
|
350
|
-
|
|
496
|
+
// TRANSIENT precisely because "the screen moved" is the usual cause: a daemon that
|
|
497
|
+
// treated one moving screen as a permanent verdict spent its whole life on the stock
|
|
498
|
+
// path, which is the likeliest origin of issue #77's "no speedup at all".
|
|
499
|
+
this.standDown('companion output did not match the platform dump; using the stock path', 'transient');
|
|
351
500
|
return undefined;
|
|
352
501
|
}
|
|
353
502
|
catch (e) {
|
|
354
|
-
this.standDown(`companion calibration failed (${e.message})
|
|
503
|
+
this.standDown(`companion calibration failed (${e.message})`, 'transient');
|
|
355
504
|
return undefined;
|
|
356
505
|
}
|
|
357
506
|
}
|
|
358
|
-
/**
|
|
359
|
-
|
|
360
|
-
|
|
507
|
+
/**
|
|
508
|
+
* Hand the UiAutomation connection back, and stop using the companion — for as long as the
|
|
509
|
+
* cause plausibly lasts:
|
|
510
|
+
* - `terminal` — for the rest of the process; the cause cannot change while it runs.
|
|
511
|
+
* - `transient` — a minute; the companion itself failed.
|
|
512
|
+
* - `screen` — a couple of seconds; the *screen* had no window, which clears on its own.
|
|
513
|
+
*
|
|
514
|
+
* Releasing FIRST is the load-bearing half: while the companion holds the connection the
|
|
515
|
+
* stock dump is not slower, it is SIGKILLed, and the caller is about to depend on it.
|
|
516
|
+
*/
|
|
517
|
+
standDown(reason, kind) {
|
|
518
|
+
if (kind === 'terminal')
|
|
519
|
+
this.declined = true;
|
|
520
|
+
else
|
|
521
|
+
this.retryAfter = Date.now() + (kind === 'screen' ? NULL_ROOT_RETRY_MS : TRANSIENT_RETRY_MS);
|
|
522
|
+
// Force a fresh ensureReady() on the retry: whatever `dims` said is exactly what stopped
|
|
523
|
+
// working, and the note-based restart is what recovers it.
|
|
524
|
+
this.dims = undefined;
|
|
525
|
+
this.nullRootSince = 0;
|
|
526
|
+
this.nullRootRecycled = false;
|
|
361
527
|
(0, output_1.err)(`[verikun] ${reason}`);
|
|
362
528
|
try {
|
|
363
529
|
(0, protocol_1.requestSync)(this.port, 'release', 4000);
|
|
@@ -385,12 +551,17 @@ class Companion {
|
|
|
385
551
|
}
|
|
386
552
|
/** For `vk companion status`. */
|
|
387
553
|
describe() {
|
|
554
|
+
const state = this.stateSummary();
|
|
555
|
+
return state === NOT_RUNNING ? state : `running on port ${this.port} (${state})`;
|
|
556
|
+
}
|
|
557
|
+
/** Just the device-side state (`ready app held`, or `not running`), for embedding in a
|
|
558
|
+
* one-line read-path report. */
|
|
559
|
+
stateSummary() {
|
|
388
560
|
try {
|
|
389
|
-
|
|
390
|
-
return `running on port ${this.port} (${state})`;
|
|
561
|
+
return (0, protocol_1.requestSync)(this.port, 'state', 4000).toString('utf8').trim();
|
|
391
562
|
}
|
|
392
563
|
catch {
|
|
393
|
-
return
|
|
564
|
+
return NOT_RUNNING;
|
|
394
565
|
}
|
|
395
566
|
}
|
|
396
567
|
}
|
package/dist/drivers/adb.js
CHANGED
|
@@ -279,6 +279,18 @@ class AdbDriver {
|
|
|
279
279
|
}
|
|
280
280
|
return this.companion;
|
|
281
281
|
}
|
|
282
|
+
/** Which read path the next getElements() will take. See `Driver.hierarchySource`. */
|
|
283
|
+
hierarchySource() {
|
|
284
|
+
if (!(0, manager_1.companionEnabled)())
|
|
285
|
+
return { path: 'stock', detail: 'companion off (VERIKUN_COMPANION)' };
|
|
286
|
+
const companion = this.companionOrNull();
|
|
287
|
+
if (!companion)
|
|
288
|
+
return { path: 'stock', detail: 'companion unavailable' };
|
|
289
|
+
const suppressed = companion.suppressedReason();
|
|
290
|
+
if (suppressed)
|
|
291
|
+
return { path: 'stock', detail: `companion ${suppressed}` };
|
|
292
|
+
return { path: 'companion', detail: companion.stateSummary() };
|
|
293
|
+
}
|
|
282
294
|
dumpXml() {
|
|
283
295
|
// The companion answers in ~40ms against ~2400ms for the stock path. A null here means
|
|
284
296
|
// it could not serve the read AND has already handed the UiAutomation connection back —
|
package/dist/server.js
CHANGED
|
@@ -35,11 +35,28 @@ const promises_1 = require("node:stream/promises");
|
|
|
35
35
|
const args_1 = require("./args");
|
|
36
36
|
const errors_1 = require("./errors");
|
|
37
37
|
const drivers_1 = require("./drivers");
|
|
38
|
+
const manager_1 = require("./companion/manager");
|
|
38
39
|
const output_1 = require("./output");
|
|
39
40
|
const ir_1 = require("./agent/ir");
|
|
40
41
|
const rpc_1 = require("./rpc");
|
|
41
42
|
const cli_1 = require("./cli");
|
|
42
43
|
const version_1 = require("./version");
|
|
44
|
+
/**
|
|
45
|
+
* A driver's read path, or null when the backend has no opinion (iOS reads through idb, one
|
|
46
|
+
* way only) or the probe failed.
|
|
47
|
+
*
|
|
48
|
+
* Best-effort on purpose. It is reported on `/v1/health`, which is also how a client checks
|
|
49
|
+
* the server is reachable at all — a companion probe must never be the reason that answer
|
|
50
|
+
* cannot be given.
|
|
51
|
+
*/
|
|
52
|
+
function safeHierarchySource(driver) {
|
|
53
|
+
try {
|
|
54
|
+
return driver.hierarchySource?.() ?? null;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
43
60
|
const DEFAULT_PORT = 8391;
|
|
44
61
|
const EXEC_BODY_CAP = 1024 * 1024; // 1 MB of JSON is far beyond any leaf command
|
|
45
62
|
const INSTALL_BODY_CAP = 512 * 1024 * 1024; // 512 MB app build
|
|
@@ -262,12 +279,14 @@ function buildServer(config) {
|
|
|
262
279
|
if (config.authKey && req.headers.authorization && !authorized(req)) {
|
|
263
280
|
throw new HttpError(401, 'invalid auth key');
|
|
264
281
|
}
|
|
282
|
+
const reads = safeHierarchySource(config.driver);
|
|
265
283
|
const health = {
|
|
266
284
|
ok: true,
|
|
267
285
|
version: version_1.VERSION,
|
|
268
286
|
platform: config.platform,
|
|
269
287
|
serial: config.serial,
|
|
270
288
|
installEnabled: config.allowInstall,
|
|
289
|
+
...(reads ? { reads } : {}),
|
|
271
290
|
};
|
|
272
291
|
sendJson(res, 200, health);
|
|
273
292
|
return;
|
|
@@ -369,6 +388,11 @@ async function cmdServer(positionals, flags) {
|
|
|
369
388
|
server.listen(port, bind, () => {
|
|
370
389
|
(0, output_1.err)(`[server] verikun ${version_1.VERSION} listening on http://${bind}:${port}`);
|
|
371
390
|
(0, output_1.err)(`[server] device: ${platform} · ${serial}`);
|
|
391
|
+
// Say the read path out loud. It is the difference between a suite that takes 8s and
|
|
392
|
+
// one that takes 43s, and it used to be invisible from both ends (issue #77).
|
|
393
|
+
const reads = safeHierarchySource(driver);
|
|
394
|
+
if (reads)
|
|
395
|
+
(0, output_1.err)(`[server] reads: ${reads.path} (${reads.detail})`);
|
|
372
396
|
(0, output_1.err)(`[server] install endpoint: ${allowInstall ? 'ENABLED (--allow-install)' : 'disabled (pass --allow-install to accept builds)'}`);
|
|
373
397
|
if (generated) {
|
|
374
398
|
(0, output_1.err)('[server] auth key generated for this session — clients pass it via VERIKUN_SERVER_AUTH_KEY or --auth-key:');
|
|
@@ -386,6 +410,11 @@ async function cmdServer(positionals, flags) {
|
|
|
386
410
|
});
|
|
387
411
|
const close = () => {
|
|
388
412
|
(0, output_1.err)('[server] shutting down');
|
|
413
|
+
// Hand the device's ONE UiAutomation connection back. The companion outlives the
|
|
414
|
+
// process that started it and would otherwise keep the connection for up to its full
|
|
415
|
+
// 15-minute idle window, blocking Appium, Layout Inspector and TalkBack on this host —
|
|
416
|
+
// with no obvious cause, and no way to stop it from a `--server` client.
|
|
417
|
+
(0, manager_1.releaseCompanionOn)(serial);
|
|
389
418
|
server.close();
|
|
390
419
|
resolve(0);
|
|
391
420
|
};
|
package/dist/suite.js
CHANGED
|
@@ -310,6 +310,7 @@ async function cmdSuite(dirArg, flags, deps) {
|
|
|
310
310
|
platform: deps.platform,
|
|
311
311
|
device: deps.device,
|
|
312
312
|
verikun: version_1.VERSION,
|
|
313
|
+
...(deps.server ? { server: deps.server } : {}),
|
|
313
314
|
totals: (0, report_1.suiteTotals)(results),
|
|
314
315
|
tests: results,
|
|
315
316
|
...(aborted ? { aborted } : {}),
|
package/dist/version.js
CHANGED
|
@@ -3,4 +3,4 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.VERSION = void 0;
|
|
4
4
|
// GENERATED by scripts/gen-version.mjs from package.json's "version" at build time
|
|
5
5
|
// (the `prebuild` script). Do NOT edit by hand; bump package.json instead.
|
|
6
|
-
exports.VERSION = '0.21.
|
|
6
|
+
exports.VERSION = '0.21.1';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "verikun",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.1",
|
|
4
4
|
"description": "Drive Android emulators/devices and iOS simulators for AI agents: tap, type, swipe, screenshot, and inspect the UI hierarchy by semantic identifiers — like Puppeteer for native apps.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"android",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"scripts": {
|
|
37
37
|
"prebuild": "node scripts/gen-version.mjs",
|
|
38
38
|
"build": "tsc",
|
|
39
|
+
"postbuild": "node scripts/chmod-bin.mjs",
|
|
39
40
|
"dev": "tsc --watch",
|
|
40
41
|
"verikun": "node dist/bin/verikun.js",
|
|
41
42
|
"test": "tsc -p tsconfig.test.json && node --test --test-reporter=spec .test-build/tests/*.test.js",
|