verikun 0.27.0 → 0.27.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 +1 -1
- package/CHANGELOG.md +16 -0
- package/dist/agent/engine.js +11 -8
- package/dist/cli.js +25 -6
- package/dist/commands/auto-wait.js +83 -23
- package/dist/device/failover.js +12 -6
- package/dist/drivers/adb.js +31 -0
- package/dist/errors.js +56 -2
- package/dist/rpc.js +11 -5
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -613,7 +613,7 @@ owns the redaction and the review-first flow.
|
|
|
613
613
|
## Gotchas
|
|
614
614
|
|
|
615
615
|
- **Prepare the device once** for reliable dumps: `vk device prep` (a physical device
|
|
616
|
-
needs `--device <serial>`). Live animations can make `vk ui` flaky
|
|
616
|
+
needs `--device <serial>`). Live animations can make `vk ui` flaky.
|
|
617
617
|
- **A slept device returns the LOCK SCREEN, not an error.** The dump succeeds and hands
|
|
618
618
|
back `com.android.systemui` — so selectors miss for a reason unrelated to the app.
|
|
619
619
|
verikun detects this, wakes the device and clears a *swipe* lock automatically; on a
|
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,22 @@ All notable changes to this project are documented here. The format is based on
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.27.1] - 2026-09-14
|
|
10
|
+
|
|
11
|
+
A hierarchy read the device killed is now waited out instead of ending the test.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- **Hierarchy reads** ride out a dump the device killed, for the caller's full wait budget,
|
|
15
|
+
instead of aborting after three fast retries. ([#137])
|
|
16
|
+
- **`assert --gone` / `wait --gone`** no longer count a killed read as an absence; a window of
|
|
17
|
+
only killed reads exits `3` instead of reporting a miss. ([#137])
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- **A killed dump** names memory pressure and is never failed over or retired — the device is
|
|
21
|
+
busy, not broken. ([#137])
|
|
22
|
+
|
|
23
|
+
[#137]: https://github.com/ddikman/verikun/issues/137
|
|
24
|
+
|
|
9
25
|
## [0.27.0] - 2026-09-13
|
|
10
26
|
|
|
11
27
|
### Added
|
package/dist/agent/engine.js
CHANGED
|
@@ -89,8 +89,10 @@ exports.DEFAULT_GUARD_SETTLE_MS = 1500;
|
|
|
89
89
|
/** Re-dump cadence inside a guard's settle window. */
|
|
90
90
|
const GUARD_POLL_MS = 150;
|
|
91
91
|
/**
|
|
92
|
-
* How long a guard keeps looking at a screen it cannot read at all, when the reason is
|
|
93
|
-
*
|
|
92
|
+
* How long a guard keeps looking at a screen it cannot read at all, when the reason is one
|
|
93
|
+
* that CLEARS ON ITS OWN (`TransientReadError`): the app force-stopped or mid-launch and not
|
|
94
|
+
* yet drawn (`NoWindowError`), or the dumper SIGKILLed under memory pressure
|
|
95
|
+
* (`DumpKilledError`, issue #137).
|
|
94
96
|
*
|
|
95
97
|
* Separate from `settleMs`, which answers "how long before I believe this selector is
|
|
96
98
|
* absent?". This answers "how long before I believe there is no screen to ask?" — a
|
|
@@ -113,7 +115,7 @@ const GUARD_POLL_MS = 150;
|
|
|
113
115
|
* Not configurable on purpose: a dial here is one more thing to explain, and every value a
|
|
114
116
|
* user might pick is worse than the measurement.
|
|
115
117
|
*/
|
|
116
|
-
const
|
|
118
|
+
const TRANSIENT_READ_GRACE_MS = 10_000;
|
|
117
119
|
/** Consecutive identical screen snapshots before a loop is believed to be stuck.
|
|
118
120
|
*
|
|
119
121
|
* This check is a TIME SAVER and nothing more. A loop already fails when its exit
|
|
@@ -205,8 +207,9 @@ async function runPlan(plan, deps) {
|
|
|
205
207
|
* probe a loop-exit check needs.
|
|
206
208
|
* So one dump attempt always happens regardless of the window.
|
|
207
209
|
*
|
|
208
|
-
* A THIRD clock sits beside both: a screen that
|
|
209
|
-
*
|
|
210
|
+
* A THIRD clock sits beside both: a screen that is not READABLE AT ALL for a reason that
|
|
211
|
+
* clears on its own (a TransientReadError) is retried against TRANSIENT_READ_GRACE_MS,
|
|
212
|
+
* not against `settleMs`.
|
|
210
213
|
* That is deliberately independent — "is this selector absent?" and "is there a screen to
|
|
211
214
|
* ask at all?" are different questions, and only the second one aborts the run.
|
|
212
215
|
*
|
|
@@ -229,10 +232,10 @@ async function runPlan(plan, deps) {
|
|
|
229
232
|
if (deps.platform)
|
|
230
233
|
(0, state_support_1.assertStateSupported)(sel, deps.platform);
|
|
231
234
|
const deadline = Date.now() + Math.max(0, settleMs);
|
|
232
|
-
// A screen that cannot be read AT ALL gets its own, longer clock — see
|
|
235
|
+
// A screen that cannot be read AT ALL gets its own, longer clock — see TRANSIENT_READ_GRACE_MS.
|
|
233
236
|
// Clamped by the run deadline so a guard can never push a run past --timeout: the grace
|
|
234
237
|
// exists to spend budget the caller already has, never to invent more.
|
|
235
|
-
const
|
|
238
|
+
const transientDeadline = Math.min(Date.now() + TRANSIENT_READ_GRACE_MS, deps.deadline ?? Infinity);
|
|
236
239
|
// A non-zero window must buy at least one SECOND look, independent of the clock.
|
|
237
240
|
// Measured on emulator-5554: one uiautomator dump costs ~2.4s, which already exceeds
|
|
238
241
|
// a 1.5s window — so a purely time-boxed loop returns after a single dump and the
|
|
@@ -280,7 +283,7 @@ async function runPlan(plan, deps) {
|
|
|
280
283
|
// One successful read — even an empty tree — and the ordinary semantics resume exactly:
|
|
281
284
|
// settleMs=0 is still a single-shot probe. It must never make a merely ABSENT selector
|
|
282
285
|
// more patient, or every guard silently costs 10s.
|
|
283
|
-
if (!everRead && lastErr instanceof errors_1.
|
|
286
|
+
if (!everRead && lastErr instanceof errors_1.TransientReadError && Date.now() < transientDeadline) {
|
|
284
287
|
await (0, wait_1.sleep)(GUARD_POLL_MS);
|
|
285
288
|
continue;
|
|
286
289
|
}
|
package/dist/cli.js
CHANGED
|
@@ -917,10 +917,13 @@ async function cmdWait(ctx) {
|
|
|
917
917
|
const timeout = (0, args_1.flagNum)(ctx.flags, 'timeout') ?? 10000;
|
|
918
918
|
const interval = (0, args_1.flagNum)(ctx.flags, 'interval') ?? 400;
|
|
919
919
|
const deadline = Date.now() + timeout;
|
|
920
|
-
const barrier = new auto_wait_1.
|
|
920
|
+
const barrier = new auto_wait_1.ReadTally(ctx);
|
|
921
921
|
while (Date.now() < deadline) {
|
|
922
|
-
const { matches, tier } = (0, selector_1.matchElements)(
|
|
923
|
-
|
|
922
|
+
const { matches, tier } = (0, selector_1.matchElements)((0, auto_wait_1.readForPoll)(ctx, barrier), sel);
|
|
923
|
+
// A read that did not happen proves nothing, and `--gone` is satisfied by an EMPTY one —
|
|
924
|
+
// so without this a kill storm answers "gone" on its first poll, exit 0. Absence has to be
|
|
925
|
+
// observed. (Measured on a Pixel 3a while fixing #137.)
|
|
926
|
+
if (!barrier.lastWasBlind() && (gone ? matches.length === 0 : matches.length > 0)) {
|
|
924
927
|
ctx.record?.note({ selector: sel, tier, element: matches[0], message: gone ? 'gone' : `${matches.length} match(es)` });
|
|
925
928
|
if (gone)
|
|
926
929
|
(0, output_1.out)(`gone: '${sel.raw}'`);
|
|
@@ -930,6 +933,11 @@ async function cmdWait(ctx) {
|
|
|
930
933
|
}
|
|
931
934
|
await (0, wait_1.sleep)(interval);
|
|
932
935
|
}
|
|
936
|
+
// A window that never once READ the screen has no timeout to report — it has an environment
|
|
937
|
+
// failure, and it throws (issue #137) rather than reaching the `return 1` below. That is also
|
|
938
|
+
// what a killed dump did before this fix, so the recorded step keeps its `error` shape
|
|
939
|
+
// instead of turning into a `failure`.
|
|
940
|
+
barrier.rethrowIfBlind();
|
|
933
941
|
// A barrier can only explain a miss: with --gone the element is absent and the wait passed above.
|
|
934
942
|
const why = withStop(gone ? '' : barrier.clause());
|
|
935
943
|
ctx.record?.note({ selector: sel, message: `timeout after ${timeout}ms${gone ? ' (still present)' : ' (never appeared)'}${why}` });
|
|
@@ -979,12 +987,23 @@ async function cmdAssert(ctx) {
|
|
|
979
987
|
// Auto-wait subsumes the common "wait then assert": poll until the assertion
|
|
980
988
|
// passes or the window elapses. `--gone` therefore waits for disappearance.
|
|
981
989
|
const deadline = Date.now() + (0, auto_wait_1.waitWindowMs)(ctx.flags);
|
|
982
|
-
const barrier = new auto_wait_1.
|
|
983
|
-
|
|
990
|
+
const barrier = new auto_wait_1.ReadTally(ctx);
|
|
991
|
+
// A verdict is only worth banking if the read behind it happened. `--gone` (and `--count 0`)
|
|
992
|
+
// pass on an EMPTY tree, which is exactly what an absorbed transient hands back — so a single
|
|
993
|
+
// killed dump mid-window could otherwise bank a green the screen never showed. Poll again
|
|
994
|
+
// instead; `rethrowIfBlind()` below handles a window that stayed blind to the end.
|
|
995
|
+
const look = () => {
|
|
996
|
+
const result = evalAssert((0, auto_wait_1.readForPoll)(ctx, barrier), sel, ctx.flags);
|
|
997
|
+
return barrier.lastWasBlind() ? { ...result, pass: false } : result;
|
|
998
|
+
};
|
|
999
|
+
let result = look();
|
|
984
1000
|
while (!result.pass && Date.now() < deadline) {
|
|
985
1001
|
await (0, wait_1.sleep)((0, auto_wait_1.pollStep)(ctx.flags, deadline));
|
|
986
|
-
result =
|
|
1002
|
+
result = look();
|
|
987
1003
|
}
|
|
1004
|
+
// `--gone` PASSES on an empty read, so a window of nothing but killed dumps would report a
|
|
1005
|
+
// green earned from a screen nobody could read. Checked before `pass` is consumed (issue #137).
|
|
1006
|
+
barrier.rethrowIfBlind();
|
|
988
1007
|
const { pass, matches } = result;
|
|
989
1008
|
// Only a "not found" can be explained by a barrier: `--gone` passed if the tree was
|
|
990
1009
|
// barrier-only, and a text mismatch found the element.
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// waiting lives here. The rules a new selector-resolving command must follow are in
|
|
11
11
|
// CLAUDE.md, "Selector auto-wait".
|
|
12
12
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
-
exports.
|
|
13
|
+
exports.ReadTally = void 0;
|
|
14
14
|
exports.parseDuration = parseDuration;
|
|
15
15
|
exports.waitWindowMs = waitWindowMs;
|
|
16
16
|
exports.waitNote = waitNote;
|
|
@@ -53,48 +53,63 @@ function pollStep(flags, deadline) {
|
|
|
53
53
|
return Math.min(interval, Math.max(0, deadline - Date.now()));
|
|
54
54
|
}
|
|
55
55
|
/**
|
|
56
|
-
* Read the hierarchy for a caller that is polling, treating
|
|
57
|
-
* screen yet" rather than a fatal environment error.
|
|
56
|
+
* Read the hierarchy for a caller that is polling, treating a read that will clear on its own
|
|
57
|
+
* as "nothing on screen yet" rather than as a fatal environment error.
|
|
58
58
|
*
|
|
59
|
-
* A `
|
|
60
|
-
* `launch` both leave a gap where the app has been stopped and has not drawn
|
|
61
|
-
*
|
|
59
|
+
* A `TransientReadError` means the device could not answer *right now* — `launch --clear` and
|
|
60
|
+
* `launch` both leave a gap where the app has been stopped and has not drawn (`NoWindowError`),
|
|
61
|
+
* and a memory-pressured phone SIGKILLs the dumper outright (`DumpKilledError`, issue #137).
|
|
62
|
+
* Both clear in seconds, so a caller that has a wait budget should keep polling; escalating to
|
|
62
63
|
* exit 3 throws away the budget it was explicitly given. MEASURED: a `wait --timeout 120000`
|
|
63
|
-
* used to abort at ~20s with 100 seconds unspent.
|
|
64
|
+
* used to abort at ~20s with 100 seconds unspent, and a `wait --timeout 30000` at 2.5s.
|
|
64
65
|
*
|
|
65
66
|
* Every OTHER capture failure still propagates untouched — a missing adb, an unauthorised
|
|
66
67
|
* device or a wedged dumper is a machine to fix, and polling it for two minutes helps nobody.
|
|
68
|
+
*
|
|
69
|
+
* Pass the tally so the window can tell "the screen said nothing was there" from "nobody ever
|
|
70
|
+
* read the screen"; see `ReadTally.rethrowIfBlind`.
|
|
67
71
|
*/
|
|
68
|
-
function readForPoll(ctx, opts = {}) {
|
|
72
|
+
function readForPoll(ctx, tally, opts = {}) {
|
|
69
73
|
try {
|
|
70
|
-
|
|
74
|
+
const els = ctx.driver.getElements(opts);
|
|
75
|
+
return tally ? tally.note(els) : els;
|
|
71
76
|
}
|
|
72
77
|
catch (e) {
|
|
73
|
-
if (e instanceof errors_1.
|
|
78
|
+
if (e instanceof errors_1.TransientReadError) {
|
|
79
|
+
tally?.noteBlind(e);
|
|
74
80
|
return [];
|
|
81
|
+
}
|
|
75
82
|
throw e;
|
|
76
83
|
}
|
|
77
84
|
}
|
|
78
85
|
/**
|
|
79
|
-
*
|
|
80
|
-
*
|
|
86
|
+
* What every read in ONE poll window saw. This is the only layer that sees all of them, so
|
|
87
|
+
* both of the things a miss message needs to be honest about live here.
|
|
88
|
+
*
|
|
89
|
+
* **Was the tree barrier-only?** (issue #131) A sheet's barrier that outlives the whole wait
|
|
90
|
+
* made the step report "never appeared", sending the reader looking for a missing identifier
|
|
91
|
+
* in app code. Naming the barrier is the cheap half of that fix.
|
|
81
92
|
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* to whoever owns the wait — this is the only layer that saw every read.
|
|
93
|
+
* **Did anyone ever read the screen at all?** (issue #137) An absorbed `DumpKilledError` costs
|
|
94
|
+
* a read and yields no elements, and a window made entirely of those has no grounds to call
|
|
95
|
+
* anything absent — see `rethrowIfBlind`.
|
|
86
96
|
*/
|
|
87
|
-
class
|
|
97
|
+
class ReadTally {
|
|
88
98
|
ctx;
|
|
89
99
|
reads = 0;
|
|
100
|
+
okReads = 0;
|
|
90
101
|
barrierReads = 0;
|
|
91
102
|
last = null;
|
|
103
|
+
blind;
|
|
104
|
+
lastBlind = false;
|
|
92
105
|
constructor(ctx) {
|
|
93
106
|
this.ctx = ctx;
|
|
94
107
|
}
|
|
95
108
|
/** Record one snapshot. Returns it, so it can wrap a read in place. */
|
|
96
109
|
note(els) {
|
|
97
110
|
this.reads++;
|
|
111
|
+
this.okReads++;
|
|
112
|
+
this.lastBlind = false;
|
|
98
113
|
this.last = (0, barrier_1.modalBarrierOnly)(els, this.ctx.driver.viewport());
|
|
99
114
|
if (this.last)
|
|
100
115
|
this.barrierReads++;
|
|
@@ -106,8 +121,47 @@ class BarrierTally {
|
|
|
106
121
|
return '';
|
|
107
122
|
return (0, barrier_1.barrierClause)(this.last, this.barrierReads === this.reads);
|
|
108
123
|
}
|
|
124
|
+
/** Record a read that never happened — a transient failure `readForPoll` absorbed as `[]`. */
|
|
125
|
+
noteBlind(e) {
|
|
126
|
+
this.reads++;
|
|
127
|
+
this.blind = e;
|
|
128
|
+
this.lastBlind = true;
|
|
129
|
+
this.last = null; // a read that did not happen is not a barrier, and must not read as one
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Did the most recent read fail to happen? Then it proves NOTHING, and least of all an
|
|
133
|
+
* absence — which `--gone` counts as a pass.
|
|
134
|
+
*
|
|
135
|
+
* Separate from `rethrowIfBlind`, and it has to be: that one asks about the whole window and
|
|
136
|
+
* fires at the deadline, but a `--gone` predicate is satisfied by the FIRST empty read and
|
|
137
|
+
* returns from inside the poll loop, so a window-level check never runs. MEASURED on a
|
|
138
|
+
* Pixel 3a while fixing #137: `wait --gone` under a kill storm exited 0 reporting "gone".
|
|
139
|
+
*
|
|
140
|
+
* Both `NoWindowError` and `DumpKilledError` count here. Unlike the deadline rule, the two
|
|
141
|
+
* need no asymmetry: an absorbed read yielded no elements to judge either way, so polling
|
|
142
|
+
* once more is right for both and costs a merely-absent selector nothing.
|
|
143
|
+
*/
|
|
144
|
+
lastWasBlind() {
|
|
145
|
+
return this.lastBlind;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Refuse to report an absence this window never actually observed (issue #137).
|
|
149
|
+
*
|
|
150
|
+
* ONLY for a killed dump. The asymmetry is the point: a null root is the device ANSWERING
|
|
151
|
+
* "nothing is drawn", so "the selector is absent" is a true reading of it and
|
|
152
|
+
* `NoWindowError` keeps its existing behaviour exactly. A kill is no answer at all — and
|
|
153
|
+
* `assert --gone` turns "absent" into a PASS, so absorbing it silently would manufacture a
|
|
154
|
+
* green from a screen nobody could read.
|
|
155
|
+
*
|
|
156
|
+
* Gated on `okReads === 0`, the same `everRead` rule the engine's guard grace uses: one good
|
|
157
|
+
* read anywhere in the window means the screen was legible and an ordinary miss is honest.
|
|
158
|
+
*/
|
|
159
|
+
rethrowIfBlind() {
|
|
160
|
+
if (this.okReads === 0 && this.blind instanceof errors_1.DumpKilledError)
|
|
161
|
+
throw this.blind;
|
|
162
|
+
}
|
|
109
163
|
}
|
|
110
|
-
exports.
|
|
164
|
+
exports.ReadTally = ReadTally;
|
|
111
165
|
/**
|
|
112
166
|
* matchElements with auto-wait: re-capture + re-match until at least one element
|
|
113
167
|
* matches or the window elapses. Returns the final result either way (empty on miss),
|
|
@@ -115,11 +169,16 @@ exports.BarrierTally = BarrierTally;
|
|
|
115
169
|
*/
|
|
116
170
|
async function matchWaiting(ctx, sel, opts = {}) {
|
|
117
171
|
const deadline = Date.now() + waitWindowMs(ctx.flags);
|
|
118
|
-
const barrier = new
|
|
172
|
+
const barrier = new ReadTally(ctx);
|
|
119
173
|
for (;;) {
|
|
120
|
-
const res = (0, selector_1.matchElements)(
|
|
121
|
-
if (res.matches.length > 0
|
|
174
|
+
const res = (0, selector_1.matchElements)(readForPoll(ctx, barrier, opts), sel);
|
|
175
|
+
if (res.matches.length > 0)
|
|
122
176
|
return { ...res, barrier };
|
|
177
|
+
if (Date.now() >= deadline) {
|
|
178
|
+
// Before ANY caller can read this as an absence — `assert --gone` calls it a pass.
|
|
179
|
+
barrier.rethrowIfBlind();
|
|
180
|
+
return { ...res, barrier };
|
|
181
|
+
}
|
|
123
182
|
await (0, wait_1.sleep)(pollStep(ctx.flags, deadline));
|
|
124
183
|
}
|
|
125
184
|
}
|
|
@@ -132,9 +191,9 @@ async function resolveOneWaiting(ctx, sel, opts = {}) {
|
|
|
132
191
|
const windowMs = waitWindowMs(ctx.flags);
|
|
133
192
|
const start = Date.now();
|
|
134
193
|
const deadline = start + windowMs;
|
|
135
|
-
const barrier = new
|
|
194
|
+
const barrier = new ReadTally(ctx);
|
|
136
195
|
for (;;) {
|
|
137
|
-
const els =
|
|
196
|
+
const els = readForPoll(ctx, barrier, opts);
|
|
138
197
|
if ((0, selector_1.matchElements)(els, sel).matches.length >= 1) {
|
|
139
198
|
const { element, tier } = (0, selector_1.resolveOne)(els, sel); // 1 → resolved; >1 → throws ambiguity
|
|
140
199
|
// The snapshot rides along: scroll-into-view needs the scrollable containers
|
|
@@ -143,6 +202,7 @@ async function resolveOneWaiting(ctx, sel, opts = {}) {
|
|
|
143
202
|
return { element, tier, waitedMs: Date.now() - start, elements: els };
|
|
144
203
|
}
|
|
145
204
|
if (Date.now() >= deadline) {
|
|
205
|
+
barrier.rethrowIfBlind();
|
|
146
206
|
const waited = windowMs > 0 ? ` after ${(windowMs / 1000).toFixed(1)}s` : '';
|
|
147
207
|
throw new errors_1.SelectorNotFoundError(`No element matched selector '${sel.raw}'${waited}.${barrier.clause()} Run \`verikun ui\` to inspect the current screen.`);
|
|
148
208
|
}
|
package/dist/device/failover.js
CHANGED
|
@@ -84,16 +84,22 @@ function exitCodeOf(e) {
|
|
|
84
84
|
return e instanceof errors_1.CliError ? e.exitCode : 3;
|
|
85
85
|
}
|
|
86
86
|
const messageOf = (e) => (e instanceof Error ? e.message : String(e ?? ''));
|
|
87
|
+
/** Why this device stays. Named per class so the operator reads the actual cause, not
|
|
88
|
+
* "transient" — the two want different responses (wait vs free some memory). */
|
|
89
|
+
const transientReason = (e) => e instanceof errors_1.DumpKilledError
|
|
90
|
+
? 'the hierarchy dump was killed — the device is under memory pressure, not broken'
|
|
91
|
+
: 'the app has not drawn yet — this clears on its own';
|
|
87
92
|
/**
|
|
88
93
|
* The arms share everything except what an unrecognised exit-3 means, so they share
|
|
89
94
|
* this and differ only in `fallback`.
|
|
90
95
|
*/
|
|
91
96
|
function classify(e, fallback) {
|
|
92
|
-
// Identity first, never message text:
|
|
93
|
-
// plausibly be matched by another rule, and getting this one wrong means rotating the
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
+
// Identity first, never message text: both transient reads are exit 3 and their wording could
|
|
98
|
+
// plausibly be matched by another rule, and getting this one wrong means rotating the pool
|
|
99
|
+
// every time an app is mid-launch — or, for a killed dump, retiring a phone for being busy
|
|
100
|
+
// (issue #137).
|
|
101
|
+
if (e instanceof errors_1.TransientReadError) {
|
|
102
|
+
return { move: false, kind: 'transient', reason: transientReason(e) };
|
|
97
103
|
}
|
|
98
104
|
const code = exitCodeOf(e);
|
|
99
105
|
if (code === 0 || code === 1)
|
|
@@ -140,7 +146,7 @@ function classifyInstallFailure(e) {
|
|
|
140
146
|
const code = exitCodeOf(e);
|
|
141
147
|
// Only an environment failure is ever the device's fault; a usage error (a rejected
|
|
142
148
|
// extension, an unreadable path) is the caller's and no device fixes it.
|
|
143
|
-
if (code === 3 && !(e instanceof errors_1.
|
|
149
|
+
if (code === 3 && !(e instanceof errors_1.TransientReadError)) {
|
|
144
150
|
const message = messageOf(e);
|
|
145
151
|
// Same order as `classify` below, so the two arms can only ever differ in their
|
|
146
152
|
// DEFAULT — which is the one difference between them that is meant to exist.
|
package/dist/drivers/adb.js
CHANGED
|
@@ -5,6 +5,7 @@ exports.probeAdb = probeAdb;
|
|
|
5
5
|
exports.parseLockKind = parseLockKind;
|
|
6
6
|
exports.looksLikeSystemUi = looksLikeSystemUi;
|
|
7
7
|
exports.lockKindOf = lockKindOf;
|
|
8
|
+
exports.dumpWasKilled = dumpWasKilled;
|
|
8
9
|
exports.escapeText = escapeText;
|
|
9
10
|
exports.adbTransport = adbTransport;
|
|
10
11
|
exports.severanceRisk = severanceRisk;
|
|
@@ -204,6 +205,26 @@ const BARRIER_SETTLE_MS = 300;
|
|
|
204
205
|
* companion reports the same condition from getRootInActiveWindow(). Transient — see
|
|
205
206
|
* NoWindowError. */
|
|
206
207
|
const NULL_ROOT = /null root node/i;
|
|
208
|
+
/** 128 + SIGKILL — what a shell reports for a command the kernel killed outright. */
|
|
209
|
+
const EXIT_SIGKILL = 137;
|
|
210
|
+
/** The device shell's word for it, when the shell itself survived to say so. */
|
|
211
|
+
const KILLED_TEXT = /\bKilled\b/;
|
|
212
|
+
/**
|
|
213
|
+
* Was this read SIGKILLed rather than merely unsuccessful? (issue #137)
|
|
214
|
+
*
|
|
215
|
+
* THE EXIT CODE IS THE PRIMARY SIGNAL, not the text. MEASURED on a Pixel 3a: in the
|
|
216
|
+
* `adb shell '<cmd>'` form the device shell prints NOTHING when it is killed — both streams
|
|
217
|
+
* come back empty and only the status says 137. That is why the old message read
|
|
218
|
+
* "Failed to capture UI hierarchy after 3 attempts." with nothing after it. The word "Killed"
|
|
219
|
+
* does appear on some shells (issue #137 was reported with it), so it is kept as a second
|
|
220
|
+
* signal for an adb too old to propagate the remote status — but a matcher built on the text
|
|
221
|
+
* alone would have missed the very device this was reported from.
|
|
222
|
+
*
|
|
223
|
+
* Pure and exported for the unit suite only; nothing else imports it.
|
|
224
|
+
*/
|
|
225
|
+
function dumpWasKilled(code, text) {
|
|
226
|
+
return code === EXIT_SIGKILL || KILLED_TEXT.test(text);
|
|
227
|
+
}
|
|
207
228
|
/** Header sizes `screencap` writes before the pixels: width/height/format, plus a
|
|
208
229
|
* colorspace word since Android 9. Newest first — see `screenshotRaw`. */
|
|
209
230
|
const RAW_HEADER_SIZES = [16, 12];
|
|
@@ -898,6 +919,16 @@ class AdbDriver {
|
|
|
898
919
|
if (NULL_ROOT.test(lastErr)) {
|
|
899
920
|
throw new errors_1.NoWindowError();
|
|
900
921
|
}
|
|
922
|
+
// A KILLED dump is the same deal, with one difference: attempt 0 has a real cure for
|
|
923
|
+
// one of its two causes (a companion holding the UiAutomation connection SIGKILLs a
|
|
924
|
+
// competing dump), so it still gets the remedy below and one more try. Once THAT is
|
|
925
|
+
// killed too, stop — a third back-to-back attempt is a third sample of the same instant
|
|
926
|
+
// (#137 measured all three losing), and the caller's poll interval is the spacing that
|
|
927
|
+
// actually helps. Judged on the DUMP's own result, never the combined text: a `cat` that
|
|
928
|
+
// reports a missing file is the kill's consequence, not evidence of one.
|
|
929
|
+
if (attempt > 0 && dumpWasKilled(dump.code, `${dump.stdout} ${dump.stderr}`)) {
|
|
930
|
+
throw new errors_1.DumpKilledError((0, errors_1.dumpKilledMessage)(lastErr));
|
|
931
|
+
}
|
|
901
932
|
if (attempt === 0) {
|
|
902
933
|
// A sleeping display is the other documented cause of a failed read. `ensureAwake` ran
|
|
903
934
|
// before the dump, so reaching here means its answer went stale during a slow read (or
|
package/dist/errors.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// 2 usage error, ambiguous selector, or a device another job is driving (caller must refine)
|
|
7
7
|
// 3 environment error (adb/simctl missing, no usable device, dump failed)
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
-
exports.AmbiguousSelectorError = exports.NO_WINDOW_MESSAGE = exports.NoWindowError = exports.SelectorNotFoundError = exports.probeFailure = exports.envError = exports.usageError = exports.CliError = void 0;
|
|
9
|
+
exports.AmbiguousSelectorError = exports.DUMP_KILLED_MESSAGE = exports.dumpKilledMessage = exports.DumpKilledError = exports.NO_WINDOW_MESSAGE = exports.NoWindowError = exports.TransientReadError = exports.SelectorNotFoundError = exports.probeFailure = exports.envError = exports.usageError = exports.CliError = void 0;
|
|
10
10
|
exports.isEnvError = isEnvError;
|
|
11
11
|
class CliError extends Error {
|
|
12
12
|
exitCode;
|
|
@@ -51,6 +51,21 @@ class SelectorNotFoundError extends CliError {
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
exports.SelectorNotFoundError = SelectorNotFoundError;
|
|
54
|
+
/**
|
|
55
|
+
* A hierarchy read that failed for a reason which CLEARS ON ITS OWN within seconds.
|
|
56
|
+
*
|
|
57
|
+
* The base exists so the three layers that mean "ride this out" — `readForPoll`, the engine's
|
|
58
|
+
* guard grace, and the failover classifier — say so once, by class, instead of listing
|
|
59
|
+
* subclasses they would each have to be remembered to update. Everything narrower than "this
|
|
60
|
+
* is transient" keeps checking the concrete class (the companion only stands down for a
|
|
61
|
+
* NoWindowError, not for a kill it had nothing to do with).
|
|
62
|
+
*
|
|
63
|
+
* Still exit 3, so a caller with NO budget is unaffected: an unabsorbed one exits exactly as
|
|
64
|
+
* it did before. What the class buys is the right to be polled through, not a softer exit.
|
|
65
|
+
*/
|
|
66
|
+
class TransientReadError extends CliError {
|
|
67
|
+
}
|
|
68
|
+
exports.TransientReadError = TransientReadError;
|
|
54
69
|
/**
|
|
55
70
|
* There is no window to read right now — the app was just force-stopped, or is mid-launch
|
|
56
71
|
* and has not drawn yet. `getRootInActiveWindow()` returns null and the platform says so.
|
|
@@ -66,7 +81,7 @@ exports.SelectorNotFoundError = SelectorNotFoundError;
|
|
|
66
81
|
* and a `wait --timeout 120000` would abort at ~20s with 100 seconds of its budget unspent.
|
|
67
82
|
* The retry belongs to the caller that knows how long it is willing to wait.
|
|
68
83
|
*/
|
|
69
|
-
class NoWindowError extends
|
|
84
|
+
class NoWindowError extends TransientReadError {
|
|
70
85
|
constructor(message = exports.NO_WINDOW_MESSAGE) {
|
|
71
86
|
super(message, 3);
|
|
72
87
|
this.name = 'NoWindowError';
|
|
@@ -86,6 +101,45 @@ exports.NoWindowError = NoWindowError;
|
|
|
86
101
|
*/
|
|
87
102
|
exports.NO_WINDOW_MESSAGE = 'No window to read: the app has no drawn window right now — force-stopped, mid-launch, or ' +
|
|
88
103
|
'its main thread is busy mid-transition. This normally clears within a few seconds.';
|
|
104
|
+
/**
|
|
105
|
+
* The dump process was SIGKILLed before it could answer (issue #137).
|
|
106
|
+
*
|
|
107
|
+
* NOT the same signal as NoWindowError, and the difference is the whole reason this is its own
|
|
108
|
+
* class. A null root is the device ANSWERING "nothing is drawn", so "the selector is absent" is
|
|
109
|
+
* a true reading of it. A kill is no answer at all, so calling the selector absent would be a
|
|
110
|
+
* fabrication — which is why a poll window that never once read the screen re-throws this
|
|
111
|
+
* instead of reporting a miss (`ReadTally.rethrowIfBlind`).
|
|
112
|
+
*
|
|
113
|
+
* MEASURED on a 4 GB-class phone (#137): the OS reaps the dumper while an app cold-starts, and
|
|
114
|
+
* the driver's three attempts fired back-to-back all landed inside the same second — so a
|
|
115
|
+
* `wait` holding a two-minute budget aborted at ~2.5s with 117 seconds unspent. Same rule as
|
|
116
|
+
* NoWindowError: the driver hands it up, the caller spends its own clock on it.
|
|
117
|
+
*/
|
|
118
|
+
class DumpKilledError extends TransientReadError {
|
|
119
|
+
constructor(message = exports.DUMP_KILLED_MESSAGE) {
|
|
120
|
+
super(message, 3);
|
|
121
|
+
this.name = 'DumpKilledError';
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
exports.DumpKilledError = DumpKilledError;
|
|
125
|
+
/** The wording plus whatever the device actually said, for the one thrower that has evidence.
|
|
126
|
+
* Separate from the constructor so the wire codec can rebuild a message losslessly rather
|
|
127
|
+
* than re-prefixing one that already carries its detail. */
|
|
128
|
+
const dumpKilledMessage = (detail) => detail ? `${exports.DUMP_KILLED_MESSAGE} (${detail})` : exports.DUMP_KILLED_MESSAGE;
|
|
129
|
+
exports.dumpKilledMessage = dumpKilledMessage;
|
|
130
|
+
/**
|
|
131
|
+
* Names the CAUSE, because "the dump was killed" and "the device left adb" both used to arrive
|
|
132
|
+
* as `Failed to capture UI hierarchy after 3 attempts` and want opposite responses from whoever
|
|
133
|
+
* reads the report — wait vs go and find the phone.
|
|
134
|
+
*
|
|
135
|
+
* Both causes are named because both are real and the fix for each is different: memory pressure
|
|
136
|
+
* (wait, or test on a device with more headroom) and a competing UiAutomation client (stop it).
|
|
137
|
+
* verikun's own companion is the second one, and the driver already tries to clear that itself
|
|
138
|
+
* before this is ever thrown.
|
|
139
|
+
*/
|
|
140
|
+
exports.DUMP_KILLED_MESSAGE = 'The UI hierarchy dump was killed before it could answer — the device reclaiming memory ' +
|
|
141
|
+
'while an app starts, or another tool holding the one UiAutomation connection. This ' +
|
|
142
|
+
'normally clears within seconds.';
|
|
89
143
|
/** Selector matched >1 element. Exit 2. Carries the candidates so the agent runner
|
|
90
144
|
* can ask the model to disambiguate (a heal trigger) instead of aborting. */
|
|
91
145
|
class AmbiguousSelectorError extends CliError {
|
package/dist/rpc.js
CHANGED
|
@@ -22,11 +22,15 @@ function describeError(e) {
|
|
|
22
22
|
if (e instanceof errors_1.SelectorNotFoundError) {
|
|
23
23
|
return { kind: 'SelectorNotFoundError', name: e.name, message: e.message, exitCode: e.exitCode };
|
|
24
24
|
}
|
|
25
|
-
// BEFORE the CliError arm —
|
|
26
|
-
// first or the identity is flattened away. device/failover.ts classifies on
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
25
|
+
// BEFORE the CliError arm — both transient reads extend it, so a subclass check must come
|
|
26
|
+
// first or the identity is flattened away. device/failover.ts classifies on the CLASS
|
|
27
|
+
// deliberately ("identity first, never message text"), and losing it turns every mid-launch
|
|
28
|
+
// gap into an unknown that costs two device probes and can quarantine a perfectly healthy
|
|
29
|
+
// phone. Flattening DumpKilledError also costs a poller its ride-out, which is the whole of
|
|
30
|
+
// issue #137 — and #137 was reported through a pooled `vk server`, i.e. across this wire.
|
|
31
|
+
if (e instanceof errors_1.DumpKilledError) {
|
|
32
|
+
return { kind: 'DumpKilledError', name: e.name, message: e.message, exitCode: e.exitCode };
|
|
33
|
+
}
|
|
30
34
|
if (e instanceof errors_1.NoWindowError) {
|
|
31
35
|
return { kind: 'NoWindowError', name: e.name, message: e.message, exitCode: e.exitCode };
|
|
32
36
|
}
|
|
@@ -45,6 +49,8 @@ function rebuildError(d) {
|
|
|
45
49
|
return new errors_1.SelectorNotFoundError(d.message);
|
|
46
50
|
case 'NoWindowError':
|
|
47
51
|
return new errors_1.NoWindowError(d.message);
|
|
52
|
+
case 'DumpKilledError':
|
|
53
|
+
return new errors_1.DumpKilledError(d.message);
|
|
48
54
|
case 'CliError':
|
|
49
55
|
return new errors_1.CliError(d.message, d.exitCode);
|
|
50
56
|
default: {
|
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.27.
|
|
6
|
+
exports.VERSION = '0.27.1';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "verikun",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.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",
|