verikun 0.15.0 → 0.16.0

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/README.md CHANGED
@@ -390,15 +390,51 @@ class:Button simplified type ("Button") or full class ("android.widget.Button
390
390
  ```
391
391
 
392
392
  Modifiers: `--contains` makes text/desc matches substring-based; `--index N`
393
- selects the Nth match (0-based) when a selector intentionally matches several;
394
- `--enabled` matches only a control that is **actionable right now** — use it for a
395
- Submit/Check button the app disables until a form is valid, since such a button is
396
- present long before it is usable and tapping presence taps a dead control (with
397
- auto-wait this reads as "wait until it is pressable").
393
+ selects the Nth match (0-based) when a selector intentionally matches several.
398
394
  If a selector for an action matches more than one element and no `--index` is
399
395
  given, the command fails with exit code 2 and lists the candidates — it never
400
396
  taps a guess.
401
397
 
398
+ ### State modifiers
399
+
400
+ A selector can also require an element's a11y **state**, in both polarities:
401
+
402
+ | Modifier | Matches | Negative form |
403
+ |---|---|---|
404
+ | `--enabled` | actionable right now | `--not-enabled` |
405
+ | `--selected` | the current option of a segmented control / tab bar / mode picker | `--not-selected` |
406
+ | `--checked` | a ticked checkbox / switch / radio | `--not-checked` |
407
+ | `--focused` | the element holding input focus | `--not-focused` |
408
+
409
+ Unset means *don't care*; these never narrow a selector you didn't ask them to.
410
+
411
+ Reach for `--enabled` on a Submit/Check button the app disables until a form is
412
+ valid: such a button is present long before it is usable, so tapping presence taps
413
+ a dead control (with auto-wait this reads as "wait until it is pressable").
414
+
415
+ The **negative** forms are what make a toggle drivable. A segmented control whose
416
+ options share one handler *flips* on any tap, so "tap the option I want" lands on
417
+ the other one whenever it was already chosen — exit 0, nothing to notice, and the
418
+ run exercises the wrong mode. Guard it instead:
419
+
420
+ ```sh
421
+ vk find "@mode_video --not-selected" --no-wait && vk tap @mode_video
422
+ ```
423
+
424
+ A modifier can be written as a flag **or appended to the selector string**, as
425
+ above. The string form exists because a `vk ai` control node (`if-present`,
426
+ `when`, `repeat`, `while-present`, `read`) holds a bare selector with nowhere to
427
+ put a flag — and a guard is exactly where the toggle case needs one:
428
+
429
+ ```
430
+ if-present "id:mode_video --not-selected" { tap id:mode_video }
431
+ ```
432
+
433
+ **`--selected` and `--focused` are Android-only.** `idb` reports no such
434
+ attribute for iOS — not merely unset, the key does not exist in its output — so
435
+ using them with `--ios` exits **3** rather than silently matching nothing.
436
+ `--enabled` and `--checked` work on both.
437
+
402
438
  ### Auto scroll-into-view
403
439
 
404
440
  An element can be in the hierarchy without being reachable at the point a tap
@@ -4,6 +4,7 @@ exports.DEFAULT_GUARD_SETTLE_MS = exports.DEFAULT_RUN_TIMEOUT_MS = void 0;
4
4
  exports.runPlan = runPlan;
5
5
  const node_crypto_1 = require("node:crypto");
6
6
  const selector_1 = require("../ui/selector");
7
+ const state_support_1 = require("../ui/state-support");
7
8
  const errors_1 = require("../errors");
8
9
  const ir_1 = require("./ir");
9
10
  /** An outcome is environment-flavoured if it carries an exit-3 CliError, or simply
@@ -191,6 +192,11 @@ async function runPlan(plan, deps) {
191
192
  deps.log(`[ai] guard selector '${selector}' did not parse (${e.message}) — treating as not present`);
192
193
  return false;
193
194
  }
195
+ // Outside the catch on purpose. A guard pinning state this platform cannot report
196
+ // would match nothing forever, so "not present" is a lie that silently skips the
197
+ // body — the plan is unrunnable HERE and must say so, not quietly do nothing.
198
+ if (deps.platform)
199
+ (0, state_support_1.assertStateSupported)(sel, deps.platform);
194
200
  const deadline = Date.now() + Math.max(0, settleMs);
195
201
  // A non-zero window must buy at least one SECOND look, independent of the clock.
196
202
  // Measured on emulator-5554: one uiautomator dump costs ~2.4s, which already exceeds
@@ -368,6 +374,8 @@ async function runPlan(plan, deps) {
368
374
  catch (e) {
369
375
  return { status: 'fail', where, reason: `read selector '${selector}' did not parse: ${e.message}` };
370
376
  }
377
+ if (deps.platform)
378
+ (0, state_support_1.assertStateSupported)(sel, deps.platform);
371
379
  // Same patience as a conditional guard: the value may not have rendered yet.
372
380
  const deadline = Date.now() + Math.max(0, guardSettleMs);
373
381
  let looks = 0;
@@ -112,12 +112,29 @@ SELECTORS (the engine auto-heals case/whitespace/partial, so prefer stable ident
112
112
  class:Button type or class
113
113
  "Sign in" bare string == text:Sign in
114
114
 
115
+ A selector may also pin ELEMENT STATE, in both polarities:
116
+ --enabled / --not-enabled actionable right now
117
+ --selected / --not-selected current option of a segmented control / tab bar / mode picker
118
+ --checked / --not-checked checkbox / switch / radio state
119
+ --focused / --not-focused holds input focus
120
+ On a command leaf write it as a flag. On a CONTROL NODE append it to the selector string —
121
+ that is the only place one can go, and it is what makes a state-conditional guard possible:
122
+ { "type":"if-present", "selector":"id:mode_video --not-selected",
123
+ "body":[ { "type":"command","command":"tap","positionals":["id:mode_video"],"flags":[] } ] }
124
+
115
125
  RULES:
116
126
  - --enabled on a tap makes it match only a control that is ACTIONABLE right now, and (with
117
127
  auto-wait) wait until it becomes so. Use it for any button that the app disables until
118
128
  something else is done — a Check/Submit/Continue that only lights up once an answer is
119
129
  selected or a form is valid. Without it the step taps a dead control, does nothing, and
120
130
  the failure surfaces later as a confusing timeout on the NEXT step.
131
+ - A picker or toggle whose options share ONE handler FLIPS on any tap, so an unconditional
132
+ "tap the option you want" lands on the option you did NOT want whenever it was already
133
+ chosen — and its starting state is usually content-driven, so you cannot know it now.
134
+ Guard it: if-present "id:<option> --not-selected" { tap id:<option> }. The guard makes an
135
+ already-correct state a no-op instead of a flip. Unguarded, the flow completes either way
136
+ and the test PASSES having exercised the opposite mode — a false green, worse than a fail.
137
+ Same shape for a checkbox that toggles: guard with --not-checked / --checked.
121
138
  - assert is for VERIFICATION only and is terminal — never use it as a step you expect to
122
139
  fail. Put genuinely-optional UI behind if-present.
123
140
  - tap/text SCROLL THEIR TARGET INTO VIEW automatically, so "scroll down to X and tap it"
package/dist/args.js CHANGED
@@ -44,6 +44,19 @@ const BOOLEAN = new Set([
44
44
  'no-restart',
45
45
  'allow-install',
46
46
  'allow-unsafe-anonymous',
47
+ // Selector state modifiers (STATE_ATTRS in ui/selector.ts) and their negations.
48
+ // `enabled` was missing here until 0.15.0, and the omission was not cosmetic: a
49
+ // non-BOOLEAN flag swallows the next token, so `vk tap --enabled @submit` bound
50
+ // the SELECTOR as the flag's value and died with "Missing selector". Only the
51
+ // trailing form worked. Any new modifier must be listed here.
52
+ 'enabled',
53
+ 'selected',
54
+ 'checked',
55
+ 'focused',
56
+ 'not-enabled',
57
+ 'not-selected',
58
+ 'not-checked',
59
+ 'not-focused',
47
60
  ]);
48
61
  function parseArgs(argv) {
49
62
  const positionals = [];
package/dist/cli.js CHANGED
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.platformFromFlags = platformFromFlags;
37
37
  exports.deviceFromFlags = deviceFromFlags;
38
+ exports.stateFromFlags = stateFromFlags;
38
39
  exports.parsePoint = parsePoint;
39
40
  exports.healNote = healNote;
40
41
  exports.parseDuration = parseDuration;
@@ -58,6 +59,7 @@ const errors_1 = require("./errors");
58
59
  const exec_1 = require("./exec");
59
60
  const drivers_1 = require("./drivers");
60
61
  const selector_1 = require("./ui/selector");
62
+ const state_support_1 = require("./ui/state-support");
61
63
  const format_1 = require("./ui/format");
62
64
  const viewport_1 = require("./ui/viewport");
63
65
  const output_1 = require("./output");
@@ -93,15 +95,42 @@ function deviceFromFlags(flags, platform) {
93
95
  (platform === 'android' ? process.env.ANDROID_SERIAL : undefined) ||
94
96
  undefined);
95
97
  }
96
- function buildSelector(raw, flags) {
98
+ /**
99
+ * Read the `--enabled` / `--not-enabled` / `--selected` / … pairs off the flags.
100
+ *
101
+ * An ABSENT flag must stay `undefined`, never `false`: these modifiers are tri-state
102
+ * ("must be" / "must not be" / "don't care"), so passing `flagBool()` straight through —
103
+ * which is what this replaced — would quietly turn every selector on every command into
104
+ * "must be disabled, unselected, unchecked and unfocused".
105
+ */
106
+ function stateFromFlags(flags) {
107
+ const state = {};
108
+ for (const attr of selector_1.STATE_ATTRS) {
109
+ const yes = (0, args_1.flagBool)(flags, attr);
110
+ const no = (0, args_1.flagBool)(flags, `not-${attr}`);
111
+ if (yes && no)
112
+ throw new errors_1.CliError(`Cannot combine --${attr} with --not-${attr}.`, 2);
113
+ if (yes)
114
+ state[attr] = true;
115
+ else if (no)
116
+ state[attr] = false;
117
+ }
118
+ return state;
119
+ }
120
+ function buildSelector(ctx, raw) {
97
121
  if (!raw) {
98
122
  throw new errors_1.CliError('Missing selector. e.g. `@login_button`, `text:Login`, `desc:Submit`.', 2);
99
123
  }
100
- return (0, selector_1.parseSelector)(raw, {
101
- contains: (0, args_1.flagBool)(flags, 'contains'),
102
- index: (0, args_1.flagNum)(flags, 'index'),
103
- enabled: (0, args_1.flagBool)(flags, 'enabled'),
124
+ const sel = (0, selector_1.parseSelector)(raw, {
125
+ contains: (0, args_1.flagBool)(ctx.flags, 'contains'),
126
+ index: (0, args_1.flagNum)(ctx.flags, 'index'),
127
+ ...stateFromFlags(ctx.flags),
104
128
  });
129
+ // Every command's selector — and so every `vk ai` leaf, which reaches these handlers
130
+ // through executeOutcome — funnels through here, which is why the platform check lives
131
+ // at this seam rather than in the (platform-free) selector layer.
132
+ (0, state_support_1.assertStateSupported)(sel, ctx.platform);
133
+ return sel;
105
134
  }
106
135
  function parsePoint(s) {
107
136
  const m = /^(-?\d+)\s*,\s*(-?\d+)$/.exec(s.trim());
@@ -458,7 +487,7 @@ function cmdUi(ctx) {
458
487
  return 0;
459
488
  }
460
489
  async function cmdFind(ctx) {
461
- const sel = buildSelector(ctx.positionals[0], ctx.flags);
490
+ const sel = buildSelector(ctx, ctx.positionals[0]);
462
491
  const { matches, tier } = await matchWaiting(ctx, sel, { all: (0, args_1.flagBool)(ctx.flags, 'all') });
463
492
  if ((0, args_1.flagBool)(ctx.flags, 'json'))
464
493
  (0, output_1.json)(matches.map(format_1.toJsonShape));
@@ -511,7 +540,7 @@ async function cmdTap(ctx) {
511
540
  ctx.record?.note({ element: target, message: `tapped by index [${idx}]` });
512
541
  }
513
542
  else {
514
- const sel = buildSelector(raw, ctx.flags);
543
+ const sel = buildSelector(ctx, raw);
515
544
  ({ element: target, tier, waitedMs, swipes, point } = await resolveTappable(ctx, sel, {
516
545
  all: (0, args_1.flagBool)(ctx.flags, 'all'),
517
546
  }));
@@ -530,7 +559,7 @@ async function cmdText(ctx) {
530
559
  if (ctx.positionals.length < 2) {
531
560
  throw new errors_1.CliError('Usage: verikun text <selector> <text...> (use -- before text starting with "-")', 2);
532
561
  }
533
- const sel = buildSelector(ctx.positionals[0], ctx.flags);
562
+ const sel = buildSelector(ctx, ctx.positionals[0]);
534
563
  const value = ctx.positionals.slice(1).join(' ');
535
564
  const { element: target, tier, waitedMs, swipes, point } = await resolveTappable(ctx, sel);
536
565
  ctx.record?.note({
@@ -606,7 +635,9 @@ async function cmdSwipe(ctx) {
606
635
  let waitedMs = 0;
607
636
  const on = (0, args_1.flagStr)(ctx.flags, 'on');
608
637
  if (on) {
609
- const onSel = (0, selector_1.parseSelector)(on, { contains: (0, args_1.flagBool)(ctx.flags, 'contains') });
638
+ // Through buildSelector, not parseSelector: --on used to see only --contains, so
639
+ // `swipe --on X --enabled` silently ignored the modifier it was given.
640
+ const onSel = buildSelector(ctx, on);
610
641
  const { element, waitedMs: w } = await resolveOneWaiting(ctx, onSel);
611
642
  waitedMs = w;
612
643
  ctx.record?.note({ selector: onSel, element });
@@ -762,7 +793,7 @@ function cmdLog(ctx) {
762
793
  return 0;
763
794
  }
764
795
  async function cmdWait(ctx) {
765
- const sel = buildSelector(ctx.positionals[0], ctx.flags);
796
+ const sel = buildSelector(ctx, ctx.positionals[0]);
766
797
  const gone = (0, args_1.flagBool)(ctx.flags, 'gone');
767
798
  const timeout = (0, args_1.flagNum)(ctx.flags, 'timeout') ?? 10000;
768
799
  const interval = (0, args_1.flagNum)(ctx.flags, 'interval') ?? 400;
@@ -822,7 +853,7 @@ function evalAssert(els, sel, flags) {
822
853
  return { pass, reason, matches };
823
854
  }
824
855
  async function cmdAssert(ctx) {
825
- const sel = buildSelector(ctx.positionals[0], ctx.flags);
856
+ const sel = buildSelector(ctx, ctx.positionals[0]);
826
857
  // Auto-wait subsumes the common "wait then assert": poll until the assertion
827
858
  // passes or the window elapses. `--gone` therefore waits for disappearance.
828
859
  const deadline = Date.now() + waitWindowMs(ctx.flags);
@@ -1353,6 +1384,9 @@ async function runAiTest(file, opts, backend, platform, device) {
1353
1384
  guardSettleMs: guardSettleMs(),
1354
1385
  runId: started.id,
1355
1386
  deadline,
1387
+ // The RESOLVED platform — for --server that is the server's, which supersedes
1388
+ // the client's --platform (leaves are gated server-side; guards run here).
1389
+ platform,
1356
1390
  });
1357
1391
  }
1358
1392
  catch (e) {
@@ -1859,6 +1893,15 @@ SELECTORS
1859
1893
  class:Button type or full class name
1860
1894
  "Sign in" bare string == text:"Sign in"
1861
1895
  Modifiers: --contains (substring), --index N (pick Nth match)
1896
+ State: --enabled / --selected / --checked / --focused, each with a --not-
1897
+ form (--not-selected). Unset = don't care. Use the negative to guard
1898
+ a toggle: tapping a picker whose options share a handler FLIPS it, so
1899
+ an unconditional tap lands on the wrong mode and still exits 0.
1900
+ May be written as a flag or appended to the selector string
1901
+ ("@mode_video --not-selected") — the latter is how a \`vk ai\`
1902
+ if-present/when/repeat guard carries one.
1903
+ --selected and --focused are Android-only (idb reports neither);
1904
+ on iOS they exit 3 rather than matching nothing.
1862
1905
 
1863
1906
  AUTO-WAIT (selector lookups retry until they resolve)
1864
1907
  Selector commands (tap, text, find, assert, swipe --on) re-poll the screen for
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.IOS_UNREPORTED_STATE = void 0;
3
4
  exports.isInteresting = isInteresting;
4
5
  exports.parseIosHierarchy = parseIosHierarchy;
5
6
  const viewport_1 = require("./viewport");
@@ -13,6 +14,23 @@ const TAPPABLE_TYPES = new Set([
13
14
  const SCROLLABLE_TYPES = new Set(['ScrollView', 'Table', 'TableView', 'CollectionView', 'WebView']);
14
15
  const CHECKABLE_TYPES = new Set(['Switch', 'Toggle', 'CheckBox', 'RadioButton']);
15
16
  const TEXT_INPUT_TYPES = new Set(['TextField', 'SecureTextField', 'SearchField', 'TextView']);
17
+ /**
18
+ * State attributes idb does not report, so `Element` can only ever say `false`.
19
+ *
20
+ * MEASURED against the Flutter fixture's `@vk_state` screen on an iPhone 17 Pro simulator
21
+ * (iOS 26.5): a selected and an unselected segment came back byte-identical apart from
22
+ * label and frame, and a focused text field was indistinguishable from an unfocused one.
23
+ * The key is not merely unset — `idb ui describe-all` has no such key in its schema at all
24
+ * (it emits AXFrame, AXLabel, AXUniqueId, AXValue, content_required, custom_actions,
25
+ * enabled, frame, help, role, role_description, subrole, title, type, and nothing else),
26
+ * so no app can supply it and there is nothing to derive it from.
27
+ *
28
+ * Exported so `--selected` / `--focused` can be REJECTED on iOS rather than silently
29
+ * matching nothing — a filter that can never match is exactly the false-green failure the
30
+ * modifier exists to prevent. Keep this list next to the hard-coded `false`s below; if idb
31
+ * ever starts reporting one, delete it from here in the same change that parses it.
32
+ */
33
+ exports.IOS_UNREPORTED_STATE = ['selected', 'focused'];
16
34
  function str(v) {
17
35
  return typeof v === 'string' ? v : v == null ? '' : String(v);
18
36
  }
@@ -81,6 +99,7 @@ function buildElement(raw) {
81
99
  checkable,
82
100
  checked: checkable && isTrue(raw.AXValue),
83
101
  focusable: false,
102
+ // `focused` and `selected` are not derivable — see IOS_UNREPORTED_STATE above.
84
103
  focused: false,
85
104
  scrollable: SCROLLABLE_TYPES.has(type),
86
105
  enabled: raw.enabled === undefined ? true : isTrue(raw.enabled),
@@ -1,19 +1,68 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.STATE_ATTRS = void 0;
3
4
  exports.parseSelector = parseSelector;
4
5
  exports.matchElements = matchElements;
5
6
  exports.resolveOne = resolveOne;
6
7
  const errors_1 = require("../errors");
7
8
  const format_1 = require("./format");
9
+ /**
10
+ * The element state a selector can require. Each is TRI-STATE on a Selector:
11
+ * unset = don't care, `true` = must be, `false` = must NOT be.
12
+ *
13
+ * The negative half is not symmetry for its own sake. A segmented control whose
14
+ * options share one handler *flips* on any tap, so "tap it unless it is already
15
+ * selected" is the only safe way to land on a known option — and a plan that
16
+ * cannot say that taps blind, completes either way, and passes having exercised
17
+ * the opposite mode.
18
+ */
19
+ exports.STATE_ATTRS = ['enabled', 'selected', 'checked', 'focused'];
20
+ /** Trailing ` --selected` / ` --not-checked` / … on a selector STRING. */
21
+ const STATE_MODIFIER = new RegExp(`\\s+--(not-)?(${exports.STATE_ATTRS.join('|')})\\s*$`, 'i');
22
+ /**
23
+ * Peel state modifiers off the END of a selector string.
24
+ *
25
+ * Flags normally arrive as flags, but a control node's selector has nowhere to put
26
+ * one: `if-present` / `when` / `repeat` / `while-present` / `read` all hold a bare
27
+ * `selector: string` (and `swipe --on` is a flag value). Guards are exactly where
28
+ * "tap it only if it is not already selected" needs to be expressed, so the string
29
+ * itself carries them and every caller converges on this one parser:
30
+ *
31
+ * if-present "id:mode_video --not-selected" { tap id:mode_video }
32
+ *
33
+ * Only these modifiers, only at the end, and only after whitespace — so `text:--selected`
34
+ * and `text:a --selected b` are still plain values. A `text:` value that genuinely ENDS
35
+ * in " --selected" would be misread; use `--contains` on a shorter substring if you ever
36
+ * meet one. Note the engine interpolates `{{ctx.…}}` before parsing, so a value captured
37
+ * by `read` could in principle end in a modifier — end-anchoring plus the required space
38
+ * is what keeps that from being a practical concern.
39
+ */
40
+ function splitStateModifiers(raw) {
41
+ const state = {};
42
+ let rest = raw;
43
+ for (;;) {
44
+ const m = STATE_MODIFIER.exec(rest);
45
+ if (!m)
46
+ return { rest, state };
47
+ const attr = m[2].toLowerCase();
48
+ const want = !m[1];
49
+ if (state[attr] !== undefined && state[attr] !== want) {
50
+ throw new errors_1.CliError(`Selector '${raw}' asks for both --${attr} and --not-${attr}.`, 2);
51
+ }
52
+ state[attr] = want;
53
+ rest = rest.slice(0, m.index);
54
+ }
55
+ }
8
56
  function parseSelector(raw, opts = {}) {
57
+ const { rest, state } = splitStateModifiers(raw);
9
58
  let kind = 'text';
10
- let value = raw;
11
- if (raw.startsWith('@')) {
59
+ let value = rest;
60
+ if (rest.startsWith('@')) {
12
61
  kind = 'id';
13
- value = raw.slice(1);
62
+ value = rest.slice(1);
14
63
  }
15
64
  else {
16
- const m = /^(id|text|desc|class):([\s\S]*)$/.exec(raw);
65
+ const m = /^(id|text|desc|class):([\s\S]*)$/.exec(rest);
17
66
  if (m) {
18
67
  kind = m[1];
19
68
  value = m[2];
@@ -21,20 +70,35 @@ function parseSelector(raw, opts = {}) {
21
70
  }
22
71
  if (!value)
23
72
  throw new errors_1.CliError(`Empty selector value in '${raw}'`, 2);
24
- return { kind, value, contains: !!opts.contains, index: opts.index, enabled: opts.enabled, raw };
73
+ const sel = { kind, value, contains: !!opts.contains, index: opts.index, raw };
74
+ for (const attr of exports.STATE_ATTRS) {
75
+ // An explicit flag beats one embedded in the string; absent means absent, not false.
76
+ const want = opts[attr] !== undefined ? opts[attr] : state[attr];
77
+ if (want !== undefined)
78
+ sel[attr] = want;
79
+ }
80
+ return sel;
25
81
  }
26
- /** Is this element actionable right now?
82
+ /** Keep only elements whose state matches every attribute the selector pins.
27
83
  *
28
- * Just `enabled` the a11y attribute, matching what Maestro's `enabled: true` means.
29
- * An earlier version also required `clickable || longClickable`, reasoning that a
30
- * disabled Button might report clickable=false. That was speculation and it was wrong in
31
- * the direction that hurts: plenty of legitimate tap targets are CONTAINERS whose own
32
- * clickable flag is false (the tappable child is inside), so the extra conjunct filtered
33
- * out real elements and turned `--enabled` into a source of phantom "not found" misses —
34
- * which then burned model repairs. Prefer under-filtering here: a tap on a present-but-
35
- * odd element fails loudly, whereas a selector that silently matches nothing looks like
36
- * app drift and sends the heal loop chasing it. */
37
- const isActionable = (e) => e.enabled;
84
+ * Each predicate is exactly the one a11y attribute and nothing else — `--enabled` is
85
+ * `enabled`, matching what Maestro's `enabled: true` means. An earlier version also
86
+ * required `clickable || longClickable`, reasoning that a disabled Button might report
87
+ * clickable=false. That was speculation and it was wrong in the direction that hurts:
88
+ * plenty of legitimate tap targets are CONTAINERS whose own clickable flag is false (the
89
+ * tappable child is inside), so the extra conjunct filtered out real elements and turned
90
+ * `--enabled` into a source of phantom "not found" misses which then burned model
91
+ * repairs. Prefer under-filtering here: a tap on a present-but-odd element fails loudly,
92
+ * whereas a selector that silently matches nothing looks like app drift and sends the
93
+ * heal loop chasing it. Same rule for any attribute added to STATE_ATTRS — do not
94
+ * strengthen a predicate with a conjunct the platform reports unreliably (`--not-checked`
95
+ * deliberately does NOT also require `checkable`). */
96
+ function filterByState(elements, sel) {
97
+ const pinned = exports.STATE_ATTRS.filter((attr) => sel[attr] !== undefined);
98
+ if (pinned.length === 0)
99
+ return elements;
100
+ return elements.filter((el) => pinned.every((attr) => el[attr] === sel[attr]));
101
+ }
38
102
  const norm = (s) => s.trim().toLowerCase();
39
103
  const strip = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '');
40
104
  /** The ordered match tiers for a selector. First tier with a hit wins. */
@@ -91,8 +155,7 @@ function tiers(sel) {
91
155
  function matchElements(elements, sel) {
92
156
  // Applied BEFORE the tier ladder, not after: filtering the candidate pool keeps a
93
157
  // disabled exact match from shadowing an enabled partial one.
94
- if (sel.enabled)
95
- elements = elements.filter(isActionable);
158
+ elements = filterByState(elements, sel);
96
159
  for (const { tier, test } of tiers(sel)) {
97
160
  const found = elements.filter(test);
98
161
  if (found.length === 0)
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.unsupportedStateAttrs = unsupportedStateAttrs;
4
+ exports.assertStateSupported = assertStateSupported;
5
+ const errors_1 = require("../errors");
6
+ const selector_1 = require("./selector");
7
+ const ios_parse_1 = require("./ios-parse");
8
+ // Which state attributes a platform's accessibility backend can actually answer for.
9
+ //
10
+ // A state modifier narrows the candidate pool, so one the platform never populates does
11
+ // not fail — it matches NOTHING, quietly, after burning the full auto-wait window, and
12
+ // then reports "No element matched selector", which is a claim about the screen that
13
+ // isn't true. That is the same false-signal failure mode `--selected` was added to fix,
14
+ // so it is refused here instead: an unsupported modifier is an environment error (exit
15
+ // 3), the way `clearApp` and `currentApp` already refuse on iOS rather than pretending.
16
+ //
17
+ // This is a capability table, not device I/O — the per-platform truth lives with the
18
+ // parser that hard-codes the value (see IOS_UNREPORTED_STATE), so the two cannot drift.
19
+ const UNREPORTED = {
20
+ android: [], // uiautomator dumps all four as real node attributes
21
+ ios: ios_parse_1.IOS_UNREPORTED_STATE,
22
+ };
23
+ /** State attributes this selector pins that the platform cannot report. */
24
+ function unsupportedStateAttrs(sel, platform) {
25
+ const blind = UNREPORTED[platform];
26
+ if (blind.length === 0)
27
+ return [];
28
+ return selector_1.STATE_ATTRS.filter((attr) => sel[attr] !== undefined && blind.includes(attr));
29
+ }
30
+ /** Throw unless every state modifier on `sel` means something on `platform`. */
31
+ function assertStateSupported(sel, platform) {
32
+ const bad = unsupportedStateAttrs(sel, platform);
33
+ if (bad.length === 0)
34
+ return;
35
+ // Name both polarities: --not-selected is refused for the same reason as --selected,
36
+ // and echoing only the attribute reads as though the wrong flag was typed.
37
+ const named = bad.map((a) => `--${a}/--not-${a}`).join(' and ');
38
+ throw new errors_1.CliError(`${named} cannot be used on ${platform}: its accessibility backend does not report ` +
39
+ `${bad.join(' or ')}, so the selector could only ever match nothing. ` +
40
+ `Match on ${platform === 'ios' ? '@id, text or --enabled/--checked' : 'another attribute'} instead.`, 3);
41
+ }
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.15.0';
6
+ exports.VERSION = '0.16.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "verikun",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
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",