verikun 0.23.0 → 0.24.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.
@@ -93,6 +93,8 @@ usually don't need a `wait` before an action — `vk tap @next` already polls fo
93
93
  - `vk swipe up|down|left|right [--on <selector>] [--distance f] [--duration ms]`
94
94
  - `vk swipe --from x,y --to x,y [--duration ms]`
95
95
  - `vk key <name|code>` · `vk back` · `vk home` · `vk enter`
96
+ Named keys include `sleep` and `wakeup`. `power` is a **toggle**, so use `sleep`/`wakeup`
97
+ when you mean a direction.
96
98
  - `vk screenshot [--out path] [--more] [--max px] [--full]` — saves a PNG (default
97
99
  `./.verikun/screen.png`) and prints the path; then read that file to *see* the
98
100
  screen. It's **downscaled to a 700px longest edge by default** to save tokens
@@ -118,11 +120,15 @@ banner, the retry path, dark theme, a layout that breaks at accessibility text s
118
120
 
119
121
  | key | values | Android | iOS simulator |
120
122
  |---|---|---|---|
123
+ | `animations` | `on\|off` | yes | **no** — nothing disables UIKit animation |
121
124
  | `airplane` | `on\|off` | yes | **no** — a simulator has no radio |
122
125
  | `dark` | `on\|off` | yes | yes |
123
126
  | `font-scale` | `0.5`–`3.0`, `default` | yes | yes (nearest Dynamic Type category) |
124
127
  | `rotation` | `portrait\|landscape\|portrait-reverse\|landscape-reverse\|auto` | yes | **no** |
125
128
  | `stay-awake` | `on\|off` | yes | no-op (simulators don't sleep) |
129
+ | `screen-timeout` | `30s` / `10m` / ms / `max` | yes | no-op (simulators don't sleep) |
130
+ | `dnd` | `on\|off` | yes | **no** — Focus is not scriptable |
131
+ | `doze` | `on\|off` | yes | no-op (no Doze equivalent) |
126
132
 
127
133
  Set several at once: `vk device set dark=on font-scale=1.3`. Each change is **verified by
128
134
  reading it back**, so success means it actually landed — these device commands silently
@@ -134,6 +140,14 @@ banner, the retry path, dark theme, a layout that breaks at accessibility text s
134
140
  even when the flow *fails* — but a bare `vk device set` from a shell stays applied until
135
141
  you reset it, so don't leave someone's phone in airplane mode.
136
142
 
143
+ - `vk device prep [--dry-run] [--revert]` — set a **test** device up once, stickily:
144
+ `animations=off stay-awake=on screen-timeout=max dnd=on doze=off`.
145
+
146
+ Unlike `device set`, prep **survives the run** and is undone only by `--revert`. A physical
147
+ device must be named (`--device <serial>`) — that requirement is deliberate, so prep can
148
+ never land on a personal phone that happened to be plugged in. Do not run it on a device
149
+ the user did not point you at. `vk doctor` reports whether a device is prepared.
150
+
137
151
  Two traps:
138
152
  - **`airplane=off` brings the radio back, not the internet.** Follow it with
139
153
  `vk assert <selector> --wait 10s`, never an immediate `tap`.
@@ -378,7 +392,12 @@ vk ai onboarding.md --timeout 5m # tighten the run timeout (default 15m)
378
392
  failure).
379
393
  - **Bounded by default:** the run aborts if the estimated spend crosses **$3**
380
394
  (`--max-cost-usd`) or the wall-clock passes **15m** (`--timeout`), so a runaway
381
- compile/repair loop can't spend or hang without limit.
395
+ compile/repair loop can't spend or hang without limit. The ceiling is **per test, not per
396
+ suite** — `vk suite` gives each `*.md` its own fresh budget, so 20 tests at the default can
397
+ reach $60; there is no suite-wide cap, so lower the per-test figure instead. A model is only
398
+ ever called to **compile** (once, on a cache miss) or to **repair** (≤3 per failing step);
399
+ replay is always $0, and every non-`ai` command is $0 always. Full mechanism, the estimate
400
+ formula and the cache multipliers: <https://ddikman.github.io/verikun/reference/cost/>.
382
401
  - Exit `0` pass · `1` a step failed (or the budget/timeout was hit) · `2` usage · `3` environment
383
402
  (e.g. the model's API key — `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` — unset, or the `codex` /
384
403
  `cursor-agent` CLI missing / not logged in for `--model codex-cli` / `cursor-cli`).
@@ -510,8 +529,13 @@ owns the redaction and the review-first flow.
510
529
 
511
530
  ## Gotchas
512
531
 
513
- - **Disable animations once** for reliable dumps: `vk doctor --fix`. Live
514
- animations can make `vk ui` flaky (it already retries 3×).
532
+ - **Prepare the device once** for reliable dumps: `vk device prep` (a physical device
533
+ needs `--device <serial>`). Live animations can make `vk ui` flaky (it already retries 3×).
534
+ - **A slept device returns the LOCK SCREEN, not an error.** The dump succeeds and hands
535
+ back `com.android.systemui` — so selectors miss for a reason unrelated to the app.
536
+ verikun detects this, wakes the device and clears a *swipe* lock automatically; on a
537
+ PIN/pattern/password it exits **3** naming the lock rather than returning that dump.
538
+ Tell the user to remove the lock in Settings > Security — verikun never asks for a PIN.
515
539
  - **Ambiguous selector → exit 2**, never a random tap. `vk` prints the candidate
516
540
  matches; add `--index N` or use a more specific selector.
517
541
  - **Indexes are per-snapshot.** `vk tap 3` taps `[3]` from the *latest* dump;
@@ -549,13 +573,15 @@ owns the redaction and the review-first flow.
549
573
  use `xcrun simctl`. Run `vk doctor --ios` to check the toolchain. Caveats: `clear`
550
574
  is unsupported (no per-app reset), `current` is `(unknown)`, device logs are
551
575
  simulator-only, and `device set` is partial — `dark`/`font-scale` work on a simulator
552
- while `airplane`/`rotation` do not exist there at all (`vk device caps --ios`). iOS
576
+ while `animations`/`airplane`/`rotation`/`dnd` do not exist there at all, and
577
+ `stay-awake`/`screen-timeout`/`doze` are no-ops (`vk device caps --ios`). That makes
578
+ `vk device prep` effectively Android-only: on iOS it applies nothing and says so. iOS
553
579
  accessibility ids are often unset, so prefer `text:`/`desc:` selectors there.
554
580
 
555
581
  ## Worked example — verify a login flow
556
582
 
557
583
  ```sh
558
- vk doctor --fix # deterministic UI
584
+ vk doctor # is the device set up? (read-only)
559
585
  vk launch com.example.app
560
586
  vk text @email_input "user@example.com" # field lookup auto-waits up to 5s
561
587
  vk text @password_input "hunter2" --enter
package/CHANGELOG.md CHANGED
@@ -6,6 +6,30 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.24.0] - 2026-08-20
10
+
11
+ ### Added
12
+ - **`vk device prep`**: prepare a test device once — animations off, display awake, DND on, doze off.
13
+ - **`vk device prep --revert`**: restore what prep changed, from a host-global snapshot that outlives the run.
14
+ - **`vk device prep --dry-run`**: show each knob's current and target value without writing.
15
+ - **`vk device set animations|screen-timeout|dnd|doze`**: four new keys, each verified by readback.
16
+ - **`vk key sleep` / `vk key wakeup`**: directional screen keys; `power` remains a toggle.
17
+ - **`vk doctor`**: reports whether the device is prepared and whether it has a screen lock.
18
+ - **Docs site**: a `Cost & budget` reference page — when a model is called, the estimate formula,
19
+ and that `--max-cost-usd` is per test, not per suite. ([#94])
20
+
21
+ ### Changed
22
+ - **`vk doctor --fix`**: now an alias for `device prep`, so a physical device must be named with `--device`.
23
+ - **`vk doctor`**: read-only without `--fix`; it no longer writes animation scales directly.
24
+ - **`vk batch|ai|suite`**: put a prepped device to sleep on teardown; `--no-sleep-when-idle` opts out.
25
+
26
+ ### Fixed
27
+ - **UI reads**: a locked device returned the lock screen as a successful dump; now woken, unlocked if
28
+ the lock is a swipe, else exit `3` naming it.
29
+ - **Docs site**: the `vk ai` cost line was printed in a format the CLI never emitted.
30
+
31
+ [#94]: https://github.com/ddikman/verikun/issues/94
32
+
9
33
  ## [0.23.0] - 2026-08-17
10
34
 
11
35
  ### Added
@@ -30,6 +54,10 @@ All notable changes to this project are documented here. The format is based on
30
54
  - **`vk devices start --server`**: releases the device lock when it finishes, instead of `409`ing
31
55
  the run it just booted the device for.
32
56
 
57
+ ### Changed
58
+ - **Docs site**: content column widened to 60rem, giving the reference tables more room on
59
+ large screens.
60
+
33
61
  ## [0.22.2] - 2026-08-17
34
62
 
35
63
  ### Changed
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  - **Agent CLI** — `vk <command>`: one-shot commands to inspect the screen as a semantic tree (or screenshot) and act on it.
8
8
  - **Puppeteer for native mobile** — a thin wrapper over native Android and iOS automation runners with zero runtime dependencies.
9
- - **Natural-language tests** — `vk ai <file>`: runs plain-English tests, compiled once and replayed model-free (~$0), calling a model only to self-heal a drifted step.
9
+ - **Natural-language tests** — `vk ai <file>`: runs plain-English tests, compiled once and replayed model-free (~$0), calling a model only to self-heal a drifted step. [What that costs](https://ddikman.github.io/verikun/reference/cost/), and how the `--max-cost-usd` ceiling bounds it.
10
10
  - **Self-improving** — the agent runner will provide prescriptive improvements to existing scripts to help stabilise flakiness for future runs.
11
11
  - **CI-ready** — `vk suite` runs a folder of tests as one gated pass/fail run; `vk server` exposes a real device over an authenticated tunnel so a disposable CI runner (no phone attached) can still drive it.
12
12
 
@@ -37,7 +37,8 @@ The package also carries the agent [`SKILL.md`](.claude/skills/verikun/SKILL.md)
37
37
  ## Quick start
38
38
 
39
39
  ```sh
40
- vk doctor --fix # check adb/device; disable animations for stable dumps
40
+ vk doctor # check adb/device (read-only never changes anything)
41
+ vk device prep --device <id> # set a TEST device up once: animations off, stays awake
41
42
  vk devices # list attached devices
42
43
  vk ui # semantic snapshot of the current screen
43
44
  vk tap @login_button # tap by resource-id
package/dist/args.js CHANGED
@@ -44,6 +44,9 @@ const BOOLEAN = new Set([
44
44
  'no-restart',
45
45
  'no-logs',
46
46
  'wipe',
47
+ 'dry-run',
48
+ 'revert',
49
+ 'no-sleep-when-idle',
47
50
  'allow-install',
48
51
  'allow-unsafe-anonymous',
49
52
  // NOT here on purpose: 'allow-device-control' and 'ensure-device' are tri-state
package/dist/cli.js CHANGED
@@ -65,6 +65,7 @@ const adb_1 = require("./drivers/adb");
65
65
  const lifecycle_1 = require("./drivers/lifecycle");
66
66
  const settings_1 = require("./device/settings");
67
67
  const claims_1 = require("./device/claims");
68
+ const prep_1 = require("./device/prep");
68
69
  const selector_1 = require("./ui/selector");
69
70
  const state_support_1 = require("./ui/state-support");
70
71
  const format_1 = require("./ui/format");
@@ -656,10 +657,38 @@ async function cmdDoctor(ctx) {
656
657
  const c = claims ? (0, claims_1.summarize)(serial) : undefined;
657
658
  return c && !c.mine ? c : undefined;
658
659
  };
660
+ // Blank lines separate doctor's three blocks — toolchain, device inventory, target detail.
661
+ // Without them the target's indented lines read as a continuation of the last device row.
662
+ (0, output_1.out)('');
659
663
  (0, output_1.out)(`devices: ${devices.length} attached, ${usable.length} usable`);
660
- for (const d of devices) {
664
+ // Probe each device, not just the one an interaction command would land on: "is there a
665
+ // screen lock" and "is this prepared" are facts about a PHONE, and a host driving three of
666
+ // them cannot act on an answer that does not say which. The lock read is one `dumpsys` per
667
+ // device and the prep read is a local file, which is affordable in an explicit setup check.
668
+ const locks = new Map();
669
+ const rows = devices.map((d) => {
670
+ const facts = [];
671
+ if (d.state === 'device') {
672
+ facts.push((0, prep_1.readPrep)(d.serial) ? 'prepared' : 'not prepared');
673
+ const lock = (0, adb_1.lockKindOf)(d.serial);
674
+ locks.set(d.serial, lock);
675
+ if (lock !== 'none' && lock !== 'unknown')
676
+ facts.push(`screen lock: ${lock}`);
677
+ }
661
678
  const claim = claims ? (0, claims_1.summarize)(d.serial) : undefined;
662
- (0, output_1.out)(` ${d.serial} ${d.state}${d.model ? ` (${d.model})` : ''}${claim ? ` [${claim.by}]` : ''}`);
679
+ return {
680
+ serial: d.serial,
681
+ what: `${d.state}${d.model ? ` (${d.model})` : ''}`,
682
+ facts: facts.join(' · '),
683
+ claim: claim ? `[${claim.by}]` : '',
684
+ };
685
+ });
686
+ // Pad each column to its widest cell so the per-device facts line up and can be scanned
687
+ // down; trimEnd so a device with no facts and no claim leaves no trailing whitespace.
688
+ const w = (pick) => Math.max(...rows.map((r) => pick(r).length), 0);
689
+ const [wSerial, wWhat, wFacts] = [w((r) => r.serial), w((r) => r.what), w((r) => r.facts)];
690
+ for (const r of rows) {
691
+ (0, output_1.out)(` ${r.serial.padEnd(wSerial)} ${r.what.padEnd(wWhat)} ${r.facts.padEnd(wFacts)} ${r.claim}`.trimEnd());
663
692
  }
664
693
  let ok = true;
665
694
  const free = usable.filter((d) => !heldByOther(d.serial));
@@ -681,25 +710,57 @@ async function cmdDoctor(ctx) {
681
710
  // the first FREE one (which is what auto-selection picks), not merely the first attached.
682
711
  const target = ctx.device || free[0]?.serial;
683
712
  if (target) {
684
- try {
685
- const serial = target;
686
- const keys = ['window_animation_scale', 'transition_animation_scale', 'animator_duration_scale'];
687
- const get = (k) => (0, exec_1.runText)(adb, ['-s', serial, 'shell', 'settings', 'get', 'global', k]).stdout.trim();
688
- const vals = keys.map(get);
689
- const off = vals.every((v) => v === '0' || v === '0.0');
690
- (0, output_1.out)(`animations: ${vals.join('/')} ${off ? '(off, good)' : '(ON — flaky dumps; run `verikun doctor --fix`)'}`);
691
- if ((0, args_1.flagBool)(ctx.flags, 'fix') && !off) {
692
- for (const k of keys)
693
- (0, exec_1.runText)(adb, ['-s', serial, 'shell', 'settings', 'put', 'global', k, '0']);
694
- (0, output_1.out)('animations: disabled (good)');
695
- }
696
- }
697
- catch {
698
- (0, output_1.err)('animations: could not read device settings');
713
+ reportTargetStatus(target, adb, locks.get(target) ?? 'unknown');
714
+ // `--fix` is now an alias for `device prep`, so it inherits prep's gate: on a physical
715
+ // device it refuses unless the serial was named. That refusal is the point — the old
716
+ // `--fix` wrote permanently to whichever phone happened to be unclaimed, with no undo.
717
+ if ((0, args_1.flagBool)(ctx.flags, 'fix')) {
718
+ const prepFlags = (0, args_1.flagBool)(ctx.flags, 'no-sleep-when-idle') ? { 'no-sleep-when-idle': true } : {};
719
+ return devicePrep({ ...ctx, flags: prepFlags });
699
720
  }
700
721
  }
701
722
  return ok ? 0 : 3;
702
723
  }
724
+ /**
725
+ * The extra detail for the ONE device an interaction command would land on. Every line names
726
+ * that serial, because the listing above may have shown several and an advisory you cannot
727
+ * attribute to a phone is not actionable.
728
+ *
729
+ * Reads NOTHING through `ctx.driver`, deliberately — resolving a driver claims the device, and
730
+ * doctor surveys the host without taking anything (see the note at the device listing above).
731
+ * The prep record is a plain file read; the animation probe goes through `adb -s <serial>`
732
+ * directly. `lock` is passed in rather than re-probed, so the listing's read is the only one.
733
+ */
734
+ function reportTargetStatus(serial, adb, lock) {
735
+ (0, output_1.out)('');
736
+ (0, output_1.out)(`target: ${serial} — the device an interaction command would use`);
737
+ const rec = (0, prep_1.readPrep)(serial);
738
+ const when = rec?.preparedAt ? ` (${rec.preparedAt.slice(0, 10)})` : '';
739
+ (0, output_1.out)(rec
740
+ ? ` prepared${when} — undo with \`verikun device prep --revert --device ${serial}\``
741
+ : ` not prepared — run \`verikun device prep --device ${serial}\``);
742
+ // Animations are still called out by name rather than folded into "not prepared": they are
743
+ // the single most common cause of a flaky dump, and a device can be prepped and then drift.
744
+ try {
745
+ const vals = ['window_animation_scale', 'transition_animation_scale', 'animator_duration_scale'].map((k) => (0, exec_1.runText)(adb, ['-s', serial, 'shell', 'settings', 'get', 'global', k]).stdout.trim());
746
+ const off = vals.every((v) => Number(v) === 0);
747
+ (0, output_1.out)(` animations: ${vals.join('/')} ${off ? '(off, good)' : '(ON — flaky dumps; run `verikun device prep`)'}`);
748
+ }
749
+ catch {
750
+ (0, output_1.err)(` animations: could not read settings on ${serial}`);
751
+ }
752
+ // Advisory, never a failure: a screen lock is a property of someone's phone, not a broken
753
+ // machine. It is worth saying because of what a slept device actually does — the read
754
+ // SUCCEEDS and returns the keyguard, so selectors miss for a reason unrelated to the app.
755
+ if (lock !== 'none' && lock !== 'unknown') {
756
+ // Indented to sit with the target's other lines, but it still NAMES the serial: this goes
757
+ // to stderr (it is a diagnostic, not data), so under `vk doctor > out.txt` it appears on
758
+ // its own with no `target:` header above it to say which phone it means.
759
+ (0, output_1.err)(` screen lock on ${serial} (${lock}) — while it is up, a read returns the lock screen ` +
760
+ 'rather than the app.\n' +
761
+ ' Remove it on a test device (Settings > Security). verikun never asks for a PIN.');
762
+ }
763
+ }
703
764
  function cmdUi(ctx) {
704
765
  const els = ctx.driver.getElements({ all: (0, args_1.flagBool)(ctx.flags, 'all') });
705
766
  if ((0, args_1.flagBool)(ctx.flags, 'json')) {
@@ -1158,7 +1219,8 @@ function cmdClear(ctx) {
1158
1219
  // of applying dark mode and then dying — a half-applied device is worse than none.
1159
1220
  // - SNAPSHOT FIRST. The pre-change value is persisted before the change is made, so
1160
1221
  // `device reset` can undo it even from a later process.
1161
- const DEVICE_USAGE = 'Usage: verikun device set <key>=<value> [<key>=<value> ...] | device get [key] | device reset [key ...] | device caps | device release [serial]\n' +
1222
+ const DEVICE_USAGE = 'Usage: verikun device set <key>=<value> [<key>=<value> ...] | device get [key] | device reset [key ...] | ' +
1223
+ 'device prep [--revert] [--dry-run] | device caps | device release [serial]\n' +
1162
1224
  `Keys: ${settings_1.SETTING_KEYS.join(', ')}`;
1163
1225
  function cmdDevice(ctx) {
1164
1226
  const sub = (ctx.positionals[0] ?? '').toLowerCase();
@@ -1171,6 +1233,8 @@ function cmdDevice(ctx) {
1171
1233
  return deviceGet(ctx, rest);
1172
1234
  case 'reset':
1173
1235
  return deviceReset(ctx, rest);
1236
+ case 'prep':
1237
+ return devicePrep(ctx);
1174
1238
  case 'caps':
1175
1239
  return deviceCaps(ctx);
1176
1240
  case 'release':
@@ -1311,6 +1375,143 @@ function deviceReset(ctx, args) {
1311
1375
  (0, output_1.out)(summary ? `device reset ${summary}` : 'device reset: nothing to restore');
1312
1376
  return 0;
1313
1377
  }
1378
+ /**
1379
+ * `vk device prep` — establish the test-device knob set, stickily.
1380
+ *
1381
+ * The whole point is that this OUTLIVES the run, so unlike `deviceSet` the snapshot does not
1382
+ * go into the run file (which `ai`/`suite`/`batch` auto-restore from a `finally`) but into the
1383
+ * host-global prep store. See device/prep.ts for why the two stores exist.
1384
+ *
1385
+ * Unsupported and no-op knobs are SKIPPED with a note rather than failing the command, which
1386
+ * is the difference between prep and `device set`: `set` was told which key to change and must
1387
+ * refuse if it cannot, while prep means "establish what this platform can" — so on iOS, where
1388
+ * every knob is a noop or unsupported, it honestly reports doing nothing instead of erroring.
1389
+ */
1390
+ function devicePrep(ctx) {
1391
+ const asJson = (0, args_1.flagBool)(ctx.flags, 'json');
1392
+ const dryRun = (0, args_1.flagBool)(ctx.flags, 'dry-run');
1393
+ const serial = ctx.driver.resolvedSerial();
1394
+ if ((0, args_1.flagBool)(ctx.flags, 'revert'))
1395
+ return devicePrepRevert(ctx, serial, dryRun, asJson);
1396
+ // Partition by what this platform can actually do, so the gate below counts real writes.
1397
+ const skipped = [];
1398
+ const applicable = [];
1399
+ for (const knob of prep_1.PREP_KNOBS) {
1400
+ const spec = settings_1.SETTINGS[knob.key];
1401
+ const support = spec.support[ctx.platform];
1402
+ if (support === 'unsupported') {
1403
+ skipped.push({ key: knob.key, reason: spec.manual[ctx.platform] ?? `not supported on ${ctx.platform}` });
1404
+ continue;
1405
+ }
1406
+ if (support === 'noop') {
1407
+ skipped.push({ key: knob.key, reason: spec.note[ctx.platform] ?? 'already satisfied on this platform' });
1408
+ continue;
1409
+ }
1410
+ // parse() so the target compares equal to a readback (`max` -> the millisecond string).
1411
+ applicable.push({
1412
+ key: knob.key,
1413
+ target: spec.parse(knob.value),
1414
+ current: ctx.driver.getDeviceSetting(knob.key),
1415
+ why: knob.why,
1416
+ });
1417
+ }
1418
+ const changes = applicable.filter((k) => k.current !== k.target);
1419
+ // `--dry-run` is deliberately NOT gated: it writes nothing, and refusing it would mean you
1420
+ // could not find out what prep would do to a phone without first asserting it is a test one.
1421
+ if (dryRun) {
1422
+ if (asJson) {
1423
+ (0, output_1.json)({ serial, prepared: (0, prep_1.isPrepared)(serial), dryRun: true, changes, skipped });
1424
+ }
1425
+ else {
1426
+ (0, output_1.out)(`device prep ${serial} (dry run — nothing written)`);
1427
+ for (const k of applicable) {
1428
+ const state = k.current === k.target ? 'already' : `${showValue(k.current)} -> ${k.target}`;
1429
+ (0, output_1.out)(` ${k.key.padEnd(15)} ${state}`);
1430
+ if (k.current !== k.target)
1431
+ (0, output_1.out)(` ${' '.repeat(15)} ${k.why}`);
1432
+ }
1433
+ for (const s of skipped)
1434
+ (0, output_1.out)(` ${s.key.padEnd(15)} skipped: ${s.reason.replace(/\n/g, ' ')}`);
1435
+ }
1436
+ return 0;
1437
+ }
1438
+ // The gate, immediately before the first write. `!!ctx.device` is true for --device AND
1439
+ // VERIKUN_DEVICE: both are a deliberate act naming one phone, which is the property asked for.
1440
+ const kind = ctx.driver.listDevices().find((d) => d.serial === serial)?.kind ?? 'physical';
1441
+ (0, prep_1.assertPreppable)(kind, serial, !!ctx.device, applicable.length);
1442
+ const original = {};
1443
+ for (const k of applicable) {
1444
+ // Snapshot even a knob already at target: `--revert` has to put back what the device
1445
+ // held before prep, and "it was already off" is exactly as much a fact as a change.
1446
+ if (k.current !== null)
1447
+ original[k.key] = k.current;
1448
+ if (k.current !== k.target)
1449
+ ctx.driver.setDeviceSetting(k.key, k.target);
1450
+ }
1451
+ // Earliest wins across re-preps, or `--revert` would restore the device to prepped.
1452
+ const prior = (0, prep_1.readPrep)(serial);
1453
+ const sleepWhenIdle = !(0, args_1.flagBool)(ctx.flags, 'no-sleep-when-idle');
1454
+ (0, prep_1.writePrep)((0, prep_1.newPrepRecord)(serial, ctx.platform, (0, prep_1.mergeOriginals)(prior?.original ?? {}, original), sleepWhenIdle));
1455
+ const applied = Object.fromEntries(applicable.map((k) => [k.key, k.target]));
1456
+ ctx.record?.note({ message: `device prep ${serial} (${changes.length} changed)` });
1457
+ if (asJson) {
1458
+ (0, output_1.json)({ serial, applied, changed: changes.map((c) => c.key), skipped, sleepWhenIdle });
1459
+ }
1460
+ else {
1461
+ (0, output_1.out)(`device prep ${serial}: ${changes.length ? changes.map((c) => `${c.key}=${c.target}`).join(' ') : 'already prepared'}`);
1462
+ for (const s of skipped)
1463
+ (0, output_1.err)(`note: ${s.key} skipped — ${s.reason.replace(/\n/g, ' ')}`);
1464
+ if (sleepWhenIdle)
1465
+ (0, output_1.err)('note: this device will be put to sleep when a run using it finishes');
1466
+ (0, output_1.err)(`undo with: verikun device prep --revert --device ${serial}`);
1467
+ }
1468
+ return 0;
1469
+ }
1470
+ /** Put a prepared device back the way it was found, and forget it. */
1471
+ function devicePrepRevert(ctx, serial, dryRun, asJson) {
1472
+ const rec = (0, prep_1.readPrep)(serial);
1473
+ if (!rec) {
1474
+ if (asJson)
1475
+ (0, output_1.json)({ serial, prepared: false, restored: {} });
1476
+ else
1477
+ (0, output_1.out)(`device prep --revert ${serial}: not prepared, nothing to restore`);
1478
+ return 0;
1479
+ }
1480
+ const entries = Object.entries(rec.original);
1481
+ if (dryRun) {
1482
+ if (asJson)
1483
+ (0, output_1.json)({ serial, prepared: true, dryRun: true, wouldRestore: rec.original });
1484
+ else {
1485
+ (0, output_1.out)(`device prep --revert ${serial} (dry run — nothing written)`);
1486
+ for (const [key, value] of entries)
1487
+ (0, output_1.out)(` ${key.padEnd(15)} ${showValue(ctx.driver.getDeviceSetting(key))} -> ${value}`);
1488
+ }
1489
+ return 0;
1490
+ }
1491
+ const restored = {};
1492
+ const failed = [];
1493
+ for (const [key, value] of entries) {
1494
+ try {
1495
+ ctx.driver.setDeviceSetting(key, value);
1496
+ restored[key] = value;
1497
+ }
1498
+ catch (e) {
1499
+ // Best-effort, like `device reset`: one stubborn knob must not strand the rest.
1500
+ failed.push(`${key} (${e instanceof Error ? e.message.split('\n')[0] : String(e)})`);
1501
+ }
1502
+ }
1503
+ // Forget the device even if a knob refused. Keeping the record would leave `vk doctor`
1504
+ // reporting it as prepped forever, and the values that DID go back are already back.
1505
+ (0, prep_1.clearPrep)(serial);
1506
+ ctx.record?.note({ message: `device prep --revert ${serial}` });
1507
+ if (failed.length)
1508
+ (0, output_1.err)(`warning: could not restore ${failed.join(', ')}`);
1509
+ if (asJson)
1510
+ (0, output_1.json)({ serial, restored, ...(failed.length ? { failed } : {}) });
1511
+ else
1512
+ (0, output_1.out)(`device prep --revert ${serial}: ${Object.entries(restored).map(([k, v]) => `${k}=${v}`).join(' ') || 'nothing to restore'}`);
1513
+ return 0;
1514
+ }
1314
1515
  function deviceCaps(ctx) {
1315
1516
  const rows = settings_1.SETTING_KEYS.map((key) => {
1316
1517
  const spec = settings_1.SETTINGS[key];
@@ -1355,6 +1556,35 @@ function requireSettingKey(v) {
1355
1556
  * same code. (Remote is a known gap: the overrides live in the *server's* run file, so
1356
1557
  * a locally-empty snapshot means this correctly skips — see the issue's Out of scope.)
1357
1558
  */
1559
+ /**
1560
+ * Park prepared devices when the flow that used them ends — #97's "in sleep mode when they're
1561
+ * not [in use]".
1562
+ *
1563
+ * Three properties worth keeping:
1564
+ *
1565
+ * * It can only ever touch a device you explicitly PREPPED, and only if that prep did not
1566
+ * pass `--no-sleep-when-idle`. A borrowed phone that was never prepped is never slept.
1567
+ * * It goes through the driver rather than the command dispatcher, so it does not appear as
1568
+ * a `key sleep` testcase in the report. Parking is host hygiene, not a test step.
1569
+ * * `ownClaimedSerials()` is empty in `--server` mode (the local process never resolves a
1570
+ * device), so the remote path degrades to doing nothing on its own, with no branch here.
1571
+ * Managing a remote device's power is `--allow-device-control`'s job.
1572
+ *
1573
+ * Must run BEFORE `releaseOwnClaims()`, which is what it reads its serials from.
1574
+ */
1575
+ function parkPreparedDevices(platform) {
1576
+ for (const serial of (0, claims_1.ownClaimedSerials)()) {
1577
+ if (!(0, prep_1.readPrep)(serial)?.sleepWhenIdle)
1578
+ continue;
1579
+ try {
1580
+ (0, output_1.err)(`[verikun] parking prepared device ${serial} (sleep)`);
1581
+ (0, drivers_1.getDriver)(platform, serial).pressKey('sleep');
1582
+ }
1583
+ catch {
1584
+ /* teardown must never throw — the device may be exactly why we are unwinding */
1585
+ }
1586
+ }
1587
+ }
1358
1588
  async function restoreDeviceOverrides(backend) {
1359
1589
  if (!run_1.Recorder.hasDeviceOverrides())
1360
1590
  return;
@@ -1635,6 +1865,7 @@ async function cmdBatch(positionals, batchFlags) {
1635
1865
  /* the device may be exactly why we are unwinding — never mask the real error */
1636
1866
  }
1637
1867
  }
1868
+ parkPreparedDevices(platformFromFlags(batchFlags));
1638
1869
  (0, claims_1.releaseOwnClaims)();
1639
1870
  }
1640
1871
  }
@@ -1693,8 +1924,9 @@ function providerRequirement(model) {
1693
1924
  return id === 'openai' ? 'OPENAI_API_KEY is not set' : 'ANTHROPIC_API_KEY is not set';
1694
1925
  }
1695
1926
  /** Route the model to its backend. HTTP providers read their own key; a CLI provider shells
1696
- * out to its logged-in binary. Unavailable → null (compile/repair off), the same graceful
1697
- * degradation as before: a cached plan can still replay for free without any provider.
1927
+ * out to its logged-in binary. Unavailable → null (compile/repair off). That is NOT a free
1928
+ * replay path: runAiTest turns a null provider into exit 3 even at a 100% cache hit, because
1929
+ * it must be able to repair a drifted step at runtime — only --show-plan degrades gracefully.
1698
1930
  * CLI providers get no `model`, so the CLI picks its own default — the "I just have a
1699
1931
  * subscription" path; --effort is likewise inapplicable to them. */
1700
1932
  function makeProvider(opts) {
@@ -2230,6 +2462,7 @@ async function cmdAi(positionals, flags) {
2230
2462
  // otherwise an unattended run leaves the phone offline or in dark mode.
2231
2463
  await restoreDeviceOverrides(backend);
2232
2464
  await backend.close?.(); // frees a remote server's device lock for the next command
2465
+ parkPreparedDevices(platform); // a prepped device goes back to sleep between runs
2233
2466
  (0, claims_1.releaseOwnClaims)(); // and the host-level claim, so the next job can have the device
2234
2467
  }
2235
2468
  if ((0, args_1.flagBool)(flags, 'json')) {
@@ -2318,6 +2551,7 @@ async function cmdSuiteEntry(positionals, flags) {
2318
2551
  finally {
2319
2552
  await restoreDeviceOverrides(backend);
2320
2553
  await backend.close?.();
2554
+ parkPreparedDevices(platform);
2321
2555
  (0, claims_1.releaseOwnClaims)();
2322
2556
  }
2323
2557
  }
@@ -2665,6 +2899,13 @@ DEVICE STATE (change the device the app runs on, then put it back)
2665
2899
  device reset [key ...] Restore what this run changed. batch/ai/suite also
2666
2900
  do this automatically when the flow ends OR fails,
2667
2901
  so a dead test can't leave the phone offline.
2902
+ device prep [--dry-run] [--json] Prepare a TEST device once, stickily: animations off,
2903
+ display kept awake, Do Not Disturb on, battery idle off.
2904
+ Survives the run (unlike \`device set\`), so it is undone
2905
+ only by \`--revert\`. A PHYSICAL device must be named with
2906
+ --device — prep must never land on a personal phone.
2907
+ --no-sleep-when-idle keeps the screen on after a run.
2908
+ device prep --revert [--dry-run] Put a prepared device back the way it was found
2668
2909
  device caps [--json] What this platform supports, and the manual
2669
2910
  equivalent where it doesn't
2670
2911
  Refuses \`airplane=on\` over wireless adb (it would cut
@@ -31,11 +31,13 @@
31
31
  // Platform-agnostic by design, like `device/settings.ts` and `ui/`: it never touches
32
32
  // adb/xcrun. The drivers know which devices exist; this knows which are taken.
33
33
  Object.defineProperty(exports, "__esModule", { value: true });
34
+ exports.ownClaimedSerials = ownClaimedSerials;
34
35
  exports.releaseOwnClaims = releaseOwnClaims;
35
36
  exports.setProcessScoped = setProcessScoped;
36
37
  exports.claimsEnabled = claimsEnabled;
37
38
  exports.claimTtlMs = claimTtlMs;
38
39
  exports.claimsDir = claimsDir;
40
+ exports.deviceFileStem = deviceFileStem;
39
41
  exports.isMine = isMine;
40
42
  exports.isLive = isLive;
41
43
  exports.readClaim = readClaim;
@@ -71,6 +73,11 @@ let processScoped = false;
71
73
  * store what it took is the only way it can release anything.
72
74
  */
73
75
  const acquired = new Set();
76
+ /** Which devices this process took. Read by the prep teardown, which has to act on them
77
+ * BEFORE `releaseOwnClaims` empties this — see `parkPreparedDevices` in cli.ts. */
78
+ function ownClaimedSerials() {
79
+ return [...acquired];
80
+ }
74
81
  /** Give back every device this process claimed. Best-effort; teardown must never throw. */
75
82
  function releaseOwnClaims(o = {}) {
76
83
  const released = [];
@@ -116,9 +123,12 @@ function claimsDir(o = {}) {
116
123
  * two serials that sanitize alike (`192.168.1.5:5555` vs `192.168.1.5_5555`) can never
117
124
  * collide onto one file and silently share a claim.
118
125
  */
119
- function fileFor(serial) {
126
+ function deviceFileStem(serial) {
120
127
  const safe = serial.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 48);
121
- return `${safe}-${(0, node_crypto_1.createHash)('sha1').update(serial).digest('hex').slice(0, 8)}.json`;
128
+ return `${safe}-${(0, node_crypto_1.createHash)('sha1').update(serial).digest('hex').slice(0, 8)}`;
129
+ }
130
+ function fileFor(serial) {
131
+ return `${deviceFileStem(serial)}.json`;
122
132
  }
123
133
  function pathFor(serial, o) {
124
134
  return (0, node_path_1.join)(claimsDir(o), fileFor(serial));
@@ -0,0 +1,156 @@
1
+ "use strict";
2
+ // Test-device provisioning — "make this phone a good place to run tests, once".
3
+ //
4
+ // The distinction that shapes everything here is PREP vs `device set`:
5
+ //
6
+ // * `device set` is TRANSIENT. It snapshots into the run file and the `finally` in
7
+ // ai/suite/batch puts it back, because a test that goes offline must come back online.
8
+ // * PREP is STICKY. Turning animations off and the display timeout up is not part of a
9
+ // test, it is what makes tests readable at all — so it must survive the run that
10
+ // established it, and be undone only when someone explicitly asks.
11
+ //
12
+ // That is why the snapshot lives HOST-GLOBALLY here rather than in `RunState.deviceOverrides`,
13
+ // and why no `finally` may touch it.
14
+ //
15
+ // Deliberately in its own directory (`~/.verikun/prepared/`) rather than beside the claims in
16
+ // `~/.verikun/devices/`: claims are churn — created, taken over and deleted constantly, and
17
+ // `rm ~/.verikun/devices/*` is a thing people genuinely do to clear a stuck one. A prep record
18
+ // is the ONLY copy of the values needed to put a borrowed phone back, so it must not sit in
19
+ // the directory whose contents are routinely swept.
20
+ //
21
+ // Platform-agnostic like `settings.ts` and `claims.ts`: it never touches adb/xcrun. The table
22
+ // says what a knob is, this says which knobs prep establishes and what they used to be, and
23
+ // the drivers know how.
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.PREP_KNOBS = void 0;
26
+ exports.prepDir = prepDir;
27
+ exports.readPrep = readPrep;
28
+ exports.isPrepared = isPrepared;
29
+ exports.mergeOriginals = mergeOriginals;
30
+ exports.writePrep = writePrep;
31
+ exports.clearPrep = clearPrep;
32
+ exports.newPrepRecord = newPrepRecord;
33
+ exports.assertPreppable = assertPreppable;
34
+ const node_fs_1 = require("node:fs");
35
+ const node_os_1 = require("node:os");
36
+ const node_path_1 = require("node:path");
37
+ const errors_1 = require("../errors");
38
+ const output_1 = require("../output");
39
+ const version_1 = require("../version");
40
+ const claims_1 = require("./claims");
41
+ /**
42
+ * The prep set.
43
+ *
44
+ * Every entry has to name a failure `vk` actually has. This is not "sensible defaults for a
45
+ * phone" — it is the shortest list that makes a hierarchy read trustworthy, and anything that
46
+ * merely feels tidy belongs in the user's own `device set` call instead.
47
+ */
48
+ exports.PREP_KNOBS = [
49
+ { key: 'animations', value: 'off', why: 'a live animation makes `uiautomator dump` return a stale or empty screen' },
50
+ // Measured: a slept device does NOT fail the read — it serves the lock screen as a
51
+ // successful dump. Keeping the display up is what stops that, and it is why these two
52
+ // knobs are in the set rather than being left to the recovery path in getElements().
53
+ { key: 'stay-awake', value: 'on', why: 'a slept display reads back as the LOCK SCREEN, not as an error' },
54
+ { key: 'screen-timeout', value: 'max', why: 'the same, for a device that is not plugged in' },
55
+ { key: 'dnd', value: 'on', why: 'a heads-up notification lands on top of the app and steals the next tap' },
56
+ { key: 'doze', value: 'off', why: 'battery idle suspends the background work a test is waiting on' },
57
+ ];
58
+ function prepDir(o = {}) {
59
+ return (0, node_path_1.join)(o.home ?? (0, node_os_1.homedir)(), '.verikun', 'prepared');
60
+ }
61
+ function pathFor(serial, o) {
62
+ return (0, node_path_1.join)(prepDir(o), `${(0, claims_1.deviceFileStem)(serial)}.json`);
63
+ }
64
+ /**
65
+ * Read a device's prep record. Missing, unreadable or malformed all mean NOT PREPARED —
66
+ * the same tolerant posture as `readClaim` and the plan cache. A poisoned file must not be
67
+ * able to make a device permanently un-preppable.
68
+ */
69
+ function readPrep(serial, o = {}) {
70
+ const p = pathFor(serial, o);
71
+ if (!(0, node_fs_1.existsSync)(p))
72
+ return null;
73
+ try {
74
+ const r = JSON.parse((0, node_fs_1.readFileSync)(p, 'utf8'));
75
+ // Guard the field every decision reads. A file that parses as JSON but is not a prep
76
+ // record must not make `--revert` iterate undefined.
77
+ if (typeof r?.serial !== 'string' || typeof r?.original !== 'object' || r.original === null)
78
+ return null;
79
+ return r;
80
+ }
81
+ catch (e) {
82
+ (0, output_1.err)(`[verikun] ignoring unreadable prep record ${p} (${e.message})`);
83
+ return null;
84
+ }
85
+ }
86
+ function isPrepared(serial, o = {}) {
87
+ return readPrep(serial, o) !== null;
88
+ }
89
+ /**
90
+ * Earliest wins. Re-prepping a device (or prepping one whose knobs have drifted) must never
91
+ * overwrite the pre-prep value with a value prep itself established — that would make
92
+ * `--revert` restore the device to prepped, i.e. do nothing at all, silently.
93
+ */
94
+ function mergeOriginals(prior, fresh) {
95
+ const merged = { ...prior };
96
+ for (const [k, v] of Object.entries(fresh)) {
97
+ if (!(k in merged) && v !== undefined)
98
+ merged[k] = v;
99
+ }
100
+ return merged;
101
+ }
102
+ /** Write atomically, so a concurrent reader sees the old record or the new one, never half. */
103
+ function writePrep(rec, o = {}) {
104
+ const p = pathFor(rec.serial, o);
105
+ (0, node_fs_1.mkdirSync)(prepDir(o), { recursive: true });
106
+ const tmp = `${p}.tmp-${process.pid}`;
107
+ (0, node_fs_1.writeFileSync)(tmp, JSON.stringify(rec, null, 2));
108
+ (0, node_fs_1.renameSync)(tmp, p);
109
+ }
110
+ function clearPrep(serial, o = {}) {
111
+ try {
112
+ (0, node_fs_1.unlinkSync)(pathFor(serial, o));
113
+ }
114
+ catch {
115
+ /* already gone is the outcome we wanted */
116
+ }
117
+ }
118
+ function newPrepRecord(serial, platform, original, sleepWhenIdle, o = {}) {
119
+ return {
120
+ serial,
121
+ platform,
122
+ original,
123
+ sleepWhenIdle,
124
+ preparedAt: new Date(o.now ?? Date.now()).toISOString(),
125
+ version: version_1.VERSION,
126
+ };
127
+ }
128
+ // --- the explicitness gate --------------------------------------------------
129
+ /**
130
+ * Refuse to prep a physical device that the caller did not name.
131
+ *
132
+ * Emulators and simulators are disposable; a physical phone might be the one in your pocket.
133
+ * `lifecycle.ts:203` already draws this exact line (it refuses to power-cycle a physical
134
+ * device), and this is the second caller — hence `DeviceKind` from types.ts rather than a
135
+ * parallel two-value type that would have to be kept in step with it.
136
+ *
137
+ * Naming the serial IS the opt-in — there is deliberately no `vk device trust` verb and no
138
+ * persisted allow-list. One less piece of state to go stale, and the thing you type names the
139
+ * phone you mean, which a flag like `--yes` never does: an agent would simply always pass it,
140
+ * and it would stop meaning anything (the same reason device claims have no `--force`).
141
+ *
142
+ * `writes` is how many knobs would actually be applied. When it is zero there is nothing to
143
+ * protect against — that is what makes `vk device prep` on iOS, where every knob is a noop or
144
+ * unsupported, report honestly instead of refusing for no reason.
145
+ *
146
+ * An UNKNOWN kind is treated as physical by the caller, deliberately: the fail-safe direction
147
+ * here is more gating, never less.
148
+ */
149
+ function assertPreppable(kind, serial, named, writes) {
150
+ if (writes === 0 || kind !== 'physical' || named)
151
+ return;
152
+ throw new errors_1.CliError(`Refusing to prepare ${serial}: it is a physical device and was not named explicitly.\n` +
153
+ 'Prep changes settings that outlive the run (animations, display timeout, Do Not Disturb), ' +
154
+ 'so it must never land on a personal phone that happened to be plugged in.\n' +
155
+ `If this is a test device, say so by naming it:\n verikun device prep --device ${serial}`, 2);
156
+ }
@@ -70,6 +70,42 @@ function parseFontScale(raw) {
70
70
  // Canonicalize so '1.30', '1.3' and '1.300' all snapshot/compare identically.
71
71
  return canonicalFontScale(n);
72
72
  }
73
+ // --- screen timeout ---------------------------------------------------------
74
+ /**
75
+ * Android stores `screen_off_timeout` as a signed 32-bit int of MILLISECONDS, so this is
76
+ * as close to "never" as the setting can express. `max` is offered as a name because the
77
+ * number is meaningless to read and nobody should have to remember it.
78
+ */
79
+ const SCREEN_TIMEOUT_MAX_MS = 2147483647;
80
+ /** Below a second the display would blank between two steps of the same tap. A value
81
+ * that low is a typo, not an intent — refuse it here rather than after it is written. */
82
+ const SCREEN_TIMEOUT_MIN_MS = 1000;
83
+ /**
84
+ * Canonical form is the raw millisecond string, NOT a pretty duration, because the
85
+ * device stores and reads back milliseconds. A snapshot has to round-trip through
86
+ * `setDeviceSetting` unchanged, and `30s` vs `30000` would compare unequal for the same
87
+ * value and report a perfectly good restore as refused — the same trap
88
+ * `canonicalFontScale` exists to close.
89
+ */
90
+ function parseScreenTimeout(raw) {
91
+ const t = raw.trim().toLowerCase();
92
+ if (t === 'max' || t === 'never')
93
+ return String(SCREEN_TIMEOUT_MAX_MS);
94
+ const m = /^(\d+(?:\.\d+)?)(ms|s|m)?$/.exec(t);
95
+ if (!m) {
96
+ throw new errors_1.CliError(`Invalid value '${raw}' for 'screen-timeout': expected a duration like 30s, 10m, ` +
97
+ "a bare number of milliseconds, or 'max'.", 2);
98
+ }
99
+ // A bare number is milliseconds, matching what the device stores and what
100
+ // `parseDuration` does everywhere else in the CLI.
101
+ const unit = m[2] ?? 'ms';
102
+ const n = Number(m[1]);
103
+ const ms = Math.round(unit === 'm' ? n * 60000 : unit === 's' ? n * 1000 : n);
104
+ if (ms < SCREEN_TIMEOUT_MIN_MS || ms > SCREEN_TIMEOUT_MAX_MS) {
105
+ throw new errors_1.CliError(`Invalid value '${raw}' for 'screen-timeout': must be between ${SCREEN_TIMEOUT_MIN_MS}ms and 'max'.`, 2);
106
+ }
107
+ return String(ms);
108
+ }
73
109
  /** Android `user_rotation` values, by the name we expose. */
74
110
  const ROTATIONS = {
75
111
  portrait: 0,
@@ -145,6 +181,19 @@ function contentSizeToFontScale(category) {
145
181
  }
146
182
  // --- the table --------------------------------------------------------------
147
183
  exports.SETTINGS = {
184
+ animations: {
185
+ key: 'animations',
186
+ describe: 'Window/transition/animator scales — live animations are the main cause of flaky dumps',
187
+ values: 'on|off',
188
+ parse: (raw) => parseOnOff('animations', raw),
189
+ support: { android: 'supported', ios: 'unsupported' },
190
+ manual: {
191
+ ios: 'Neither `simctl` nor `idb` can disable UIKit animation. The Simulator menu offers only ' +
192
+ 'Debug > Slow Animations, which is the opposite of what a test wants — prefer `vk` selector ' +
193
+ 'auto-wait, which polls until the screen settles.',
194
+ },
195
+ note: {},
196
+ },
148
197
  airplane: {
149
198
  key: 'airplane',
150
199
  describe: 'Airplane mode — cut the radios to test offline/retry handling',
@@ -200,6 +249,39 @@ exports.SETTINGS = {
200
249
  manual: {},
201
250
  note: { ios: 'simulators do not sleep, so there is nothing to keep awake' },
202
251
  },
252
+ 'screen-timeout': {
253
+ key: 'screen-timeout',
254
+ describe: "How long the display stays on when idle — 'max' is the test-device setting",
255
+ values: "a duration like 30s or 10m, a bare number of milliseconds, or 'max'",
256
+ parse: parseScreenTimeout,
257
+ support: { android: 'supported', ios: 'noop' },
258
+ manual: {},
259
+ note: { ios: 'simulators do not sleep, so there is no display timeout to extend' },
260
+ },
261
+ dnd: {
262
+ key: 'dnd',
263
+ describe: 'Do Not Disturb — stops a heads-up notification stealing a tap mid-run',
264
+ values: 'on|off',
265
+ parse: (raw) => parseOnOff('dnd', raw),
266
+ support: { android: 'supported', ios: 'unsupported' },
267
+ manual: {
268
+ ios: '`simctl ui` exposes only appearance/content_size/increase_contrast, and neither it ' +
269
+ "nor `idb` can reach Focus. Turn it on inside the simulator: Settings > Focus > Do Not Disturb.",
270
+ },
271
+ note: {},
272
+ },
273
+ doze: {
274
+ key: 'doze',
275
+ describe: 'Battery idle/Doze — leave it off on a test device so background work is not suspended',
276
+ values: 'on|off',
277
+ parse: (raw) => parseOnOff('doze', raw),
278
+ support: { android: 'supported', ios: 'noop' },
279
+ manual: {},
280
+ note: {
281
+ ios: 'iOS has no scriptable Doze equivalent, and a simulator does not idle-suspend ' +
282
+ 'the app the way Android does — there is nothing to turn off',
283
+ },
284
+ },
203
285
  };
204
286
  exports.SETTING_KEYS = Object.keys(exports.SETTINGS);
205
287
  function isSettingKey(v) {
@@ -2,6 +2,9 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.AdbDriver = void 0;
4
4
  exports.probeAdb = probeAdb;
5
+ exports.parseLockKind = parseLockKind;
6
+ exports.looksLikeSystemUi = looksLikeSystemUi;
7
+ exports.lockKindOf = lockKindOf;
5
8
  exports.escapeText = escapeText;
6
9
  exports.adbTransport = adbTransport;
7
10
  exports.severanceRisk = severanceRisk;
@@ -46,6 +49,90 @@ function probeAdb() {
46
49
  return { name: 'adb', ok: false, detail: e.message, hint: ADB_HINT };
47
50
  }
48
51
  }
52
+ /**
53
+ * What kind of screen lock a `dumpsys lock_settings` dump describes. Pure, and exported so
54
+ * the two measured rules below are unit-testable without a device.
55
+ *
56
+ * * MATCH CASE-INSENSITIVELY. The string changed between releases — `CredentialType: Pin`
57
+ * on API 32, `CredentialType: PIN` on API 35.
58
+ * * NEVER read the neighbouring `Quality` field. It reported 196608 on API 32 and 0 on API
59
+ * 35 for two devices that BOTH had a PIN set, so trusting it would report a locked phone
60
+ * as unlocked — and "no lock" is exactly what licenses us to walk past a keyguard.
61
+ *
62
+ * Anything unrecognized is `unknown`, never `none`: a failed read must not be able to grant
63
+ * permission that a successful read would have withheld.
64
+ *
65
+ * MEASURED GAP: API 29 does not print `CredentialType` at all — its dump is just
66
+ * `SP Enabled` / `SP Handle` / `SID` — so this returns `unknown` there and `vk doctor` stays
67
+ * silent about the lock. That is the honest degrade and it costs nothing important, because
68
+ * the read-time protection in `AdbDriver.onKeyguard` keys off `dumpsys trust`'s `deviceLocked`,
69
+ * which works on API 29. `SID` looks like a usable substitute but is not: only the
70
+ * credential-present case has been observed, so reading it could only ever be a guess.
71
+ */
72
+ function parseLockKind(dump) {
73
+ const m = /CredentialType:\s*(\w+)/i.exec(dump);
74
+ switch (m?.[1].toLowerCase()) {
75
+ case 'none':
76
+ return 'none';
77
+ case 'pin':
78
+ return 'pin';
79
+ case 'pattern':
80
+ return 'pattern';
81
+ case 'password':
82
+ return 'password';
83
+ default:
84
+ return 'unknown';
85
+ }
86
+ }
87
+ const SYSTEMUI_ID_PREFIX = 'com.android.systemui:id/';
88
+ /** The platform's own namespace. Neutral evidence — see `looksLikeSystemUi`. */
89
+ const FRAMEWORK_ID_PREFIX = 'android:id/';
90
+ /**
91
+ * Is every package-qualified resource-id in this hierarchy the system's rather than an app's?
92
+ *
93
+ * The free half of the keyguard check (see `AdbDriver.onKeyguard`). Pure, and exported so the
94
+ * discriminator is unit-testable without a device.
95
+ *
96
+ * Two rules, both measured on a locked Pixel 3a (API 32):
97
+ *
98
+ * * Nodes with no package-qualified id are SKIPPED, not counted either way. Most nodes have
99
+ * no resource-id at all, and a Flutter app has none anywhere, so the answer hinges on
100
+ * whether any APP id appears rather than on a ratio.
101
+ * * `android:` is NEUTRAL, not disqualifying. The lock screen inflates framework notification
102
+ * layouts inside SystemUI's rows — `android:id/status_bar_latest_event_content`,
103
+ * `android:id/icon` — so treating the platform namespace as "this must be an app" made the
104
+ * check pass on a bare keyguard and fail once a notification was on it. One third-party
105
+ * package id is proof we are looking at an app; none, plus at least one SystemUI id, is the
106
+ * keyguard's signature.
107
+ *
108
+ * False positives are cheap and safe: they cost one `dumpsys trust`, which then reports the
109
+ * device unlocked and the elements are returned untouched.
110
+ */
111
+ function looksLikeSystemUi(els) {
112
+ let sawSystemUi = false;
113
+ for (const e of els) {
114
+ if (!e.id.includes(':id/'))
115
+ continue;
116
+ if (e.id.startsWith(SYSTEMUI_ID_PREFIX)) {
117
+ sawSystemUi = true;
118
+ continue;
119
+ }
120
+ if (e.id.startsWith(FRAMEWORK_ID_PREFIX))
121
+ continue;
122
+ return false;
123
+ }
124
+ return sawSystemUi;
125
+ }
126
+ /** Screen-lock kind for a serial WITHOUT building a driver — and therefore without taking a
127
+ * claim, which is what lets `vk doctor` report it while staying the read-only survey it is. */
128
+ function lockKindOf(serial) {
129
+ try {
130
+ return parseLockKind((0, exec_1.runText)(ADB, ['-s', serial, 'shell', 'dumpsys', 'lock_settings']).stdout);
131
+ }
132
+ catch {
133
+ return 'unknown';
134
+ }
135
+ }
49
136
  /** Short probes only — a boot poll must not block the event loop (see wait.ts). */
50
137
  const PROBE_TIMEOUT_MS = 5000;
51
138
  /** The boot animation lags sys.boot_completed; waiting it out makes dumps less flaky,
@@ -76,7 +163,11 @@ const KEYCODES = {
76
163
  dpad_left: 21,
77
164
  dpad_right: 22,
78
165
  dpad_center: 23,
166
+ // `power` is a TOGGLE, so it cannot express "make sure the screen is on". sleep/wakeup
167
+ // are the directional pair, and they are what the wake-before-dump path below uses.
79
168
  power: 26,
169
+ sleep: 223,
170
+ wakeup: 224,
80
171
  app_switch: 187,
81
172
  recents: 187,
82
173
  volume_up: 24,
@@ -166,6 +257,12 @@ function severanceRisk(transport, key, value) {
166
257
  * local writes, so they land in well under a second or not at all. */
167
258
  const VERIFY_TIMEOUT_MS = 4000;
168
259
  const VERIFY_INTERVAL_MS = 200;
260
+ /** How long to let the screen settle after a wakeup before reading it. Long enough for
261
+ * the unlock/wake animation, short enough that it is noise next to the dump it precedes. */
262
+ const WAKE_SETTLE_MS = 600;
263
+ /** The three `global` scales behind the `animations` setting. All must be zero for it to
264
+ * read `off` — one live scale is enough to make a dump flaky. */
265
+ const ANIMATION_SCALES = ['window_animation_scale', 'transition_animation_scale', 'animator_duration_scale'];
169
266
  /**
170
267
  * Parse `adb devices -l` output into DeviceInfo[]. PURE — exported for unit tests
171
268
  * and reused by the device-lifecycle layer, which needs the attached-device list
@@ -511,12 +608,89 @@ class AdbDriver {
511
608
  return (0, exec_1.runText)(ADB, this.withSerial(['shell', ...args]), { timeout }).stdout;
512
609
  }
513
610
  getElements(opts = {}) {
611
+ // Ask whether the display is even on BEFORE dumping. Measured at ~85ms against a 278ms
612
+ // read, and it pays for itself twice over when the answer is "no": the dump that would
613
+ // have returned the wrong screen is skipped entirely rather than done and discarded.
614
+ //
615
+ // It cannot be deferred to "only if the hierarchy looks wrong", which is what this
616
+ // originally did. A dozing device does not reliably show SystemUI: a Motorola on API 29
617
+ // serves its VENDOR always-on display (`@clock`, `@date`, `@battery_progress`, package
618
+ // `com.motorola.*`), which no package heuristic can tell from an app. The display being
619
+ // off is the only signal that generalises — if it is off, the app is not on screen,
620
+ // whoever drew what is there.
621
+ if (this.readWakefulness() === false) {
622
+ (0, output_1.err)('note: the display is off — waking the device before reading the screen');
623
+ this.wakeAndUnlock();
624
+ }
625
+ const els = this.captureElements(opts);
626
+ // Second net, for a device that is awake but still behind the keyguard.
627
+ if (!this.keyguardReason(els))
628
+ return els;
629
+ (0, output_1.err)('note: the app is not on screen — trying to dismiss the keyguard');
630
+ this.wakeAndUnlock();
631
+ const after = this.captureElements(opts);
632
+ const reason = this.keyguardReason(after);
633
+ if (!reason)
634
+ return after;
635
+ const lock = this.readLockKind();
636
+ const what = reason === 'locked'
637
+ ? `The device is on the lock screen${lock === 'unknown' ? '' : ` (${lock})`}`
638
+ : "The device's display will not stay on";
639
+ throw new errors_1.CliError(`${what}, so this read would return the keyguard rather than the app — which would look ` +
640
+ 'like a successful read of the wrong screen.\n' +
641
+ (reason === 'locked'
642
+ ? 'Remove the screen lock on your test device (Settings > Security), then `verikun device ' +
643
+ 'prep` to stop the display sleeping in the first place.\n' +
644
+ 'verikun never asks for or stores a device PIN, so it cannot unlock this for you.'
645
+ : 'Keep the display awake with `verikun device prep`, or wake it with `verikun key wakeup`.'), 3);
646
+ }
647
+ captureElements(opts) {
514
648
  const xml = this.dumpXml();
515
649
  // Read off the dump we already have: free, and it refreshes every capture, so a
516
650
  // device rotated mid-run is handled without re-asking for the screen size.
517
651
  this.lastRotation = (0, android_parse_1.parseRotation)(xml);
518
652
  return (0, android_parse_1.parseHierarchy)(xml, { ...opts, screen: this.screenOrNull() ?? undefined });
519
653
  }
654
+ /**
655
+ * Why is the system UI what we just read, rather than the app? `null` means it isn't — we
656
+ * are looking at the app and the elements are good.
657
+ *
658
+ * MEASURED, and the reason this check exists at all: a sleeping display does not reliably
659
+ * fail the read. The companion serves a hierarchy quite happily while the device is
660
+ * `Dozing`, so the dump SUCCEEDS and hands back a perfectly well-formed tree of
661
+ * `com.android.systemui` — a false green, which is worse than the hang it was assumed to be.
662
+ *
663
+ * TWO reasons, not one. `deviceLocked` alone is not enough: it is only true behind a SECURE
664
+ * credential, so a phone with no lock (or a swipe lock) reports `deviceLocked=0` while
665
+ * dozing and still shows the ambient keyguard rather than the app. Measured on a Pixel 3a
666
+ * once its PIN was removed — exit 0 with 36 SystemUI nodes, the same false green again.
667
+ *
668
+ * Staged so the happy path costs nothing: `looksLikeSystemUi` reads only ids we already
669
+ * have, and the `dumpsys` round-trips run ONLY when that says the whole screen belongs to
670
+ * the system. That ordering is also what stops this hijacking a notification shade the
671
+ * caller deliberately opened — the shade looks identical, but the device is neither locked
672
+ * nor asleep, so both reasons come back false and the elements are returned untouched.
673
+ */
674
+ keyguardReason(els) {
675
+ if (!looksLikeSystemUi(els))
676
+ return null;
677
+ if (this.deviceLocked() === true)
678
+ return 'locked';
679
+ if (this.readWakefulness() === false)
680
+ return 'display-off';
681
+ return null;
682
+ }
683
+ /** `dumpsys trust` reports `deviceLocked=1` while the keyguard is up. `null` = could not tell,
684
+ * which never triggers a refusal — an unreadable probe must not block a legitimate read. */
685
+ deviceLocked() {
686
+ try {
687
+ const m = /deviceLocked=(\d)/.exec(this.shell(['dumpsys', 'trust']));
688
+ return m ? m[1] === '1' : null;
689
+ }
690
+ catch {
691
+ return null;
692
+ }
693
+ }
520
694
  viewport() {
521
695
  const screen = this.screenOrNull();
522
696
  return screen ? (0, viewport_1.viewportFor)(screen, this.lastRotation) : null;
@@ -602,16 +776,21 @@ class AdbDriver {
602
776
  throw new errors_1.NoWindowError('No window to read: the app has not drawn yet (force-stopped, or mid-launch). ' +
603
777
  'Retry, or use a command that waits (`vk wait`, or any selector lookup).');
604
778
  }
605
- // A companion holds the device's ONE UiAutomation connection and SIGKILLs anything
606
- // else that wants it including this dump. It outlives the process that started it,
607
- // so a later command that never opted in would fail for as long as it lives. Ask it
608
- // to let go and try again: verikun's own helper must not be why verikun cannot read
609
- // the screen. No companion running is the normal case and costs one refused connect.
610
- if (attempt === 0)
779
+ if (attempt === 0) {
780
+ // A sleeping display is the other documented cause of a failed read, so it is checked
781
+ // here, lazily, where it costs nothing until something has already gone wrong.
782
+ this.wakeIfAsleep();
783
+ // A companion holds the device's ONE UiAutomation connection and SIGKILLs anything
784
+ // else that wants it — including this dump. It outlives the process that started it,
785
+ // so a later command that never opted in would fail for as long as it lives. Ask it
786
+ // to let go and try again: verikun's own helper must not be why verikun cannot read
787
+ // the screen. No companion running is the normal case and costs one refused connect.
611
788
  (0, manager_1.releaseCompanionOn)(this.resolvedSerial());
789
+ }
612
790
  }
613
791
  throw new errors_1.CliError(`Failed to capture UI hierarchy after 3 attempts. ${lastErr}\n` +
614
- 'Tip: disable animations (`verikun doctor --fix`) and ensure the screen is idle.', 3);
792
+ 'Tip: prepare the device once with `verikun device prep` (disables animations, keeps the ' +
793
+ 'display awake) and ensure the screen is idle.', 3);
615
794
  }
616
795
  screenshot() {
617
796
  const r = (0, exec_1.runBinary)(ADB, this.withSerial(['exec-out', 'screencap', '-p']));
@@ -864,6 +1043,14 @@ class AdbDriver {
864
1043
  }
865
1044
  getDeviceSetting(key) {
866
1045
  switch (key) {
1046
+ case 'animations': {
1047
+ // Three separate scales, one knob: any of them still animating is enough to make a
1048
+ // dump flaky, so `off` means all three are zero and anything else reads as `on`.
1049
+ const vals = ANIMATION_SCALES.map((k) => this.readSetting('global', k));
1050
+ if (vals.some((v) => v === null))
1051
+ return null;
1052
+ return vals.every((v) => Number(v) === 0) ? 'off' : 'on';
1053
+ }
867
1054
  case 'airplane':
868
1055
  return this.readSetting('global', 'airplane_mode_on') === '1' ? 'on' : 'off';
869
1056
  case 'dark': {
@@ -896,10 +1083,44 @@ class AdbDriver {
896
1083
  const v = this.readSetting('global', 'stay_on_while_plugged_in');
897
1084
  return v === null ? 'off' : v !== '0' ? 'on' : 'off';
898
1085
  }
1086
+ case 'screen-timeout':
1087
+ // Unset is not a meaningful default here (unlike font_scale, whose absence means
1088
+ // 1.0) — the device always has one. Report null so a snapshot declines rather than
1089
+ // restoring to a number we invented.
1090
+ return this.readSetting('system', 'screen_off_timeout');
1091
+ case 'dnd': {
1092
+ // zen_mode: 0 = off, 1 = priority only, 2 = total silence, 3 = alarms only. Our
1093
+ // on|off domain only claims "is anything suppressing notifications", so any
1094
+ // non-zero mode is `on` — and `set` below picks one specific mode to turn it on.
1095
+ const v = this.readSetting('global', 'zen_mode');
1096
+ return v === null ? 'off' : v !== '0' ? 'on' : 'off';
1097
+ }
1098
+ case 'doze': {
1099
+ // `dumpsys deviceidle enabled` prints a bare 1/0. Note this is whether the idle
1100
+ // MANAGER is enabled, not the momentary idle state (`get deep`, which reports
1101
+ // ACTIVE/IDLE and swings on its own) — only the former is a setting we can hold.
1102
+ const v = this.shell(['dumpsys', 'deviceidle', 'enabled']).trim();
1103
+ if (v === '1')
1104
+ return 'on';
1105
+ if (v === '0')
1106
+ return 'off';
1107
+ return null;
1108
+ }
899
1109
  }
900
1110
  }
901
1111
  setDeviceSetting(key, value) {
902
1112
  switch (key) {
1113
+ case 'animations':
1114
+ return this.applyAndVerify(`animations=${value}`, () => {
1115
+ // One shell per scale, but only the LAST result is reported — every write here
1116
+ // is the same command against the same namespace, so a failure looks identical
1117
+ // whichever of the three refused, and the readback below is the real contract.
1118
+ let last;
1119
+ for (const k of ANIMATION_SCALES) {
1120
+ last = this.shellFull(['settings', 'put', 'global', k, value === 'on' ? '1' : '0']);
1121
+ }
1122
+ return last;
1123
+ }, () => this.getDeviceSetting('animations'), (v) => v === value, 'Writing global settings requires an unrestricted adb shell.');
903
1124
  case 'airplane':
904
1125
  return this.setAirplane(value === 'on');
905
1126
  case 'dark':
@@ -910,6 +1131,16 @@ class AdbDriver {
910
1131
  return this.setRotation(value);
911
1132
  case 'stay-awake':
912
1133
  return this.applyAndVerify(`stay-awake=${value}`, () => this.shellFull(['svc', 'power', 'stayon', value === 'on' ? 'true' : 'false']), () => this.getDeviceSetting('stay-awake'), (v) => v === value, 'Some devices restrict `svc power` while a battery-saver profile is active.');
1134
+ case 'screen-timeout':
1135
+ return this.applyAndVerify(`screen-timeout=${value}`, () => this.shellFull(['settings', 'put', 'system', 'screen_off_timeout', value]), () => this.getDeviceSetting('screen-timeout'), (v) => v === value, 'Writing system settings requires an unrestricted adb shell.');
1136
+ case 'dnd':
1137
+ // `priority` rather than `on`/`none`: total silence also suppresses ALARMS, which
1138
+ // would be a surprising thing for a testing tool to leave on a borrowed phone.
1139
+ // Priority-only is enough to stop a heads-up notification stealing a tap.
1140
+ return this.applyAndVerify(`dnd=${value}`, () => this.shellFull(['cmd', 'notification', 'set_dnd', value === 'on' ? 'priority' : 'off']), () => this.getDeviceSetting('dnd'), (v) => v === value, 'Setting Do Not Disturb needs `cmd notification` (API 28+); some OEM skins gate it behind ' +
1141
+ 'notification-policy access.');
1142
+ case 'doze':
1143
+ return this.applyAndVerify(`doze=${value}`, () => this.shellFull(['dumpsys', 'deviceidle', value === 'on' ? 'enable' : 'disable']), () => this.getDeviceSetting('doze'), (v) => v === value, '`dumpsys deviceidle` is refused on some locked-down builds.');
913
1144
  }
914
1145
  }
915
1146
  /**
@@ -961,5 +1192,66 @@ class AdbDriver {
961
1192
  const target = String((0, settings_1.rotationToUserRotation)(value));
962
1193
  this.applyAndVerify(`rotation=${value}`, () => this.shellFull(['settings', 'put', 'system', 'user_rotation', target]), () => this.readSetting('system', 'user_rotation'), (v) => v === target, 'Writing system settings requires an unrestricted adb shell.');
963
1194
  }
1195
+ // --- screen + lock state --------------------------------------------------
1196
+ /** Best-effort, never throws: this sits on the READ path, and a probe that could fail
1197
+ * a dump would be worse than the hang it exists to prevent. */
1198
+ screenState() {
1199
+ return { awake: this.readWakefulness(), lock: this.readLockKind() };
1200
+ }
1201
+ readWakefulness() {
1202
+ try {
1203
+ const m = /mWakefulness=(\w+)/.exec(this.shell(['dumpsys', 'power']));
1204
+ return m ? m[1].toLowerCase() === 'awake' : null;
1205
+ }
1206
+ catch {
1207
+ return null;
1208
+ }
1209
+ }
1210
+ readLockKind() {
1211
+ try {
1212
+ return parseLockKind(this.shell(['dumpsys', 'lock_settings']));
1213
+ }
1214
+ catch {
1215
+ return 'unknown';
1216
+ }
1217
+ }
1218
+ /** Wake the display and clear a non-secure keyguard, then let the screen settle. Never
1219
+ * throws — deciding whether the result is good enough is `getElements`' job. */
1220
+ wakeAndUnlock() {
1221
+ this.pressKey('wakeup');
1222
+ (0, exec_1.sleepSync)(WAKE_SETTLE_MS);
1223
+ this.dismissKeyguard();
1224
+ (0, exec_1.sleepSync)(WAKE_SETTLE_MS);
1225
+ }
1226
+ /** Only ever clears a SWIPE lock. On a secure keyguard `wm dismiss-keyguard` raises the
1227
+ * credential prompt instead, which is why callers must check `lock` first. */
1228
+ dismissKeyguard() {
1229
+ try {
1230
+ this.shell(['wm', 'dismiss-keyguard']);
1231
+ return true;
1232
+ }
1233
+ catch {
1234
+ return false;
1235
+ }
1236
+ }
1237
+ /**
1238
+ * Wake a sleeping display before retrying a dump that already failed.
1239
+ *
1240
+ * Best-effort and never throws: refusing is `getElements`' job, which owns the ONE decision
1241
+ * about whether we are looking at the app or the keyguard. This only handles the narrower
1242
+ * case where the stock dump genuinely could not read a sleeping screen at all.
1243
+ *
1244
+ * Called lazily, after the first attempt has failed, so the happy path pays nothing — an
1245
+ * extra `dumpsys power` on every read would tax every command in the CLI to help the rare one.
1246
+ */
1247
+ wakeIfAsleep() {
1248
+ // `null` means the probe could not tell. Do nothing — the dump may well be failing for an
1249
+ // unrelated reason, and pressing wakeup at random is not a fix.
1250
+ if (this.screenState().awake !== false)
1251
+ return;
1252
+ (0, output_1.err)('note: the display was asleep — waking it before retrying the read');
1253
+ this.pressKey('wakeup');
1254
+ (0, exec_1.sleepSync)(WAKE_SETTLE_MS);
1255
+ }
964
1256
  }
965
1257
  exports.AdbDriver = AdbDriver;
@@ -635,9 +635,13 @@ class IdbDriver {
635
635
  const scale = (0, settings_1.contentSizeToFontScale)(this.simctlUi('content_size'));
636
636
  return scale === null ? null : (0, settings_1.canonicalFontScale)(scale);
637
637
  }
638
+ case 'animations':
638
639
  case 'airplane':
639
640
  case 'rotation':
640
641
  case 'stay-awake':
642
+ case 'screen-timeout':
643
+ case 'dnd':
644
+ case 'doze':
641
645
  return null;
642
646
  }
643
647
  }
@@ -674,6 +678,22 @@ class IdbDriver {
674
678
  case 'rotation':
675
679
  return this.unsupportedSetting(key, 'Neither `simctl ui` (appearance/content_size/increase_contrast only) nor `idb ui` ' +
676
680
  'exposes orientation. Rotate the Simulator window by hand (Cmd+Left / Cmd+Right).');
681
+ case 'screen-timeout':
682
+ // Same honest no-op as stay-awake, and for the same reason.
683
+ this.assertSimulator(key);
684
+ (0, output_1.err)('note: screen-timeout is a no-op on iOS — simulators do not sleep');
685
+ return;
686
+ case 'doze':
687
+ this.assertSimulator(key);
688
+ (0, output_1.err)('note: doze is a no-op on iOS — there is no Doze equivalent to turn off');
689
+ return;
690
+ case 'dnd':
691
+ return this.unsupportedSetting(key, '`simctl ui` exposes only appearance/content_size/increase_contrast, and neither it nor ' +
692
+ '`idb` can reach Focus. Turn it on inside the simulator: Settings > Focus > Do Not Disturb.');
693
+ case 'animations':
694
+ return this.unsupportedSetting(key, 'Neither `simctl` nor `idb` can disable UIKit animation. The Simulator menu offers only ' +
695
+ 'Debug > Slow Animations, which is the opposite of what a test wants — rely on selector ' +
696
+ 'auto-wait, which polls until the screen settles.');
677
697
  }
678
698
  }
679
699
  }
package/dist/run.js CHANGED
@@ -40,8 +40,10 @@ function isRecordable(command, positionals = []) {
40
40
  // the environment the app runs in and belong in the report, while `get`/`caps` are
41
41
  // pure inspection — recording those would auto-start a test run just for asking what
42
42
  // the device supports, exactly the noise `ui`/`find` are excluded to avoid.
43
+ // `prep` joins set/reset: it changes the device the app runs on, and a report showing a
44
+ // flaky read without "we turned animations off mid-suite" would be missing the cause.
43
45
  if (command === 'device')
44
- return ['set', 'reset'].includes((positionals[0] ?? '').toLowerCase());
46
+ return ['set', 'reset', 'prep'].includes((positionals[0] ?? '').toLowerCase());
45
47
  return RECORDABLE.has(command);
46
48
  }
47
49
  const HIERARCHY_CAP = 24000; // chars of failure hierarchy kept inline in run.json
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.23.0';
6
+ exports.VERSION = '0.24.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "verikun",
3
- "version": "0.23.0",
3
+ "version": "0.24.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",