verikun 0.26.0 → 0.26.2
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 +6 -1
- package/CHANGELOG.md +32 -0
- package/dist/agent/engine.js +52 -0
- package/dist/agent/remote.js +20 -0
- package/dist/cli.js +18 -8
- package/dist/commands/auto-wait.js +43 -5
- package/dist/companion/manager.js +23 -2
- package/dist/drivers/adb.js +64 -4
- package/dist/errors.js +15 -2
- package/dist/server-http.js +8 -1
- package/dist/server.js +11 -1
- package/dist/ui/barrier.js +113 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -233,6 +233,9 @@ round-trips and tokens.
|
|
|
233
233
|
away (the elements are already there); add `--index N` or refine the selector.
|
|
234
234
|
- **`vk assert <sel> --gone` waits for *disappearance*** — it polls until the
|
|
235
235
|
element is absent, so you don't need a separate `wait --gone`.
|
|
236
|
+
- **A modal's barrier is not an absence.** Reads settle through a sheet or dialog opening or
|
|
237
|
+
closing on their own. A miss saying *the hierarchy held only a modal barrier* means a modal
|
|
238
|
+
is still up — dismiss it (`vk tap desc:Scrim`, or `vk key back`); the selector is fine.
|
|
236
239
|
|
|
237
240
|
When you *do* want to block on a condition as an explicit step (e.g. a long
|
|
238
241
|
network wait beyond 5s), the `wait` command is still there with its own
|
|
@@ -390,7 +393,9 @@ vk ai onboarding.md --timeout 5m # tighten the run timeout (default 15m)
|
|
|
390
393
|
the flow. An `if-present` guard **waits for its selector to settle** (at least two looks
|
|
391
394
|
at the screen) before deciding the optional UI is absent, so a dialog that animates in a
|
|
392
395
|
beat after the transition is still caught. An absent guard costs about one extra UI dump;
|
|
393
|
-
`VERIKUN_GUARD_SETTLE_MS=0` restores the old single-shot probe.
|
|
396
|
+
`VERIKUN_GUARD_SETTLE_MS=0` restores the old single-shot probe. A guard that cannot read the
|
|
397
|
+
screen **at all** — the app force-stopped, mid-launch, or busy mid-transition — keeps looking
|
|
398
|
+
for up to 10s rather than aborting; still blind after that is exit `3`, never "absent".
|
|
394
399
|
- **A compile has to cover the test.** Compilation is nondeterministic, and its worst outcome
|
|
395
400
|
is a plan that stops part-way: it asserts nothing after that point, so it *passes*, caches
|
|
396
401
|
green, and replays against later builds — a test exercising none of its subject reporting
|
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,38 @@ All notable changes to this project are documented here. The format is based on
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.26.2] - 2026-09-10
|
|
10
|
+
|
|
11
|
+
Android hierarchy reads no longer mistake a sheet's or dialog's barrier for an empty screen.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- **Android hierarchy reads** re-read a tree holding only a sheet's or dialog's barrier before
|
|
15
|
+
trusting it, `vk ui` included; a barrier that persists still misses. ([#131])
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- **Selector misses** (`tap`, `find`, `assert`, `wait`) name a modal barrier that was all the read
|
|
19
|
+
could see, instead of "never appeared". ([#131])
|
|
20
|
+
|
|
21
|
+
[#131]: https://github.com/ddikman/verikun/issues/131
|
|
22
|
+
|
|
23
|
+
## [0.26.1] - 2026-09-07
|
|
24
|
+
|
|
25
|
+
Fixes a `vk ai` run dying when the app is redrawing at the moment a guard checks the screen.
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
- **`errorKind` on every `vk server` error response**: a device error keeps its class over the
|
|
29
|
+
wire, not only on `/v1/exec`. Absent from older servers. ([#80])
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
- **`vk ai` guards** now ride out a transient "no window" for up to 10s instead of aborting the
|
|
33
|
+
run. A bare `vk ui` still exits `3`. ([#80])
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
- **"No window to read"** now names a busy main thread mid-transition as a cause, and no longer
|
|
37
|
+
suggests a command that waits. ([#80])
|
|
38
|
+
|
|
39
|
+
[#80]: https://github.com/ddikman/verikun/issues/80
|
|
40
|
+
|
|
9
41
|
## [0.26.0] - 2026-09-05
|
|
10
42
|
|
|
11
43
|
Runs a suite across a pool of devices, and shares prose between tests with `@include`.
|
package/dist/agent/engine.js
CHANGED
|
@@ -88,6 +88,32 @@ exports.DEFAULT_RUN_TIMEOUT_MS = 15 * 60 * 1000;
|
|
|
88
88
|
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
|
+
/**
|
|
92
|
+
* How long a guard keeps looking at a screen it cannot read at all, when the reason is
|
|
93
|
+
* NoWindowError — the app was force-stopped or is mid-launch and has genuinely not drawn.
|
|
94
|
+
*
|
|
95
|
+
* Separate from `settleMs`, which answers "how long before I believe this selector is
|
|
96
|
+
* absent?". This answers "how long before I believe there is no screen to ask?" — a
|
|
97
|
+
* different question, and the only one whose wrong answer aborts the whole run.
|
|
98
|
+
*
|
|
99
|
+
* MEASURED (issue #80), post-`vk launch`, time until the first hierarchy read succeeds:
|
|
100
|
+
* emulator-5554 (stock reads ~2s): 2027-2379ms over 10 launches
|
|
101
|
+
* SM-A415F, physical (companion ~0.3s): 4746-6219ms over 15 launches
|
|
102
|
+
* So the "clears within a second or two" in NoWindowError's own doc is optimistic by 2.5x on
|
|
103
|
+
* real hardware; the emulator only looks compliant because one slow read already outlasts the
|
|
104
|
+
* gap. 10s clears the worst observed by ~1.6x. Below that the constant sits INSIDE the
|
|
105
|
+
* measured distribution, which is the one place it must not be.
|
|
106
|
+
*
|
|
107
|
+
* Deliberately MORE patient than a leaf command's 5s auto-wait, which is the opposite of
|
|
108
|
+
* DEFAULT_GUARD_SETTLE_MS's reasoning, because the asymmetry is real: a leaf that needs
|
|
109
|
+
* longer takes `--wait`, whereas a guard's patience is internal and a test author cannot
|
|
110
|
+
* reach it. Being generous costs at most this long ONCE per present() call, on a run that is
|
|
111
|
+
* already failing — against a 15-minute default run timeout, and clamped by it.
|
|
112
|
+
*
|
|
113
|
+
* Not configurable on purpose: a dial here is one more thing to explain, and every value a
|
|
114
|
+
* user might pick is worse than the measurement.
|
|
115
|
+
*/
|
|
116
|
+
const NO_WINDOW_GRACE_MS = 10_000;
|
|
91
117
|
/** Consecutive identical screen snapshots before a loop is believed to be stuck.
|
|
92
118
|
*
|
|
93
119
|
* This check is a TIME SAVER and nothing more. A loop already fails when its exit
|
|
@@ -179,6 +205,11 @@ async function runPlan(plan, deps) {
|
|
|
179
205
|
* probe a loop-exit check needs.
|
|
180
206
|
* So one dump attempt always happens regardless of the window.
|
|
181
207
|
*
|
|
208
|
+
* A THIRD clock sits beside both: a screen that cannot be read because the app has not
|
|
209
|
+
* drawn (NoWindowError) is retried against NO_WINDOW_GRACE_MS, not against `settleMs`.
|
|
210
|
+
* That is deliberately independent — "is this selector absent?" and "is there a screen to
|
|
211
|
+
* ask at all?" are different questions, and only the second one aborts the run.
|
|
212
|
+
*
|
|
182
213
|
* Throws GuardBlindError when the window closes having NEVER once read the screen
|
|
183
214
|
* and the failure was an environment error — see that class for why. */
|
|
184
215
|
const present = async (selector, settleMs) => {
|
|
@@ -198,6 +229,10 @@ async function runPlan(plan, deps) {
|
|
|
198
229
|
if (deps.platform)
|
|
199
230
|
(0, state_support_1.assertStateSupported)(sel, deps.platform);
|
|
200
231
|
const deadline = Date.now() + Math.max(0, settleMs);
|
|
232
|
+
// A screen that cannot be read AT ALL gets its own, longer clock — see NO_WINDOW_GRACE_MS.
|
|
233
|
+
// Clamped by the run deadline so a guard can never push a run past --timeout: the grace
|
|
234
|
+
// exists to spend budget the caller already has, never to invent more.
|
|
235
|
+
const noWindowDeadline = Math.min(Date.now() + NO_WINDOW_GRACE_MS, deps.deadline ?? Infinity);
|
|
201
236
|
// A non-zero window must buy at least one SECOND look, independent of the clock.
|
|
202
237
|
// Measured on emulator-5554: one uiautomator dump costs ~2.4s, which already exceeds
|
|
203
238
|
// a 1.5s window — so a purely time-boxed loop returns after a single dump and the
|
|
@@ -235,9 +270,26 @@ async function runPlan(plan, deps) {
|
|
|
235
270
|
return true;
|
|
236
271
|
const remaining = deadline - Date.now();
|
|
237
272
|
if (looks >= minLooks && remaining <= 0) {
|
|
273
|
+
// Nothing has been readable yet, and the reason is that the app has not drawn. That is
|
|
274
|
+
// an observation about the SCREEN, not a broken machine, and it clears on its own — so
|
|
275
|
+
// keep looking on the no-window clock instead of killing the run. MEASURED (#80): the
|
|
276
|
+
// two attempts above span ~75ms against a gap of 4.7-6.2s on a physical device, so
|
|
277
|
+
// without this a `repeat` with minutes of budget gives up in under a tenth of a second.
|
|
278
|
+
//
|
|
279
|
+
// Gated on `everRead` so this can only ever extend patience for an UNREADABLE screen.
|
|
280
|
+
// One successful read — even an empty tree — and the ordinary semantics resume exactly:
|
|
281
|
+
// settleMs=0 is still a single-shot probe. It must never make a merely ABSENT selector
|
|
282
|
+
// more patient, or every guard silently costs 10s.
|
|
283
|
+
if (!everRead && lastErr instanceof errors_1.NoWindowError && Date.now() < noWindowDeadline) {
|
|
284
|
+
await (0, wait_1.sleep)(GUARD_POLL_MS);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
238
287
|
// The window closed having NEVER once read the screen, because the environment is
|
|
239
288
|
// broken. Answering "absent" here is a lie that silently skips the body — and a
|
|
240
289
|
// guard-heavy plan would then finish fully GREEN having executed nothing.
|
|
290
|
+
//
|
|
291
|
+
// A no-window that outlives its grace lands here too, and still aborts: at that point
|
|
292
|
+
// the app really is gone, and reporting "absent" would be the same false green.
|
|
241
293
|
if (!everRead && (0, errors_1.isEnvError)(lastErr))
|
|
242
294
|
throw new GuardBlindError(selector, lastErr);
|
|
243
295
|
return false;
|
package/dist/agent/remote.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// into the CALLER's local run — so a remote run archives a report identical to a
|
|
10
10
|
// local one. Recording stays a caller concern: this module never touches ./.verikun.
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.describeStatus = describeStatus;
|
|
12
13
|
exports.pingServer = pingServer;
|
|
13
14
|
exports.remoteDeviceOp = remoteDeviceOp;
|
|
14
15
|
exports.remoteDeviceList = remoteDeviceList;
|
|
@@ -30,6 +31,19 @@ const DEVICE_LIST_TIMEOUT_MS = 30_000;
|
|
|
30
31
|
const DEVICE_START_TIMEOUT_MS = 5 * 60_000;
|
|
31
32
|
const DEVICE_STOP_TIMEOUT_MS = 60_000;
|
|
32
33
|
const trimUrl = (url) => url.replace(/\/+$/, '');
|
|
34
|
+
/**
|
|
35
|
+
* Turn a non-2xx into the error the caller sees.
|
|
36
|
+
*
|
|
37
|
+
* The 401/409/503 arms come FIRST and stay class-free on purpose: those describe the
|
|
38
|
+
* TRANSPORT (wrong key, device leased, nothing attached), not something a driver threw, so
|
|
39
|
+
* there is no device-error identity to restore and their wording is what a user acts on.
|
|
40
|
+
*
|
|
41
|
+
* Everything else prefers the server's `errorKind`. That field is what stops a `--server` run
|
|
42
|
+
* reading a mid-launch `NoWindowError` as a fatal environment error: the class survives the
|
|
43
|
+
* worker→main hop server-side, and this is where it used to be replaced by an anonymous
|
|
44
|
+
* `CliError` (issue #80). No field — an older server, or a failure with no class worth
|
|
45
|
+
* naming — falls through to exactly the previous behaviour.
|
|
46
|
+
*/
|
|
33
47
|
function describeStatus(status, body, url) {
|
|
34
48
|
const detail = body?.error ? `: ${body.error}` : '';
|
|
35
49
|
if (status === 401) {
|
|
@@ -44,6 +58,12 @@ function describeStatus(status, body, url) {
|
|
|
44
58
|
// The server sends the intended exit code (usage 2 / env 3) in the body; fall
|
|
45
59
|
// back on the HTTP class when it didn't.
|
|
46
60
|
const exitCode = body?.exitCode ?? (status === 400 || status === 404 || status === 413 ? 2 : 3);
|
|
61
|
+
if (body?.errorKind) {
|
|
62
|
+
// The server's own message, NOT the `verikun server error 500 at <url>` wrapper: this is
|
|
63
|
+
// a device error that happens to have travelled, and it reads (and matches) the same as
|
|
64
|
+
// the local one. The same shape /v1/exec's 200-with-descriptor path already produces.
|
|
65
|
+
return (0, rpc_1.rebuildError)({ kind: body.errorKind, name: body.errorKind, message: body.error, exitCode });
|
|
66
|
+
}
|
|
47
67
|
return new errors_1.CliError(`verikun server error ${status} at ${url}${detail}`, exitCode);
|
|
48
68
|
}
|
|
49
69
|
async function readBody(res) {
|
package/dist/cli.js
CHANGED
|
@@ -596,13 +596,16 @@ function cmdUi(ctx) {
|
|
|
596
596
|
(0, output_1.out)((0, args_1.flagBool)(ctx.flags, 'tree') ? (0, format_1.formatTree)(els) : (0, format_1.formatCompact)(els));
|
|
597
597
|
return 0;
|
|
598
598
|
}
|
|
599
|
+
/** A barrier clause joined onto a message that had no full stop of its own — and nothing at
|
|
600
|
+
* all when there is no clause, so the ordinary message stays byte-identical. */
|
|
601
|
+
const withStop = (clause) => (clause ? `.${clause}` : '');
|
|
599
602
|
async function cmdFind(ctx) {
|
|
600
603
|
const sel = buildSelector(ctx, ctx.positionals[0]);
|
|
601
|
-
const { matches, tier } = await (0, auto_wait_1.matchWaiting)(ctx, sel, { all: (0, args_1.flagBool)(ctx.flags, 'all') });
|
|
604
|
+
const { matches, tier, barrier } = await (0, auto_wait_1.matchWaiting)(ctx, sel, { all: (0, args_1.flagBool)(ctx.flags, 'all') });
|
|
602
605
|
if ((0, args_1.flagBool)(ctx.flags, 'json'))
|
|
603
606
|
(0, output_1.json)(matches.map(format_1.toJsonShape));
|
|
604
607
|
else if (!matches.length)
|
|
605
|
-
(0, output_1.err)(`no match for '${sel.raw}'`);
|
|
608
|
+
(0, output_1.err)(`no match for '${sel.raw}'${withStop(barrier.clause())}`);
|
|
606
609
|
else {
|
|
607
610
|
(0, output_1.out)((0, format_1.formatCompact)(matches));
|
|
608
611
|
if (tier && tier !== 'exact')
|
|
@@ -907,8 +910,9 @@ async function cmdWait(ctx) {
|
|
|
907
910
|
const timeout = (0, args_1.flagNum)(ctx.flags, 'timeout') ?? 10000;
|
|
908
911
|
const interval = (0, args_1.flagNum)(ctx.flags, 'interval') ?? 400;
|
|
909
912
|
const deadline = Date.now() + timeout;
|
|
913
|
+
const barrier = new auto_wait_1.BarrierTally(ctx);
|
|
910
914
|
while (Date.now() < deadline) {
|
|
911
|
-
const { matches, tier } = (0, selector_1.matchElements)((0, auto_wait_1.readForPoll)(ctx), sel);
|
|
915
|
+
const { matches, tier } = (0, selector_1.matchElements)(barrier.note((0, auto_wait_1.readForPoll)(ctx)), sel);
|
|
912
916
|
if (gone ? matches.length === 0 : matches.length > 0) {
|
|
913
917
|
ctx.record?.note({ selector: sel, tier, element: matches[0], message: gone ? 'gone' : `${matches.length} match(es)` });
|
|
914
918
|
if (gone)
|
|
@@ -919,8 +923,10 @@ async function cmdWait(ctx) {
|
|
|
919
923
|
}
|
|
920
924
|
await (0, wait_1.sleep)(interval);
|
|
921
925
|
}
|
|
922
|
-
|
|
923
|
-
|
|
926
|
+
// A barrier can only explain a miss: with --gone the element is absent and the wait passed above.
|
|
927
|
+
const why = withStop(gone ? '' : barrier.clause());
|
|
928
|
+
ctx.record?.note({ selector: sel, message: `timeout after ${timeout}ms${gone ? ' (still present)' : ' (never appeared)'}${why}` });
|
|
929
|
+
(0, output_1.err)(`timeout after ${timeout}ms waiting for '${sel.raw}'${gone ? ' to disappear' : ''}${why}`);
|
|
924
930
|
return 1;
|
|
925
931
|
}
|
|
926
932
|
/** Evaluate an assertion against a single captured snapshot. */
|
|
@@ -966,12 +972,16 @@ async function cmdAssert(ctx) {
|
|
|
966
972
|
// Auto-wait subsumes the common "wait then assert": poll until the assertion
|
|
967
973
|
// passes or the window elapses. `--gone` therefore waits for disappearance.
|
|
968
974
|
const deadline = Date.now() + (0, auto_wait_1.waitWindowMs)(ctx.flags);
|
|
969
|
-
|
|
975
|
+
const barrier = new auto_wait_1.BarrierTally(ctx);
|
|
976
|
+
let result = evalAssert(barrier.note((0, auto_wait_1.readForPoll)(ctx)), sel, ctx.flags);
|
|
970
977
|
while (!result.pass && Date.now() < deadline) {
|
|
971
978
|
await (0, wait_1.sleep)((0, auto_wait_1.pollStep)(ctx.flags, deadline));
|
|
972
|
-
result = evalAssert((0, auto_wait_1.readForPoll)(ctx), sel, ctx.flags);
|
|
979
|
+
result = evalAssert(barrier.note((0, auto_wait_1.readForPoll)(ctx)), sel, ctx.flags);
|
|
973
980
|
}
|
|
974
|
-
const { pass,
|
|
981
|
+
const { pass, matches } = result;
|
|
982
|
+
// Only a "not found" can be explained by a barrier: `--gone` passed if the tree was
|
|
983
|
+
// barrier-only, and a text mismatch found the element.
|
|
984
|
+
const reason = !pass && result.reason === 'not found' ? `not found${withStop(barrier.clause())}` : result.reason;
|
|
975
985
|
ctx.record?.note({ selector: sel, element: matches[0], message: `${pass ? 'PASS' : 'FAIL'} — ${reason}` });
|
|
976
986
|
if ((0, args_1.flagBool)(ctx.flags, 'json'))
|
|
977
987
|
(0, output_1.json)({ pass, selector: sel.raw, reason, matches: matches.map(format_1.toJsonShape) });
|
|
@@ -10,6 +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.BarrierTally = void 0;
|
|
13
14
|
exports.parseDuration = parseDuration;
|
|
14
15
|
exports.waitWindowMs = waitWindowMs;
|
|
15
16
|
exports.waitNote = waitNote;
|
|
@@ -19,6 +20,7 @@ exports.matchWaiting = matchWaiting;
|
|
|
19
20
|
exports.resolveOneWaiting = resolveOneWaiting;
|
|
20
21
|
const args_1 = require("../args");
|
|
21
22
|
const errors_1 = require("../errors");
|
|
23
|
+
const barrier_1 = require("../ui/barrier");
|
|
22
24
|
const selector_1 = require("../ui/selector");
|
|
23
25
|
const wait_1 = require("../wait");
|
|
24
26
|
const DEFAULT_WAIT_MS = 5000;
|
|
@@ -73,16 +75,51 @@ function readForPoll(ctx, opts = {}) {
|
|
|
73
75
|
throw e;
|
|
74
76
|
}
|
|
75
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Keeps track of whether the snapshots a poll loop read were barrier-only trees (see
|
|
80
|
+
* ui/barrier.ts), so the failure at the end can say so instead of "never appeared".
|
|
81
|
+
*
|
|
82
|
+
* A sheet's barrier that outlives the whole wait is the report behind issue #131: the
|
|
83
|
+
* step waited 30s on a painted sheet and the message sent the reader looking for a missing
|
|
84
|
+
* identifier in app code. Naming the barrier is the cheap half of that fix, and it belongs
|
|
85
|
+
* to whoever owns the wait — this is the only layer that saw every read.
|
|
86
|
+
*/
|
|
87
|
+
class BarrierTally {
|
|
88
|
+
ctx;
|
|
89
|
+
reads = 0;
|
|
90
|
+
barrierReads = 0;
|
|
91
|
+
last = null;
|
|
92
|
+
constructor(ctx) {
|
|
93
|
+
this.ctx = ctx;
|
|
94
|
+
}
|
|
95
|
+
/** Record one snapshot. Returns it, so it can wrap a read in place. */
|
|
96
|
+
note(els) {
|
|
97
|
+
this.reads++;
|
|
98
|
+
this.last = (0, barrier_1.modalBarrierOnly)(els, this.ctx.driver.viewport());
|
|
99
|
+
if (this.last)
|
|
100
|
+
this.barrierReads++;
|
|
101
|
+
return els;
|
|
102
|
+
}
|
|
103
|
+
/** The clause to append to a miss, or '' when the last read was not barrier-only. */
|
|
104
|
+
clause() {
|
|
105
|
+
if (!this.last)
|
|
106
|
+
return '';
|
|
107
|
+
return (0, barrier_1.barrierClause)(this.last, this.barrierReads === this.reads);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
exports.BarrierTally = BarrierTally;
|
|
76
111
|
/**
|
|
77
112
|
* matchElements with auto-wait: re-capture + re-match until at least one element
|
|
78
|
-
* matches or the window elapses. Returns the final result either way (empty on miss)
|
|
113
|
+
* matches or the window elapses. Returns the final result either way (empty on miss),
|
|
114
|
+
* plus the barrier tally so the caller's miss message can name a barrier.
|
|
79
115
|
*/
|
|
80
116
|
async function matchWaiting(ctx, sel, opts = {}) {
|
|
81
117
|
const deadline = Date.now() + waitWindowMs(ctx.flags);
|
|
118
|
+
const barrier = new BarrierTally(ctx);
|
|
82
119
|
for (;;) {
|
|
83
|
-
const res = (0, selector_1.matchElements)(readForPoll(ctx, opts), sel);
|
|
120
|
+
const res = (0, selector_1.matchElements)(barrier.note(readForPoll(ctx, opts)), sel);
|
|
84
121
|
if (res.matches.length > 0 || Date.now() >= deadline)
|
|
85
|
-
return res;
|
|
122
|
+
return { ...res, barrier };
|
|
86
123
|
await (0, wait_1.sleep)(pollStep(ctx.flags, deadline));
|
|
87
124
|
}
|
|
88
125
|
}
|
|
@@ -95,8 +132,9 @@ async function resolveOneWaiting(ctx, sel, opts = {}) {
|
|
|
95
132
|
const windowMs = waitWindowMs(ctx.flags);
|
|
96
133
|
const start = Date.now();
|
|
97
134
|
const deadline = start + windowMs;
|
|
135
|
+
const barrier = new BarrierTally(ctx);
|
|
98
136
|
for (;;) {
|
|
99
|
-
const els = readForPoll(ctx, opts);
|
|
137
|
+
const els = barrier.note(readForPoll(ctx, opts));
|
|
100
138
|
if ((0, selector_1.matchElements)(els, sel).matches.length >= 1) {
|
|
101
139
|
const { element, tier } = (0, selector_1.resolveOne)(els, sel); // 1 → resolved; >1 → throws ambiguity
|
|
102
140
|
// The snapshot rides along: scroll-into-view needs the scrollable containers
|
|
@@ -106,7 +144,7 @@ async function resolveOneWaiting(ctx, sel, opts = {}) {
|
|
|
106
144
|
}
|
|
107
145
|
if (Date.now() >= deadline) {
|
|
108
146
|
const waited = windowMs > 0 ? ` after ${(windowMs / 1000).toFixed(1)}s` : '';
|
|
109
|
-
throw new errors_1.SelectorNotFoundError(`No element matched selector '${sel.raw}'${waited}. Run \`verikun ui\` to inspect the current screen.`);
|
|
147
|
+
throw new errors_1.SelectorNotFoundError(`No element matched selector '${sel.raw}'${waited}.${barrier.clause()} Run \`verikun ui\` to inspect the current screen.`);
|
|
110
148
|
}
|
|
111
149
|
await (0, wait_1.sleep)(pollStep(ctx.flags, deadline));
|
|
112
150
|
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
11
|
exports.Companion = void 0;
|
|
12
12
|
exports.nullRootAction = nullRootAction;
|
|
13
|
+
exports.barrierRecycleDue = barrierRecycleDue;
|
|
13
14
|
exports.companionJarPath = companionJarPath;
|
|
14
15
|
exports.companionEnabled = companionEnabled;
|
|
15
16
|
exports.releaseCompanionOn = releaseCompanionOn;
|
|
@@ -102,6 +103,24 @@ function nullRootAction(runMs, alreadyRecycled) {
|
|
|
102
103
|
return 'fallback';
|
|
103
104
|
return runMs < WEDGE_AFTER_MS ? 'propagate' : 'recycle';
|
|
104
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Is a run of barrier-only reads (see ui/barrier.ts) long enough to suspect the CONNECTION
|
|
108
|
+
* rather than the screen?
|
|
109
|
+
*
|
|
110
|
+
* Same clock as a null root, for the same reason: a held UiAutomation connection is the one
|
|
111
|
+
* component that can keep serving a stale answer after the screen has moved on, and a
|
|
112
|
+
* release + acquire is the one thing measured to clear it (a null root for a live window
|
|
113
|
+
* on a Pixel 3a; a foreign window, issue #79). A barrier that outlives the sheet's own
|
|
114
|
+
* entrance by this much is either a modal the test should have dismissed — in which case a
|
|
115
|
+
* recycle costs ~1s once — or that same staleness wearing a different hierarchy. The report
|
|
116
|
+
* behind this (issue #131) waited 30s on a painted sheet; it did not reproduce here, so the
|
|
117
|
+
* recycle is the cheap insurance and not a measured cure.
|
|
118
|
+
*
|
|
119
|
+
* Once per run: the recycle IS the test, so a second one would only prove it again.
|
|
120
|
+
*/
|
|
121
|
+
function barrierRecycleDue(runMs, alreadyRecycled) {
|
|
122
|
+
return !alreadyRecycled && runMs >= WEDGE_AFTER_MS;
|
|
123
|
+
}
|
|
105
124
|
/** The packaged companion jar: shipped in the npm tarball, and present in a source checkout
|
|
106
125
|
* once `tools/verikun-companion/build.sh` has run. Absent means "no companion available",
|
|
107
126
|
* which is a normal state, not an error. */
|
|
@@ -290,10 +309,12 @@ class Companion {
|
|
|
290
309
|
case 'propagate':
|
|
291
310
|
break;
|
|
292
311
|
}
|
|
293
|
-
throw new errors_1.NoWindowError(
|
|
312
|
+
throw new errors_1.NoWindowError();
|
|
294
313
|
}
|
|
295
314
|
/** Hand the UiAutomation connection back and take it again. The one thing measured to clear
|
|
296
|
-
* a stale connection — it is what the stock path's releaseCompanionOn() does by accident.
|
|
315
|
+
* a stale connection — it is what the stock path's releaseCompanionOn() does by accident.
|
|
316
|
+
* Public for ONE caller besides onNullRoot: the driver's barrier settle (see
|
|
317
|
+
* `AdbDriver.settleBarrier`), which has the same suspicion for a different hierarchy. */
|
|
297
318
|
recycleConnection() {
|
|
298
319
|
try {
|
|
299
320
|
(0, protocol_1.requestSync)(this.port, 'release', 5000);
|
package/dist/drivers/adb.js
CHANGED
|
@@ -27,6 +27,7 @@ const manager_1 = require("../companion/manager");
|
|
|
27
27
|
const errors_1 = require("../errors");
|
|
28
28
|
const exec_1 = require("../exec");
|
|
29
29
|
const android_parse_1 = require("../ui/android-parse");
|
|
30
|
+
const barrier_1 = require("../ui/barrier");
|
|
30
31
|
const viewport_1 = require("../ui/viewport");
|
|
31
32
|
const settings_1 = require("../device/settings");
|
|
32
33
|
const claims_1 = require("../device/claims");
|
|
@@ -188,6 +189,16 @@ const DUMP_PATHS = ['/sdcard/window_dump.xml', '/data/local/tmp/window_dump.xml'
|
|
|
188
189
|
* costs the same ~10ms as `dump 0`). The stock path pays a full second for it only
|
|
189
190
|
* because a freshly connected bridge has no history of quiet to draw on. */
|
|
190
191
|
const COMPANION_IDLE_MS = 1000;
|
|
192
|
+
/**
|
|
193
|
+
* How long to let a barrier-only tree settle before reading it again (see `settleBarrier`).
|
|
194
|
+
*
|
|
195
|
+
* MEASURED on a physical SM-A415F and a motorola one, companion on: the read issued the
|
|
196
|
+
* moment `tap @vk_sheet_open` returned (+0.3-0.45s) held only the barrier, and the next one
|
|
197
|
+
* held the sheet. The re-read itself absorbs the rest — the companion's idle wait sees the
|
|
198
|
+
* entrance's accessibility events and returns once they stop (~1.05s against ~0.2s for a
|
|
199
|
+
* quiet screen) — so this only has to outlast the gap between the two.
|
|
200
|
+
*/
|
|
201
|
+
const BARRIER_SETTLE_MS = 300;
|
|
191
202
|
/** What both capture paths say when there is no window to read: AOSP's DumpCommand prints
|
|
192
203
|
* "ERROR: null root node returned by UiTestAutomationBridge." and writes no file, and the
|
|
193
204
|
* companion reports the same condition from getRootInActiveWindow(). Transient — see
|
|
@@ -592,6 +603,9 @@ class AdbDriver {
|
|
|
592
603
|
lastRotation;
|
|
593
604
|
/** undefined = not built yet, null = opted out. See companionOrNull(). */
|
|
594
605
|
companion;
|
|
606
|
+
/** When the current run of barrier-only reads began (0 = not in one). See settleBarrier. */
|
|
607
|
+
barrierSince = 0;
|
|
608
|
+
barrierRecycled = false;
|
|
595
609
|
/** Until when `ensureAwake` may skip its probe. See AWAKE_FRESH_MS. */
|
|
596
610
|
awakeUntil = 0;
|
|
597
611
|
constructor(serial) {
|
|
@@ -686,13 +700,13 @@ class AdbDriver {
|
|
|
686
700
|
const els = this.captureElements(opts);
|
|
687
701
|
// Second net, for a device that is awake but still behind the keyguard.
|
|
688
702
|
if (!this.keyguardReason(els))
|
|
689
|
-
return els;
|
|
703
|
+
return this.settleBarrier(els, opts);
|
|
690
704
|
(0, output_1.err)('note: the app is not on screen — trying to dismiss the keyguard');
|
|
691
705
|
this.wakeAndUnlock();
|
|
692
706
|
const after = this.captureElements(opts);
|
|
693
707
|
const reason = this.keyguardReason(after);
|
|
694
708
|
if (!reason)
|
|
695
|
-
return after;
|
|
709
|
+
return this.settleBarrier(after, opts);
|
|
696
710
|
const lock = this.readLockKind();
|
|
697
711
|
const what = reason === 'locked'
|
|
698
712
|
? `The device is on the lock screen${lock === 'unknown' ? '' : ` (${lock})`}`
|
|
@@ -742,6 +756,53 @@ class AdbDriver {
|
|
|
742
756
|
return 'display-off';
|
|
743
757
|
return null;
|
|
744
758
|
}
|
|
759
|
+
/**
|
|
760
|
+
* Third net: a tree that holds only a modal barrier is read again before it is believed.
|
|
761
|
+
*
|
|
762
|
+
* A sheet's barrier blocks the route beneath it from the accessibility tree, and this
|
|
763
|
+
* dumper skips the sheet's own contents until they are on screen — so for the length of
|
|
764
|
+
* the entrance the hierarchy is the barrier and nothing else, exit 0 (issue #131; the
|
|
765
|
+
* shape is ui/barrier.ts). It lives HERE, not in the pollers, because every consumer
|
|
766
|
+
* meets it: the auto-wait, the `vk ai` guards, `/v1/elements`, the failure-evidence
|
|
767
|
+
* capture. One re-read after BARRIER_SETTLE_MS clears the measured case; a run that
|
|
768
|
+
* outlives that gets the companion connection recycled ONCE (barrierRecycleDue) and is
|
|
769
|
+
* then returned whatever it holds — a modal the test should have dismissed is still a
|
|
770
|
+
* modal, and the caller's message names it. Checked on the INTERESTING nodes even under
|
|
771
|
+
* `--all`, so the two views of one screen cannot disagree about whether it has settled.
|
|
772
|
+
*/
|
|
773
|
+
settleBarrier(els, opts) {
|
|
774
|
+
const barrier = this.barrierIn(els, opts);
|
|
775
|
+
if (!barrier) {
|
|
776
|
+
this.barrierSince = 0;
|
|
777
|
+
this.barrierRecycled = false;
|
|
778
|
+
return els;
|
|
779
|
+
}
|
|
780
|
+
if (!this.barrierSince)
|
|
781
|
+
this.barrierSince = Date.now();
|
|
782
|
+
(0, exec_1.sleepSync)(BARRIER_SETTLE_MS);
|
|
783
|
+
let settled = this.captureElements(opts);
|
|
784
|
+
if (!this.barrierIn(settled, opts))
|
|
785
|
+
return this.settleBarrier(settled, opts); // resets the run
|
|
786
|
+
if ((0, manager_1.barrierRecycleDue)(Date.now() - this.barrierSince, this.barrierRecycled)) {
|
|
787
|
+
this.barrierRecycled = true;
|
|
788
|
+
// ONLY when the companion is what served these reads. A recycle re-acquires the
|
|
789
|
+
// device's single UiAutomation connection, and a companion that had stood down would
|
|
790
|
+
// then SIGKILL the very stock dump the next read depends on.
|
|
791
|
+
const companion = this.hierarchySource().path === 'companion' ? this.companionOrNull() : null;
|
|
792
|
+
if (companion?.recycleConnection()) {
|
|
793
|
+
(0, output_1.err)(`[verikun] the hierarchy has held only a modal barrier (${(0, barrier_1.describeBarrier)(barrier)}) for a while; ` +
|
|
794
|
+
'recycling the companion connection in case it is stale');
|
|
795
|
+
settled = this.captureElements(opts);
|
|
796
|
+
if (!this.barrierIn(settled, opts))
|
|
797
|
+
return this.settleBarrier(settled, opts);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
return settled;
|
|
801
|
+
}
|
|
802
|
+
/** The barrier when `els` is a barrier-only tree, else null. */
|
|
803
|
+
barrierIn(els, opts) {
|
|
804
|
+
return (0, barrier_1.modalBarrierOnly)(opts.all ? els.filter(android_parse_1.isInteresting) : els, this.viewport());
|
|
805
|
+
}
|
|
745
806
|
/** `dumpsys trust` reports `deviceLocked=1` while the keyguard is up. `null` = could not tell,
|
|
746
807
|
* which never triggers a refusal — an unreadable probe must not block a legitimate read. */
|
|
747
808
|
deviceLocked() {
|
|
@@ -835,8 +896,7 @@ class AdbDriver {
|
|
|
835
896
|
// nothing on it yet. Retrying it here just spends someone else's wait budget three
|
|
836
897
|
// times as fast; hand it up to whoever knows how long they are willing to wait.
|
|
837
898
|
if (NULL_ROOT.test(lastErr)) {
|
|
838
|
-
throw new errors_1.NoWindowError(
|
|
839
|
-
'Retry, or use a command that waits (`vk wait`, or any selector lookup).');
|
|
899
|
+
throw new errors_1.NoWindowError();
|
|
840
900
|
}
|
|
841
901
|
if (attempt === 0) {
|
|
842
902
|
// A sleeping display is the other documented cause of a failed read. `ensureAwake` ran
|
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.NoWindowError = exports.SelectorNotFoundError = exports.probeFailure = exports.envError = exports.usageError = exports.CliError = void 0;
|
|
9
|
+
exports.AmbiguousSelectorError = exports.NO_WINDOW_MESSAGE = exports.NoWindowError = 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;
|
|
@@ -67,12 +67,25 @@ exports.SelectorNotFoundError = SelectorNotFoundError;
|
|
|
67
67
|
* The retry belongs to the caller that knows how long it is willing to wait.
|
|
68
68
|
*/
|
|
69
69
|
class NoWindowError extends CliError {
|
|
70
|
-
constructor(message) {
|
|
70
|
+
constructor(message = exports.NO_WINDOW_MESSAGE) {
|
|
71
71
|
super(message, 3);
|
|
72
72
|
this.name = 'NoWindowError';
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
exports.NoWindowError = NoWindowError;
|
|
76
|
+
/**
|
|
77
|
+
* The one wording for "there is no window", shared by both Android read paths so they cannot
|
|
78
|
+
* drift — the companion and the stock dumper are reporting the same device state.
|
|
79
|
+
*
|
|
80
|
+
* It names THREE causes, not two. `getRootInActiveWindow()` also returns null while the app's
|
|
81
|
+
* main thread is busy mid-transition, and issue #80 was reported against a build that listed
|
|
82
|
+
* only force-stop and mid-launch: the reporter went looking at app startup for a screen that
|
|
83
|
+
* was drawn, present, and merely busy (device logs showed 32-121 dropped frames in the same
|
|
84
|
+
* window). It also no longer ends in "use a command that waits" — every caller that hit this
|
|
85
|
+
* in the wild was already doing exactly that.
|
|
86
|
+
*/
|
|
87
|
+
exports.NO_WINDOW_MESSAGE = 'No window to read: the app has no drawn window right now — force-stopped, mid-launch, or ' +
|
|
88
|
+
'its main thread is busy mid-transition. This normally clears within a few seconds.';
|
|
76
89
|
/** Selector matched >1 element. Exit 2. Carries the candidates so the agent runner
|
|
77
90
|
* can ask the model to disambiguate (a heal trigger) instead of aborting. */
|
|
78
91
|
class AmbiguousSelectorError extends CliError {
|
package/dist/server-http.js
CHANGED
|
@@ -14,14 +14,21 @@ class HttpError extends Error {
|
|
|
14
14
|
status;
|
|
15
15
|
exitCode;
|
|
16
16
|
deviceChanged;
|
|
17
|
+
errorKind;
|
|
17
18
|
constructor(status, message, exitCode = status === 400 || status === 404 || status === 413 ? 2 : 3,
|
|
18
19
|
/** Set when this request moved the server's device before failing — the client
|
|
19
20
|
* needs to know the ground shifted even though the answer is an error. */
|
|
20
|
-
deviceChanged
|
|
21
|
+
deviceChanged,
|
|
22
|
+
/** The CLASS of the error this wraps, when it wraps one. Wrapping a driver error in an
|
|
23
|
+
* HttpError is how the identity used to be lost: only `.message` and `.exitCode` were
|
|
24
|
+
* copied across, so a `NoWindowError` reached the client as a bare `CliError`. Carry it
|
|
25
|
+
* here and the outer catch can put it on the wire. */
|
|
26
|
+
errorKind) {
|
|
21
27
|
super(message);
|
|
22
28
|
this.status = status;
|
|
23
29
|
this.exitCode = exitCode;
|
|
24
30
|
this.deviceChanged = deviceChanged;
|
|
31
|
+
this.errorKind = errorKind;
|
|
25
32
|
this.name = 'HttpError';
|
|
26
33
|
}
|
|
27
34
|
}
|
package/dist/server.js
CHANGED
|
@@ -1053,7 +1053,10 @@ function buildServer(config) {
|
|
|
1053
1053
|
const deviceChanged = await considerFailover(e, 'read', handle);
|
|
1054
1054
|
if (!deviceChanged)
|
|
1055
1055
|
throw e;
|
|
1056
|
-
|
|
1056
|
+
// describeError, not just .message/.exitCode: this wrap is on the path a mid-launch
|
|
1057
|
+
// NoWindowError takes, and the engine's guard tells "still drawing" from "box broken"
|
|
1058
|
+
// by class alone (issue #80).
|
|
1059
|
+
throw new server_http_1.HttpError(500, e.message, e instanceof errors_1.CliError ? e.exitCode : 3, deviceChanged, (0, rpc_1.describeError)(e).kind);
|
|
1057
1060
|
}
|
|
1058
1061
|
}
|
|
1059
1062
|
async function handleLogs(handle, req, res) {
|
|
@@ -1629,9 +1632,16 @@ function buildServer(config) {
|
|
|
1629
1632
|
// back why a suite degraded.
|
|
1630
1633
|
failure = ` — ${(0, server_http_1.firstLine)(mapped.message)}`;
|
|
1631
1634
|
if (!res.headersSent) {
|
|
1635
|
+
// The class comes from the ORIGINAL throw, never from `mapped`: the HttpError
|
|
1636
|
+
// mapping above keeps only message + exit code, which is precisely how a
|
|
1637
|
+
// NoWindowError used to reach the client as an anonymous CliError (issue #80). An
|
|
1638
|
+
// HttpError raised by the server itself (auth, validation, a lock) has no wrapped
|
|
1639
|
+
// class and simply omits the field, which older clients already tolerate.
|
|
1640
|
+
const errorKind = e instanceof server_http_1.HttpError ? e.errorKind : (0, rpc_1.describeError)(e).kind;
|
|
1632
1641
|
const body = {
|
|
1633
1642
|
error: mapped.message,
|
|
1634
1643
|
exitCode: mapped.exitCode,
|
|
1644
|
+
...(errorKind ? { errorKind } : {}),
|
|
1635
1645
|
...(mapped.deviceChanged ? { deviceChanged: mapped.deviceChanged } : {}),
|
|
1636
1646
|
};
|
|
1637
1647
|
(0, server_http_1.sendJson)(res, mapped.status, body);
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MIN_BARRIER_COVERAGE = void 0;
|
|
4
|
+
exports.isInert = isInert;
|
|
5
|
+
exports.isBarrierShaped = isBarrierShaped;
|
|
6
|
+
exports.modalBarrierOnly = modalBarrierOnly;
|
|
7
|
+
exports.describeBarrier = describeBarrier;
|
|
8
|
+
exports.barrierClause = barrierClause;
|
|
9
|
+
// A modal barrier — the scrim a sheet or dialog puts in front of everything else — and
|
|
10
|
+
// the one tree shape it produces that reads as "empty" while the screen is plainly full.
|
|
11
|
+
//
|
|
12
|
+
// Pure functions over Element[] (no device, no time, no platform), like ui/selector.ts,
|
|
13
|
+
// so the shape is unit-testable and the two consumers cannot drift: the Android driver
|
|
14
|
+
// re-reads a tree of this shape before trusting it, and the selector commands name the
|
|
15
|
+
// barrier in their failure message when it outlived their wait.
|
|
16
|
+
//
|
|
17
|
+
// WHY THIS EXISTS (issue #131). Flutter's ModalBarrier is a BlockSemantics around one
|
|
18
|
+
// full-screen Semantics(label:, onTap:) node. BlockSemantics DROPS every node painted
|
|
19
|
+
// before it — the whole route underneath — on purpose: a screen reader must not reach
|
|
20
|
+
// content the user cannot touch. The sheet's own content joins the tree only once it is
|
|
21
|
+
// on screen, and Android's dumper skips what it considers invisible, so for the length
|
|
22
|
+
// of the entrance the hierarchy is the barrier and nothing else. MEASURED on a physical
|
|
23
|
+
// SM-A415F and a motorola one: the first read after `tap @vk_sheet_open` returned
|
|
24
|
+
// (+0.3-0.45s) held `android:id/content` and `Scrim`; the next read held the sheet.
|
|
25
|
+
//
|
|
26
|
+
// Detection is STRUCTURAL, never by label. The label is `MaterialLocalizations.scrimLabel`
|
|
27
|
+
// — "Scrim" in English and Swedish, "Gitter" in German, "Fond" in French, "スクリム" in
|
|
28
|
+
// Japanese — and `modalBarrierDismissLabel` ("Dismiss") for a dialog, so a label match
|
|
29
|
+
// would work on exactly the devices it was written on. What is stable is the shape: a
|
|
30
|
+
// clickable, id-less node with no text, large enough to be the thing you tap to dismiss.
|
|
31
|
+
/**
|
|
32
|
+
* How much of the viewport a lone tap target must cover to read as a barrier.
|
|
33
|
+
*
|
|
34
|
+
* Loose on purpose. A sheet's barrier is CLIPPED to the area above the sheet once the
|
|
35
|
+
* sheet is up (measured: 1620 of 2184 view px on the SM-A415F, ~68% of the 2400px display),
|
|
36
|
+
* and a dialog's covers everything — so "covers the screen" would miss the sheet. What the
|
|
37
|
+
* floor has to exclude is a lone ordinary button on an otherwise empty screen, which is a
|
|
38
|
+
* few percent at most.
|
|
39
|
+
*/
|
|
40
|
+
exports.MIN_BARRIER_COVERAGE = 0.4;
|
|
41
|
+
/** Input classes `isInteresting()` keeps even when empty — a field is never inert. */
|
|
42
|
+
const INPUT_CLASS = /EditText|AutoComplete|TextField|Edit$/;
|
|
43
|
+
/**
|
|
44
|
+
* Nothing to read and nothing to act on: a layout container, with or without an id.
|
|
45
|
+
*
|
|
46
|
+
* An id alone does not make a node readable — `android:id/content` is in every Android
|
|
47
|
+
* dump and says nothing about the app — which is why this looks at content and actions
|
|
48
|
+
* and ignores `id`. The report behind this listed "thirteen unlabelled containers".
|
|
49
|
+
*/
|
|
50
|
+
function isInert(el) {
|
|
51
|
+
if (el.text.trim() || el.desc.trim())
|
|
52
|
+
return false;
|
|
53
|
+
if (el.clickable || el.longClickable || el.checkable || el.scrollable)
|
|
54
|
+
return false;
|
|
55
|
+
return !INPUT_CLASS.test(el.class);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Is this node shaped like a modal barrier: clickable (it dismisses on tap), no id (a
|
|
59
|
+
* framework node, not one the app named), no text, and — when the viewport is known —
|
|
60
|
+
* covering at least MIN_BARRIER_COVERAGE of it? A label is allowed but not required: an
|
|
61
|
+
* app can raise a barrier with no `semanticsLabel`, and the node then has an action and
|
|
62
|
+
* nothing else.
|
|
63
|
+
*
|
|
64
|
+
* With no viewport (the driver could not read a screen size) the size check is skipped:
|
|
65
|
+
* a wrong "barrier" costs one extra read, a wrong "not a barrier" costs the false miss
|
|
66
|
+
* this exists to prevent.
|
|
67
|
+
*/
|
|
68
|
+
function isBarrierShaped(el, vp) {
|
|
69
|
+
if (!el.clickable || el.id || el.text.trim())
|
|
70
|
+
return false;
|
|
71
|
+
if (!vp || vp.width <= 0 || vp.height <= 0)
|
|
72
|
+
return true;
|
|
73
|
+
const w = Math.max(0, Math.min(el.bounds.x2, vp.width) - Math.max(el.bounds.x1, 0));
|
|
74
|
+
const h = Math.max(0, Math.min(el.bounds.y2, vp.height) - Math.max(el.bounds.y1, 0));
|
|
75
|
+
return (w * h) / (vp.width * vp.height) >= exports.MIN_BARRIER_COVERAGE;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The barrier, when the tree holds one or more barrier-shaped nodes and nothing else that
|
|
79
|
+
* could be read or acted on; null for any other tree — including an EMPTY one, which is a
|
|
80
|
+
* different signal (a bad read) and is handled where it is met.
|
|
81
|
+
*
|
|
82
|
+
* Two barriers at once are one shape, not two: a dialog raised over a sheet has the
|
|
83
|
+
* dialog's barrier and the sheet's in the same tree mid-transition.
|
|
84
|
+
*/
|
|
85
|
+
function modalBarrierOnly(els, vp) {
|
|
86
|
+
let barrier = null;
|
|
87
|
+
for (const el of els) {
|
|
88
|
+
if (isInert(el))
|
|
89
|
+
continue;
|
|
90
|
+
if (!isBarrierShaped(el, vp))
|
|
91
|
+
return null;
|
|
92
|
+
barrier ??= el;
|
|
93
|
+
}
|
|
94
|
+
return barrier;
|
|
95
|
+
}
|
|
96
|
+
/** How a message names the barrier: its label when it has one, else its shape. */
|
|
97
|
+
function describeBarrier(el) {
|
|
98
|
+
const label = el.desc.trim();
|
|
99
|
+
return label ? `desc=${JSON.stringify(label)}` : 'an unlabelled full-screen tap target';
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The clause a selector failure appends when the barrier was what it kept reading.
|
|
103
|
+
*
|
|
104
|
+
* `every` says whether EVERY read during the wait was barrier-only (the report's case:
|
|
105
|
+
* "waited 30s, never appeared") or only the last one (a sheet or dialog opened late in
|
|
106
|
+
* the window). Both are worth naming; they send the reader to different places.
|
|
107
|
+
*/
|
|
108
|
+
function barrierClause(barrier, every) {
|
|
109
|
+
const when = every ? 'for the whole wait' : 'on the last read';
|
|
110
|
+
return (` The hierarchy held only a modal barrier (${describeBarrier(barrier)}) ${when}: a sheet ` +
|
|
111
|
+
'or dialog is up and nothing inside it has reached the accessibility tree. Dismiss it, ' +
|
|
112
|
+
'or wait for its contents, before this step.');
|
|
113
|
+
}
|
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.26.
|
|
6
|
+
exports.VERSION = '0.26.2';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "verikun",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.2",
|
|
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",
|