verikun 0.26.1 → 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.
@@ -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
package/CHANGELOG.md CHANGED
@@ -6,6 +6,20 @@ 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
+
9
23
  ## [0.26.1] - 2026-09-07
10
24
 
11
25
  Fixes a `vk ai` run dying when the app is redrawing at the moment a guard checks the screen.
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
- ctx.record?.note({ selector: sel, message: `timeout after ${timeout}ms${gone ? ' (still present)' : ' (never appeared)'}` });
923
- (0, output_1.err)(`timeout after ${timeout}ms waiting for '${sel.raw}'${gone ? ' to disappear' : ''}`);
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
- let result = evalAssert((0, auto_wait_1.readForPoll)(ctx), sel, ctx.flags);
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, reason, matches } = result;
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. */
@@ -293,7 +312,9 @@ class Companion {
293
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);
@@ -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() {
@@ -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.1';
6
+ exports.VERSION = '0.26.2';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "verikun",
3
- "version": "0.26.1",
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",