verikun 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -95,6 +95,16 @@ vk screenshot # -> ./.verikun/screen.png
95
95
  | `clear <app>` | Wipe the app's locally stored data — login/session, preferences, caches — resetting it to a just-installed state (Android `pm clear`, which also force-stops the app). iOS unsupported: there is no per-app data reset. |
96
96
  | `install <app.apk\|.ipa> [--server url]` | Install a build on the device (`adb install -r` / `idb install`). With `--server`, the file is uploaded to a remote [`vk server`](#remote-devices--vk-server) started with `--allow-install` (single-file `.apk`/`.ipa`, sha256-verified). |
97
97
 
98
+ ### Device state
99
+ | Command | Description |
100
+ |---|---|
101
+ | `device set <key>=<value> …` | Change the *device* the app runs on, snapshotting each original first. Keys: `airplane`, `dark`, `font-scale`, `rotation`, `stay-awake`. Every change is **verified by reading it back** — `svc`/`cmd`/`settings put` are fire-and-forget and silently no-op on some OEM skins, so trusting the exit code would report success for a change that never happened. Refuses `airplane=on` over a wireless adb link (it would cut the connection carrying the next command); `--allow-wireless` overrides. |
102
+ | `device get [key] [--json]` | Current values; `n/a` where the platform can't answer. |
103
+ | `device reset [key …]` | Restore what this run changed. `batch`, `ai` and `suite` also do this automatically when the flow ends **or fails**, so a dead test can't leave your phone offline or rotated. |
104
+ | `device caps [--json]` | What the active platform supports, and the manual equivalent where it doesn't. |
105
+
106
+ See [Device state](#device-state-1) for the per-platform matrix.
107
+
98
108
  ### Batch
99
109
  | Command | Description |
100
110
  |---|---|
@@ -144,11 +154,17 @@ skips the archive dump on green runs (failures still capture).
144
154
  - **`report.html`** — a self-contained report: every step, the identifiers used,
145
155
  any screenshots taken, the screenshot + hierarchy of any failed page, a link to
146
156
  the full device log, an app-scoped log accordion when the run launched an app,
147
- and any per-step logs from `vk log`.
157
+ and any per-step logs from `vk log`. A run that did not pass says so in a banner
158
+ at the top.
148
159
  - **`artifacts/logcat.txt`** — full device log for the run window (default).
149
160
  - **`artifacts/logcat-app.txt`** — app-scoped log (when a package/bundle was launched).
150
161
  - **`run.json`** — the raw recording.
151
162
 
163
+ A `vk ai` run can also fail where no single command did — a `repeat` that never
164
+ sees its target, a cost/timeout abort. That verdict is recorded too (as
165
+ `run.json`'s `failure`, plus a failed step carrying the reason and a screenshot),
166
+ so a failed test is never reported as `failures="0"` in the JUnit that CI reads.
167
+
152
168
  `vk run archive` exits non-zero when the run contained failures, so the same
153
169
  command both produces the report and gates CI.
154
170
 
@@ -602,6 +618,58 @@ at runtime, so driving a flow to a report should capture liberally around transi
602
618
  `vk ai` does this automatically (see [AI](#ai--natural-language-tests)); when driving by
603
619
  hand, `vk screenshot` around each screen change and leave the PNG in the report.
604
620
 
621
+ ## Device state
622
+
623
+ Some behaviour only appears when the *device* changes underneath the app: the offline
624
+ banner, the retry path, dark theme, a layout that breaks at accessibility text sizes,
625
+ landscape. `vk device set` changes those, verifies each one landed, and — this is the part
626
+ that makes it safe to point at your own phone — **puts them back**.
627
+
628
+ ```sh
629
+ vk device set airplane=on # go offline
630
+ vk tap @retry
631
+ vk assert text:"No connection"
632
+ vk device reset # back online
633
+
634
+ vk device set dark=on font-scale=1.3 rotation=landscape # several at once
635
+ vk device get --json
636
+ vk device caps # what this platform supports
637
+ ```
638
+
639
+ | key | values | Android | iOS simulator | iOS device |
640
+ |---|---|---|---|---|
641
+ | `airplane` | `on\|off` | ✅ | ❌ no radio to switch off | ❌ |
642
+ | `dark` | `on\|off` | ✅ | ✅ | ❌ |
643
+ | `font-scale` | `0.5`–`3.0`, or `default` | ✅ | ✅ mapped to the nearest Dynamic Type category | ❌ |
644
+ | `rotation` | `portrait\|landscape\|portrait-reverse\|landscape-reverse\|auto` | ✅ | ❌ neither simctl nor idb rotates | ❌ |
645
+ | `stay-awake` | `on\|off` | ✅ | ⊘ no-op — simulators don't sleep | ❌ |
646
+
647
+ An unsupported key exits **3 before any device I/O**, naming the manual equivalent, so a
648
+ test asking for something the platform can't do fails on the first step rather than
649
+ half-way through a half-modified device. `vk device caps --json` is the same matrix for
650
+ whatever platform you're pointed at.
651
+
652
+ **Restore is the point.** `device set` records what each setting held *before* it changed
653
+ it, in the run file rather than in memory — so `vk device reset` works from a later process
654
+ too. `batch`, `ai` and `suite` restore automatically in a `finally`, which is what stops a
655
+ test that dies between `airplane=on` and `airplane=off` from leaving your phone offline.
656
+ A bare `vk device set` from your shell stays applied until you reset it, deliberately.
657
+
658
+ Three things worth knowing:
659
+
660
+ - **`airplane=on` is verified by effect, not by the flag.** Android remembers a user who
661
+ re-enabled wifi during a previous flight, so `airplane-mode enable` can leave wifi *up*.
662
+ `vk` reads the device's own `airplane_mode_toggleable_radios` — the radios that can
663
+ survive the flag — confirms each actually went down, and forces any survivor. Reporting
664
+ "offline" while the app is still online would make an offline test pass for the wrong
665
+ reason. (Cellular is not in that list, and `mobile_data` is a stored preference rather
666
+ than live radio state, so it is deliberately not probed.)
667
+ - **`airplane=off` re-enables the radio, not the internet.** Follow it with
668
+ `vk assert <selector> --wait 10s` rather than tapping straight away.
669
+ - **Wireless adb is refused for `airplane=on`.** It would cut the very link carrying the
670
+ next command, and nothing could turn it back on remotely. Exit 2; `--allow-wireless` if
671
+ you mean it.
672
+
605
673
  ## How it works
606
674
 
607
675
  ```
@@ -652,6 +720,10 @@ your setup with `vk doctor --ios`. `vk --ios tap`, `vk --ios ui`, etc. then work
652
720
  - `log` capture is simulator-only (via `log show`); for a physical device use
653
721
  Console.app or `idb log` directly.
654
722
  - `--tree` renders flat — idb's accessibility list has no nesting depth.
723
+ - `device set` is partial: `dark` and `font-scale` work on a **simulator** (`simctl ui`),
724
+ `stay-awake` is a no-op (simulators don't sleep), and `airplane`/`rotation` are
725
+ unsupported — neither `simctl ui` nor `idb ui` exposes a radio or an orientation.
726
+ A physical device supports none of them. Run `vk device caps --ios` for the live matrix.
655
727
 
656
728
  ## Using it from an AI agent
657
729
 
@@ -28,6 +28,20 @@ Each step is one of three node types:
28
28
  assert <selector> [--text <s>] [--gone] — assert presence/text/absence (FAILS the test if false)
29
29
  wait <selector> [--gone] [--timeout <ms>] — block until present/absent
30
30
  screenshot — capture the screen into the report
31
+ device <set|get|reset|caps> — change the DEVICE (not the app) to test how the app
32
+ copes. The command name is EXACTLY "device"; the
33
+ subcommand is the FIRST POSITIONAL, never part of the
34
+ command name. Each assignment is one positional:
35
+ {"command":"device","positionals":["set","dark=on","font-scale=1.3"]}
36
+ {"command":"device","positionals":["reset"]}
37
+ Keys: airplane=on|off (go offline — for retry/error
38
+ handling), dark=on|off, font-scale=<0.5-3.0>,
39
+ rotation=portrait|landscape|portrait-reverse|
40
+ landscape-reverse|auto, stay-awake=on|off.
41
+ ALWAYS finish the scenario with a "reset" step. Do NOT
42
+ tap immediately after airplane=off — the radio is back
43
+ but the network is not; follow it with a wait/assert.
44
+ Android only for airplane + rotation.
31
45
 
32
46
  2. IF-PRESENT — { "type":"if-present", "selector":<sel>, "body":[<command leaves>] }
33
47
  Run body ONLY if the selector is on screen now. Use for OPTIONAL interstitials:
package/dist/agent/ir.js CHANGED
@@ -23,6 +23,8 @@ exports.bodiesOf = bodiesOf;
23
23
  exports.validateNode = validateNode;
24
24
  exports.parsePlan = parsePlan;
25
25
  exports.leafToFlags = leafToFlags;
26
+ const errors_1 = require("../errors");
27
+ const settings_1 = require("../device/settings");
26
28
  /** Max control-node nesting. 1 = a control node at top level whose body is leaves
27
29
  * (the v1 rule). 2 = a control node inside that body, whose own body is leaves —
28
30
  * i.e. `repeat { when { … } }`, the loop-that-branches shape a real dynamic flow
@@ -50,6 +52,7 @@ exports.KNOWN_COMMANDS = new Set([
50
52
  'screenshot', 'shot',
51
53
  'wait', 'assert',
52
54
  'launch', 'open', 'stop', 'clear',
55
+ 'device',
53
56
  // Inspection/diagnostic commands (`current`, `ui`, `find`, `log`, `logs`) are
54
57
  // deliberately NOT here: they are not test actions (the grammar never offers them), so
55
58
  // a plan or repair must never emit them — and `log`'s flags reach a device shell
@@ -253,6 +256,47 @@ const READ_FIELDS = new Set(['text', 'desc', 'id', 'idShort']);
253
256
  /** ctx keys are interpolated into selectors as {{ctx.NAME}}; keep them boring so a
254
257
  * name can never smuggle regex/template metacharacters into a selector. */
255
258
  const CTX_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
259
+ /**
260
+ * Fold `{command: "device set"}` back into `{command: "device", positionals: ["set", …]}`.
261
+ *
262
+ * `device` is the only verb in the grammar with a subcommand, and every other one is a
263
+ * single word — so the model reliably writes the pair as one command name. That is an
264
+ * unambiguous spelling of a REAL command, not a hallucination, so normalizing it is the
265
+ * same kind of leniency as the `click`/`tap` aliases rather than a hole in the
266
+ * allowlist: an unknown subcommand still falls through and is rejected below.
267
+ */
268
+ function normalizeLeaf(command, positionals) {
269
+ const m = /^device[\s:_-]+(set|get|reset|caps)$/i.exec(command.trim());
270
+ return m ? { command: 'device', positionals: [m[1].toLowerCase(), ...positionals] } : { command, positionals };
271
+ }
272
+ /**
273
+ * Check a `device` leaf's subcommand and assignments at PLAN-VALIDATION time.
274
+ *
275
+ * Every other command is validated only by name, because a bad selector is a runtime
276
+ * fact the engine can heal. A device setting is different: whether it exists, and
277
+ * whether the value is legal, are known statically — so catching it here means a
278
+ * suite that asks for an unknown key fails before the first tap instead of twenty
279
+ * steps in, on a device it has already half-modified.
280
+ */
281
+ function validateDeviceStep(positionals, where) {
282
+ const sub = (positionals[0] ?? '').toLowerCase();
283
+ if (!['set', 'get', 'reset', 'caps'].includes(sub)) {
284
+ throw new InvalidPlanError(`${where}: device subcommand must be set|get|reset|caps, got ${JSON.stringify(sub)}`);
285
+ }
286
+ const rest = positionals.slice(1);
287
+ try {
288
+ if (sub === 'set')
289
+ (0, settings_1.parseDeviceAssignments)(rest);
290
+ else
291
+ for (const k of rest) {
292
+ if (!(0, settings_1.isSettingKey)(k.trim().toLowerCase()))
293
+ throw new errors_1.CliError(`unknown device setting '${k}'`, 2);
294
+ }
295
+ }
296
+ catch (e) {
297
+ throw new InvalidPlanError(`${where}: ${e instanceof Error ? e.message.split('\n')[0] : String(e)}`);
298
+ }
299
+ }
256
300
  /** Validate a single node (used for both compile output and a spliced repair).
257
301
  *
258
302
  * `depth` is how many control nodes enclose this one. It is the ONLY thing bounding
@@ -294,15 +338,20 @@ function validateNode(node, where, depth = 0) {
294
338
  const optional = (v, pick) => v === undefined || v === null ? undefined : pick(v);
295
339
  switch (n.type) {
296
340
  case 'command': {
297
- if (typeof n.command !== 'string' || !exports.KNOWN_COMMANDS.has(n.command)) {
341
+ if (typeof n.command !== 'string')
298
342
  throw new InvalidPlanError(`${where}: unknown command ${JSON.stringify(n.command)}`);
299
- }
300
343
  if (!Array.isArray(n.positionals) || !n.positionals.every((p) => typeof p === 'string')) {
301
344
  throw new InvalidPlanError(`${where}: positionals must be a string[]`);
302
345
  }
346
+ const { command, positionals } = normalizeLeaf(n.command, n.positionals);
347
+ if (!exports.KNOWN_COMMANDS.has(command)) {
348
+ throw new InvalidPlanError(`${where}: unknown command ${JSON.stringify(n.command)}`);
349
+ }
303
350
  if (!isFlagSpecArray(n.flags))
304
351
  throw new InvalidPlanError(`${where}: flags must be {name,value}[]`);
305
- return { type: 'command', command: n.command, positionals: n.positionals, flags: n.flags };
352
+ if (command === 'device')
353
+ validateDeviceStep(positionals, where);
354
+ return { type: 'command', command, positionals, flags: n.flags };
306
355
  }
307
356
  case 'read': {
308
357
  const selector = selectorOf();
package/dist/args.js CHANGED
@@ -58,6 +58,7 @@ const BOOLEAN = new Set([
58
58
  'not-selected',
59
59
  'not-checked',
60
60
  'not-focused',
61
+ 'allow-wireless',
61
62
  ]);
62
63
  function parseArgs(argv) {
63
64
  const positionals = [];