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.
- package/.claude/skills/verikun/SKILL.md +31 -5
- package/CHANGELOG.md +28 -0
- package/README.md +3 -2
- package/dist/args.js +3 -0
- package/dist/cli.js +261 -20
- package/dist/device/claims.js +12 -2
- package/dist/device/prep.js +156 -0
- package/dist/device/settings.js +82 -0
- package/dist/drivers/adb.js +299 -7
- package/dist/drivers/ios.js +20 -0
- package/dist/run.js +3 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
- **
|
|
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
|
|
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
|
|
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
|
|
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
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
const
|
|
690
|
-
|
|
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 ...] |
|
|
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)
|
|
1697
|
-
*
|
|
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
|
package/dist/device/claims.js
CHANGED
|
@@ -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
|
|
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)}
|
|
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
|
+
}
|
package/dist/device/settings.js
CHANGED
|
@@ -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) {
|
package/dist/drivers/adb.js
CHANGED
|
@@ -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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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:
|
|
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;
|
package/dist/drivers/ios.js
CHANGED
|
@@ -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.
|
|
6
|
+
exports.VERSION = '0.24.0';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "verikun",
|
|
3
|
-
"version": "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",
|