verikun 0.18.1 → 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
  |---|---|
@@ -608,6 +618,58 @@ at runtime, so driving a flow to a report should capture liberally around transi
608
618
  `vk ai` does this automatically (see [AI](#ai--natural-language-tests)); when driving by
609
619
  hand, `vk screenshot` around each screen change and leave the PNG in the report.
610
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
+
611
673
  ## How it works
612
674
 
613
675
  ```
@@ -658,6 +720,10 @@ your setup with `vk doctor --ios`. `vk --ios tap`, `vk --ios ui`, etc. then work
658
720
  - `log` capture is simulator-only (via `log show`); for a physical device use
659
721
  Console.app or `idb log` directly.
660
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.
661
727
 
662
728
  ## Using it from an AI agent
663
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 = [];
package/dist/cli.js CHANGED
@@ -59,6 +59,8 @@ const args_1 = require("./args");
59
59
  const errors_1 = require("./errors");
60
60
  const exec_1 = require("./exec");
61
61
  const drivers_1 = require("./drivers");
62
+ const adb_1 = require("./drivers/adb");
63
+ const settings_1 = require("./device/settings");
62
64
  const selector_1 = require("./ui/selector");
63
65
  const state_support_1 = require("./ui/state-support");
64
66
  const format_1 = require("./ui/format");
@@ -924,6 +926,194 @@ function cmdClear(ctx) {
924
926
  (0, output_1.out)(`cleared ${appId}`);
925
927
  return 0;
926
928
  }
929
+ // ---------------------------------------------------------------------------
930
+ // device — control the device the app runs on (see device/settings.ts)
931
+ // ---------------------------------------------------------------------------
932
+ //
933
+ // The capability table is the single source of truth: it validates values, decides
934
+ // what each platform supports, and prints itself via `device caps`. Two properties
935
+ // are load-bearing:
936
+ //
937
+ // - FAIL EARLY. Every assignment is parsed and capability-checked before ANY device
938
+ // I/O, so `device set dark=on rotation=landscape` on iOS refuses up front instead
939
+ // of applying dark mode and then dying — a half-applied device is worse than none.
940
+ // - SNAPSHOT FIRST. The pre-change value is persisted before the change is made, so
941
+ // `device reset` can undo it even from a later process.
942
+ const DEVICE_USAGE = 'Usage: verikun device set <key>=<value> [<key>=<value> ...] | device get [key] | device reset [key ...] | device caps\n' +
943
+ `Keys: ${settings_1.SETTING_KEYS.join(', ')}`;
944
+ function cmdDevice(ctx) {
945
+ const sub = (ctx.positionals[0] ?? '').toLowerCase();
946
+ const rest = ctx.positionals.slice(1);
947
+ switch (sub) {
948
+ case 'set':
949
+ return deviceSet(ctx, rest);
950
+ case 'get':
951
+ case 'status':
952
+ return deviceGet(ctx, rest);
953
+ case 'reset':
954
+ return deviceReset(ctx, rest);
955
+ case 'caps':
956
+ return deviceCaps(ctx);
957
+ default:
958
+ throw new errors_1.CliError((sub ? `Unknown 'device' subcommand '${sub}'.\n` : '') + DEVICE_USAGE, 2);
959
+ }
960
+ }
961
+ /** Render a setting value for humans/JSON; a platform that cannot answer says so. */
962
+ const showValue = (v) => v ?? 'n/a';
963
+ function deviceSet(ctx, args) {
964
+ const assignments = (0, settings_1.parseDeviceAssignments)(args);
965
+ // Gate 1: capability, for EVERY key, before touching the device.
966
+ const support = new Map();
967
+ for (const { key } of assignments)
968
+ support.set(key, (0, settings_1.checkSupport)(key, ctx.platform));
969
+ // Gate 2: would this cut the wire we are talking over? Only wireless adb is at
970
+ // risk, and only from the radios. A stderr warning would be useless here — the
971
+ // command that reads it is the one that just lost its transport — so refuse, and
972
+ // require an explicit opt-in to accept losing the link.
973
+ if (ctx.platform === 'android') {
974
+ const transport = (0, adb_1.adbTransport)(ctx.driver.resolvedSerial());
975
+ for (const { key, value } of assignments) {
976
+ if ((0, adb_1.severanceRisk)(transport, key, value) && !(0, args_1.flagBool)(ctx.flags, 'allow-wireless')) {
977
+ throw new errors_1.CliError(`Refusing to set ${key}=${value} over a wireless adb connection (${ctx.driver.resolvedSerial()}): ` +
978
+ 'it would cut the link carrying this session, and nothing could turn it back on remotely.\n' +
979
+ 'Connect over USB, or pass --allow-wireless to accept losing the connection.', 2);
980
+ }
981
+ }
982
+ }
983
+ const applied = {};
984
+ for (const { key, value } of assignments) {
985
+ // A no-op key has nothing to put back, so it is never snapshotted.
986
+ if (support.get(key) !== 'noop')
987
+ snapshotSetting(ctx, key);
988
+ ctx.driver.setDeviceSetting(key, value);
989
+ applied[key] = value;
990
+ }
991
+ const summary = assignments.map((a) => `${a.key}=${a.value}`).join(' ');
992
+ ctx.record?.note({ message: `device set ${summary}` });
993
+ if ((0, args_1.flagBool)(ctx.flags, 'json'))
994
+ (0, output_1.json)({ set: applied });
995
+ else
996
+ (0, output_1.out)(`device set ${summary}`);
997
+ return 0;
998
+ }
999
+ /** Persist what a setting held before we change it. Warns rather than fails when the
1000
+ * value can't be captured — the change still happens, it just can't be auto-undone. */
1001
+ function snapshotSetting(ctx, key) {
1002
+ if (!ctx.record) {
1003
+ // Recording disabled (VERIKUN_NO_RUN=1): there is nowhere to persist the original,
1004
+ // so this change is one-way. Say so rather than implying reset will handle it.
1005
+ (0, output_1.err)(`warning: run recording is off, so '${key}' was not snapshotted — \`device reset\` cannot restore it`);
1006
+ return;
1007
+ }
1008
+ const original = ctx.driver.getDeviceSetting(key);
1009
+ if (original === null) {
1010
+ (0, output_1.err)(`warning: could not read the current '${key}' — \`device reset\` will not restore it`);
1011
+ return;
1012
+ }
1013
+ ctx.record.rememberDeviceOverride(key, original);
1014
+ }
1015
+ function deviceGet(ctx, args) {
1016
+ const keys = args.length ? args.map(requireSettingKey) : settings_1.SETTING_KEYS;
1017
+ const values = {};
1018
+ for (const key of keys) {
1019
+ values[key] = settings_1.SETTINGS[key].support[ctx.platform] === 'unsupported'
1020
+ ? 'n/a'
1021
+ : showValue(ctx.driver.getDeviceSetting(key));
1022
+ }
1023
+ if ((0, args_1.flagBool)(ctx.flags, 'json'))
1024
+ (0, output_1.json)(values);
1025
+ else
1026
+ for (const key of keys)
1027
+ (0, output_1.out)(`${key.padEnd(12)} ${values[key]}`);
1028
+ return 0;
1029
+ }
1030
+ function deviceReset(ctx, args) {
1031
+ // Read from the recorder's in-flight state, not from disk: this step's own commit()
1032
+ // will write that state back, so a disk-level delete here would be undone.
1033
+ const overrides = ctx.record?.deviceOverrides() ?? {};
1034
+ const wanted = args.length ? args.map(requireSettingKey) : Object.keys(overrides);
1035
+ const restored = {};
1036
+ const failed = [];
1037
+ for (const key of wanted) {
1038
+ const original = overrides[key];
1039
+ if (original === undefined)
1040
+ continue; // never changed by this run — leave it alone
1041
+ try {
1042
+ ctx.driver.setDeviceSetting(key, original);
1043
+ restored[key] = original;
1044
+ }
1045
+ catch (e) {
1046
+ // Best-effort by design: reset is usually reached while cleaning up after a
1047
+ // failure, and one stubborn setting must not stop the rest being put back.
1048
+ failed.push(`${key} (${e instanceof Error ? e.message.split('\n')[0] : String(e)})`);
1049
+ }
1050
+ }
1051
+ ctx.record?.forgetDeviceOverrides(Object.keys(restored));
1052
+ const summary = Object.entries(restored).map(([k, v]) => `${k}=${v}`).join(' ');
1053
+ ctx.record?.note({ message: summary ? `device reset ${summary}` : 'device reset (nothing to restore)' });
1054
+ if (failed.length)
1055
+ (0, output_1.err)(`warning: could not restore ${failed.join(', ')}`);
1056
+ if ((0, args_1.flagBool)(ctx.flags, 'json'))
1057
+ (0, output_1.json)({ restored, ...(failed.length ? { failed } : {}) });
1058
+ else
1059
+ (0, output_1.out)(summary ? `device reset ${summary}` : 'device reset: nothing to restore');
1060
+ return 0;
1061
+ }
1062
+ function deviceCaps(ctx) {
1063
+ const rows = settings_1.SETTING_KEYS.map((key) => {
1064
+ const spec = settings_1.SETTINGS[key];
1065
+ const support = spec.support[ctx.platform];
1066
+ return {
1067
+ key,
1068
+ values: spec.values,
1069
+ support,
1070
+ describe: spec.describe,
1071
+ ...(support === 'unsupported' && spec.manual[ctx.platform] ? { manual: spec.manual[ctx.platform] } : {}),
1072
+ ...(spec.note[ctx.platform] ? { note: spec.note[ctx.platform] } : {}),
1073
+ };
1074
+ });
1075
+ if ((0, args_1.flagBool)(ctx.flags, 'json')) {
1076
+ (0, output_1.json)({ platform: ctx.platform, settings: rows });
1077
+ return 0;
1078
+ }
1079
+ (0, output_1.out)(`device settings on ${ctx.platform}:`);
1080
+ for (const r of rows) {
1081
+ (0, output_1.out)(` ${r.key.padEnd(12)} ${r.support.padEnd(12)} ${r.values}`);
1082
+ (0, output_1.out)(` ${' '.repeat(12)} ${r.describe}`);
1083
+ if (r.manual)
1084
+ (0, output_1.out)(` ${' '.repeat(12)} unsupported: ${r.manual.replace(/\n/g, ' ')}`);
1085
+ if (r.note)
1086
+ (0, output_1.out)(` ${' '.repeat(12)} note: ${r.note}`);
1087
+ }
1088
+ return 0;
1089
+ }
1090
+ function requireSettingKey(v) {
1091
+ const k = v.trim().toLowerCase();
1092
+ if (!(0, settings_1.isSettingKey)(k)) {
1093
+ throw new errors_1.CliError(`Unknown device setting '${v}'. Known: ${settings_1.SETTING_KEYS.join(', ')}.`, 2);
1094
+ }
1095
+ return k;
1096
+ }
1097
+ /**
1098
+ * Put back every device setting this run changed. Best-effort and silent about
1099
+ * "nothing to do", because it runs from a `finally` — the flow may well be unwinding
1100
+ * from the very failure that left the device in a modified state.
1101
+ *
1102
+ * Runs through the backend rather than a Driver so the local and remote paths are the
1103
+ * same code. (Remote is a known gap: the overrides live in the *server's* run file, so
1104
+ * a locally-empty snapshot means this correctly skips — see the issue's Out of scope.)
1105
+ */
1106
+ async function restoreDeviceOverrides(backend) {
1107
+ if (!run_1.Recorder.hasDeviceOverrides())
1108
+ return;
1109
+ try {
1110
+ (0, output_1.err)('[verikun] restoring device settings changed by this run…');
1111
+ await backend.exec('device', ['reset'], {});
1112
+ }
1113
+ catch {
1114
+ /* the device may be exactly why we are unwinding — never mask the real error */
1115
+ }
1116
+ }
927
1117
  function cmdCurrent(ctx) {
928
1118
  (0, output_1.out)(ctx.driver.currentApp());
929
1119
  return 0;
@@ -1144,31 +1334,46 @@ async function cmdBatch(positionals, batchFlags) {
1144
1334
  (0, output_1.err)('[verikun] batch: no commands to run');
1145
1335
  return 0;
1146
1336
  }
1147
- for (const { n, text } of commands) {
1148
- let code;
1149
- try {
1150
- const { command, positionals: pos, flags } = (0, args_1.parseArgs)(tokenizeLine(text));
1151
- if (!command)
1152
- continue; // tokens were all flags — nothing to run
1153
- if (command === 'batch') {
1154
- throw new errors_1.CliError(`batch: a batch line may not itself be 'batch' (line ${n})`, 2);
1337
+ // The finally is what makes `device set` safe to use in a batch: a line that fails
1338
+ // (or a ^C) still puts the device back, instead of leaving it offline or rotated.
1339
+ try {
1340
+ for (const { n, text } of commands) {
1341
+ let code;
1342
+ try {
1343
+ const { command, positionals: pos, flags } = (0, args_1.parseArgs)(tokenizeLine(text));
1344
+ if (!command)
1345
+ continue; // tokens were all flags — nothing to run
1346
+ if (command === 'batch') {
1347
+ throw new errors_1.CliError(`batch: a batch line may not itself be 'batch' (line ${n})`, 2);
1348
+ }
1349
+ if (!quiet)
1350
+ (0, output_1.err)(`[verikun] batch ${n}: ${text}`);
1351
+ code = await executeParsed(command, pos, withBatchGlobals(flags, batchFlags));
1352
+ }
1353
+ catch (e) {
1354
+ // A malformed line (bad quoting, nested batch) is itself an error to halt on.
1355
+ code = mapError(e, batchFlags);
1356
+ }
1357
+ if (code !== 0) {
1358
+ (0, output_1.err)(`[verikun] batch stopped at line ${n} (\`${text}\`) — exit ${code}`);
1359
+ return code;
1155
1360
  }
1156
- if (!quiet)
1157
- (0, output_1.err)(`[verikun] batch ${n}: ${text}`);
1158
- code = await executeParsed(command, pos, withBatchGlobals(flags, batchFlags));
1159
- }
1160
- catch (e) {
1161
- // A malformed line (bad quoting, nested batch) is itself an error to halt on.
1162
- code = mapError(e, batchFlags);
1163
1361
  }
1164
- if (code !== 0) {
1165
- (0, output_1.err)(`[verikun] batch stopped at line ${n} (\`${text}\`) — exit ${code}`);
1166
- return code;
1362
+ if (!quiet)
1363
+ (0, output_1.err)(`[verikun] batch: ${commands.length} command(s) ok`);
1364
+ return 0;
1365
+ }
1366
+ finally {
1367
+ if (run_1.Recorder.hasDeviceOverrides()) {
1368
+ (0, output_1.err)('[verikun] restoring device settings changed by this batch…');
1369
+ try {
1370
+ await executeParsed('device', ['reset'], withBatchGlobals({}, batchFlags));
1371
+ }
1372
+ catch {
1373
+ /* the device may be exactly why we are unwinding — never mask the real error */
1374
+ }
1167
1375
  }
1168
1376
  }
1169
- if (!quiet)
1170
- (0, output_1.err)(`[verikun] batch: ${commands.length} command(s) ok`);
1171
- return 0;
1172
1377
  }
1173
1378
  function parseAiOptions(flags) {
1174
1379
  const model = (0, cost_1.resolveModel)((0, args_1.flagStr)(flags, 'model'));
@@ -1620,6 +1825,9 @@ async function cmdAi(positionals, flags) {
1620
1825
  result = await runAiTest(file, opts, backend, platform, device);
1621
1826
  }
1622
1827
  finally {
1828
+ // Undo any device setting the test changed, INCLUDING when it failed part-way —
1829
+ // otherwise an unattended run leaves the phone offline or in dark mode.
1830
+ await restoreDeviceOverrides(backend);
1623
1831
  await backend.close?.(); // frees a remote server's device lock for the next command
1624
1832
  }
1625
1833
  if ((0, args_1.flagBool)(flags, 'json')) {
@@ -1702,6 +1910,7 @@ async function cmdSuiteEntry(positionals, flags) {
1702
1910
  });
1703
1911
  }
1704
1912
  finally {
1913
+ await restoreDeviceOverrides(backend);
1705
1914
  await backend.close?.();
1706
1915
  }
1707
1916
  }
@@ -1751,6 +1960,8 @@ async function executeCommand(command, ctx) {
1751
1960
  return cmdStop(ctx);
1752
1961
  case 'clear':
1753
1962
  return cmdClear(ctx);
1963
+ case 'device':
1964
+ return cmdDevice(ctx);
1754
1965
  case 'current':
1755
1966
  return cmdCurrent(ctx);
1756
1967
  case 'log':
@@ -1793,7 +2004,7 @@ async function executeOutcome(command, positionals, flags, sharedDriver) {
1793
2004
  // Recordable commands open a step (auto-starting an implicit run if needed);
1794
2005
  // the step is finalized with the outcome — and, on failure, screenshot + UI
1795
2006
  // hierarchy of the page are captured — whether the command returns or throws.
1796
- const recordable = (0, run_1.isRecordable)(command);
2007
+ const recordable = (0, run_1.isRecordable)(command, positionals);
1797
2008
  let driver = sharedDriver;
1798
2009
  let recorder = null;
1799
2010
  try {
@@ -1971,6 +2182,22 @@ ACT
1971
2182
  idb install). With --server, uploads the file to a
1972
2183
  remote vk server (which must run --allow-install)
1973
2184
 
2185
+ DEVICE STATE (change the device the app runs on, then put it back)
2186
+ device set <key>=<value> ... Apply settings, snapshotting each original first.
2187
+ Keys: ${settings_1.SETTING_KEYS.join(', ')}
2188
+ e.g. \`device set airplane=on\` to test offline handling,
2189
+ \`device set dark=on font-scale=1.3 rotation=landscape\`.
2190
+ Each change is verified by reading it back — the
2191
+ underlying device commands silently no-op on some skins.
2192
+ device get [key] [--json] Show current values ('n/a' where unsupported)
2193
+ device reset [key ...] Restore what this run changed. batch/ai/suite also
2194
+ do this automatically when the flow ends OR fails,
2195
+ so a dead test can't leave the phone offline.
2196
+ device caps [--json] What this platform supports, and the manual
2197
+ equivalent where it doesn't
2198
+ Refuses \`airplane=on\` over wireless adb (it would cut
2199
+ this very connection); --allow-wireless overrides.
2200
+
1974
2201
  BATCH (script many commands in one process)
1975
2202
  batch [--file path] [--quiet] Run newline-separated commands — from --file,
1976
2203
  else piped stdin — each exactly as its own
@@ -2094,5 +2321,7 @@ EXIT CODES
2094
2321
 
2095
2322
  iOS (--ios): full parity via idb — ui/tap/text/swipe/key + screenshot/launch/stop.
2096
2323
  Needs idb (\`brew install idb-companion\` + \`pip install fb-idb\`); see \`vk doctor --ios\`.
2097
- Caveats: no \`clear\` (no per-app reset), \`current\` is (unknown), device logs are simulator-only.`;
2324
+ Caveats: no \`clear\` (no per-app reset), \`current\` is (unknown), device logs are simulator-only.
2325
+ \`device set\`: dark + font-scale work on a SIMULATOR (font-scale maps to the nearest
2326
+ Dynamic Type category); airplane and rotation are unsupported — run \`vk device caps --ios\`.`;
2098
2327
  }
@@ -0,0 +1,252 @@
1
+ "use strict";
2
+ // The device-settings capability table — what `vk device set` can change, on which
3
+ // platform, and what a value is allowed to be.
4
+ //
5
+ // This module is DATA plus pure functions, and it is deliberately platform-agnostic:
6
+ // like ui/ and image.ts it never touches adb/xcrun. The table says *what* is supported
7
+ // and *why not*; the drivers know *how*. That split is what lets one place drive all
8
+ // four consumers — argument validation, the driver switch, `vk device caps`, and the
9
+ // `vk ai` plan validator — so a new setting is a table row rather than an edit in five
10
+ // files, and a platform gap can never be documented in one place and forgotten in another.
11
+ //
12
+ // Load-bearing rule: a gap is declared, never silently absorbed. Every 'unsupported'
13
+ // entry carries the MANUAL EQUIVALENT the user should reach for instead, and every
14
+ // 'noop' carries the reason it was unnecessary. A test that believes it went offline
15
+ // while the app is still online is the worst outcome a testing tool can produce, so
16
+ // there is no fourth state that means "we tried".
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.SETTING_KEYS = exports.SETTINGS = exports.ROTATION_VALUES = exports.ROTATION_AUTO = void 0;
19
+ exports.canonicalFontScale = canonicalFontScale;
20
+ exports.rotationToUserRotation = rotationToUserRotation;
21
+ exports.userRotationToRotation = userRotationToRotation;
22
+ exports.fontScaleToContentSize = fontScaleToContentSize;
23
+ exports.contentSizeToFontScale = contentSizeToFontScale;
24
+ exports.isSettingKey = isSettingKey;
25
+ exports.parseDeviceAssignments = parseDeviceAssignments;
26
+ exports.checkSupport = checkSupport;
27
+ const errors_1 = require("../errors");
28
+ // --- value parsers ----------------------------------------------------------
29
+ const ON_OFF_ALIASES = {
30
+ on: 'on', off: 'off',
31
+ true: 'on', false: 'off',
32
+ yes: 'on', no: 'off',
33
+ enable: 'on', disable: 'off',
34
+ enabled: 'on', disabled: 'off',
35
+ '1': 'on', '0': 'off',
36
+ };
37
+ function parseOnOff(key, raw) {
38
+ const v = ON_OFF_ALIASES[raw.trim().toLowerCase()];
39
+ if (!v)
40
+ throw new errors_1.CliError(`Invalid value '${raw}' for '${key}': expected on|off.`, 2);
41
+ return v;
42
+ }
43
+ // Bounds, not taste: below ~0.5 the UI is unreadable and above ~3.0 Android's own
44
+ // accessibility slider stops, so a stray `font-scale=100` is far likelier to be a typo
45
+ // than an intent — and it would leave the device unusable enough to be hard to undo
46
+ // through the UI. Refuse it here rather than after it has been written.
47
+ const FONT_SCALE_MIN = 0.5;
48
+ const FONT_SCALE_MAX = 3.0;
49
+ /**
50
+ * Canonical string form of a scale. EVERY path that produces a font-scale value —
51
+ * parse, the 'default' alias, and the driver's readback — must go through this, or a
52
+ * write and its verification would compare unequal strings for the same number
53
+ * ('1.0' vs '1') and report a perfectly good change as refused.
54
+ */
55
+ function canonicalFontScale(n) {
56
+ return String(n);
57
+ }
58
+ const FONT_SCALE_DEFAULT = 1.0;
59
+ function parseFontScale(raw) {
60
+ const t = raw.trim().toLowerCase();
61
+ if (t === 'default')
62
+ return canonicalFontScale(FONT_SCALE_DEFAULT);
63
+ if (!/^\d+(\.\d+)?$/.test(t)) {
64
+ throw new errors_1.CliError(`Invalid value '${raw}' for 'font-scale': expected a number like 1.3, or 'default'.`, 2);
65
+ }
66
+ const n = Number(t);
67
+ if (n < FONT_SCALE_MIN || n > FONT_SCALE_MAX) {
68
+ throw new errors_1.CliError(`Invalid value '${raw}' for 'font-scale': must be between ${FONT_SCALE_MIN} and ${FONT_SCALE_MAX}.`, 2);
69
+ }
70
+ // Canonicalize so '1.30', '1.3' and '1.300' all snapshot/compare identically.
71
+ return canonicalFontScale(n);
72
+ }
73
+ /** Android `user_rotation` values, by the name we expose. */
74
+ const ROTATIONS = {
75
+ portrait: 0,
76
+ landscape: 1,
77
+ 'portrait-reverse': 2,
78
+ 'landscape-reverse': 3,
79
+ };
80
+ /** Auto-rotate (accelerometer_rotation=1), where no fixed orientation applies.
81
+ * It is a real value rather than a separate key so a snapshot can restore a device
82
+ * that was on auto-rotate to auto-rotate, instead of pinning it to whatever
83
+ * orientation it happened to be held in when the run started. */
84
+ exports.ROTATION_AUTO = 'auto';
85
+ exports.ROTATION_VALUES = [...Object.keys(ROTATIONS), exports.ROTATION_AUTO];
86
+ function parseRotation(raw) {
87
+ const t = raw.trim().toLowerCase();
88
+ // A bare integer is rejected on purpose: `rotation=2` is meaningless to someone
89
+ // reading the run report, and the number-to-orientation mapping is Android's, not ours.
90
+ if (t !== exports.ROTATION_AUTO && !(t in ROTATIONS)) {
91
+ throw new errors_1.CliError(`Invalid value '${raw}' for 'rotation': expected ${exports.ROTATION_VALUES.join('|')}.`, 2);
92
+ }
93
+ return t;
94
+ }
95
+ /** The fixed orientation a name pins to. Throws for `auto`, which has no fixed
96
+ * value — callers must branch on it before asking. */
97
+ function rotationToUserRotation(name) {
98
+ const v = ROTATIONS[name.trim().toLowerCase()];
99
+ if (v === undefined) {
100
+ throw new errors_1.CliError(`Invalid rotation '${name}': expected ${Object.keys(ROTATIONS).join('|')}.`, 2);
101
+ }
102
+ return v;
103
+ }
104
+ function userRotationToRotation(value) {
105
+ const hit = Object.entries(ROTATIONS).find(([, v]) => String(v) === value.trim());
106
+ return hit ? hit[0] : null;
107
+ }
108
+ // --- font-scale <-> iOS content size ----------------------------------------
109
+ // iOS takes a named Dynamic Type category where Android takes a float, so the two
110
+ // domains have to be reconciled somewhere. The float is canonical (it is the more
111
+ // precise of the two, and what Android natively stores); iOS maps to the nearest
112
+ // category and the driver echoes which one it applied, so a caller is never guessing.
113
+ //
114
+ // Scales are the real UIKit body point size for each category divided by 17pt (the
115
+ // `large` default), which is what makes 1.0 land exactly on `large` on both platforms.
116
+ const CONTENT_SIZES = [
117
+ { category: 'extra-small', scale: 0.82 },
118
+ { category: 'small', scale: 0.88 },
119
+ { category: 'medium', scale: 0.94 },
120
+ { category: 'large', scale: 1.0 },
121
+ { category: 'extra-large', scale: 1.12 },
122
+ { category: 'extra-extra-large', scale: 1.24 },
123
+ { category: 'extra-extra-extra-large', scale: 1.35 },
124
+ { category: 'accessibility-medium', scale: 1.65 },
125
+ { category: 'accessibility-large', scale: 1.94 },
126
+ { category: 'accessibility-extra-large', scale: 2.35 },
127
+ { category: 'accessibility-extra-extra-large', scale: 2.76 },
128
+ { category: 'accessibility-extra-extra-extra-large', scale: 3.12 },
129
+ ];
130
+ /** Nearest Dynamic Type category for a font scale. Clamps rather than throwing —
131
+ * the value was already range-checked by parse(), so out-of-table just means the
132
+ * extreme end of the scale, not a caller error. */
133
+ function fontScaleToContentSize(scale) {
134
+ let best = CONTENT_SIZES[0];
135
+ for (const c of CONTENT_SIZES) {
136
+ if (Math.abs(c.scale - scale) < Math.abs(best.scale - scale))
137
+ best = c;
138
+ }
139
+ return best.category;
140
+ }
141
+ /** Inverse, for readback. `unknown` / `unsupported` (both real simctl outputs) → null. */
142
+ function contentSizeToFontScale(category) {
143
+ const hit = CONTENT_SIZES.find((c) => c.category === category.trim().toLowerCase());
144
+ return hit ? hit.scale : null;
145
+ }
146
+ // --- the table --------------------------------------------------------------
147
+ exports.SETTINGS = {
148
+ airplane: {
149
+ key: 'airplane',
150
+ describe: 'Airplane mode — cut the radios to test offline/retry handling',
151
+ values: 'on|off',
152
+ parse: (raw) => parseOnOff('airplane', raw),
153
+ support: { android: 'supported', ios: 'unsupported' },
154
+ manual: {
155
+ ios: 'A simulator has no radio to switch off (`simctl status_bar override --wifiMode failed` ' +
156
+ 'only repaints the status bar). Use Xcode > Open Developer Tool > Network Link Conditioner, ' +
157
+ "or toggle the host Mac's own network — the simulator shares it.",
158
+ },
159
+ note: {},
160
+ },
161
+ dark: {
162
+ key: 'dark',
163
+ describe: 'Dark mode / night theme',
164
+ values: 'on|off',
165
+ parse: (raw) => parseOnOff('dark', raw),
166
+ support: { android: 'supported', ios: 'supported' },
167
+ manual: {},
168
+ note: { ios: 'simulator only — a physical iOS device has no scriptable appearance switch' },
169
+ },
170
+ 'font-scale': {
171
+ key: 'font-scale',
172
+ describe: 'Text scaling factor — catches layout overflow at accessibility sizes',
173
+ values: "a number 0.5-3.0 (e.g. 1.3), or 'default'",
174
+ parse: parseFontScale,
175
+ support: { android: 'supported', ios: 'supported' },
176
+ manual: {},
177
+ note: {
178
+ ios: 'simulator only, and mapped to the nearest Dynamic Type category ' +
179
+ '(iOS has named sizes, not a float) — the applied category is printed',
180
+ },
181
+ },
182
+ rotation: {
183
+ key: 'rotation',
184
+ describe: 'Screen orientation (a fixed value also pins auto-rotate off, so it stays put)',
185
+ values: exports.ROTATION_VALUES.join('|'),
186
+ parse: parseRotation,
187
+ support: { android: 'supported', ios: 'unsupported' },
188
+ manual: {
189
+ ios: 'Neither `simctl ui` (appearance/content_size/increase_contrast only) nor `idb ui` ' +
190
+ 'exposes orientation. Rotate the Simulator window by hand (Cmd+Left / Cmd+Right).',
191
+ },
192
+ note: {},
193
+ },
194
+ 'stay-awake': {
195
+ key: 'stay-awake',
196
+ describe: 'Keep the screen on while charging — a sleeping display hangs UI hierarchy dumps',
197
+ values: 'on|off',
198
+ parse: (raw) => parseOnOff('stay-awake', raw),
199
+ support: { android: 'supported', ios: 'noop' },
200
+ manual: {},
201
+ note: { ios: 'simulators do not sleep, so there is nothing to keep awake' },
202
+ },
203
+ };
204
+ exports.SETTING_KEYS = Object.keys(exports.SETTINGS);
205
+ function isSettingKey(v) {
206
+ return Object.prototype.hasOwnProperty.call(exports.SETTINGS, v);
207
+ }
208
+ /**
209
+ * Parse `key=value` positionals into validated assignments.
210
+ *
211
+ * `key=value` rather than `set <key> <value>` so several settings can be established in
212
+ * one call (each needs its own snapshot entry), and so the `vk ai` compiler model has a
213
+ * single unambiguous form to emit.
214
+ */
215
+ function parseDeviceAssignments(positionals) {
216
+ if (positionals.length === 0) {
217
+ throw new errors_1.CliError(`Usage: verikun device set <key>=<value> [<key>=<value> ...]\nKeys: ${exports.SETTING_KEYS.join(', ')}`, 2);
218
+ }
219
+ const out = [];
220
+ const seen = new Set();
221
+ for (const raw of positionals) {
222
+ const eq = raw.indexOf('=');
223
+ if (eq <= 0) {
224
+ throw new errors_1.CliError(`Invalid assignment '${raw}': expected <key>=<value>, e.g. 'dark=on'.`, 2);
225
+ }
226
+ const key = raw.slice(0, eq).trim().toLowerCase();
227
+ const value = raw.slice(eq + 1);
228
+ if (!isSettingKey(key)) {
229
+ throw new errors_1.CliError(`Unknown device setting '${key}'. Known: ${exports.SETTING_KEYS.join(', ')}.`, 2);
230
+ }
231
+ if (seen.has(key)) {
232
+ throw new errors_1.CliError(`Duplicate device setting '${key}' — it can only be set once per call.`, 2);
233
+ }
234
+ seen.add(key);
235
+ out.push({ key, value: exports.SETTINGS[key].parse(value) });
236
+ }
237
+ return out;
238
+ }
239
+ /**
240
+ * The capability gate, applied BEFORE any device I/O so an unsupported setting fails on
241
+ * the first step rather than midway through a flow. Returns the support state so the
242
+ * caller can print the 'noop' note; throws CliError(…, 3) for 'unsupported'.
243
+ */
244
+ function checkSupport(key, platform) {
245
+ const spec = exports.SETTINGS[key];
246
+ const support = spec.support[platform];
247
+ if (support === 'unsupported') {
248
+ const manual = spec.manual[platform];
249
+ throw new errors_1.CliError(`Device setting '${key}' is not supported on ${platform}.` + (manual ? `\n${manual}` : ''), 3);
250
+ }
251
+ return support;
252
+ }
@@ -3,10 +3,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.AdbDriver = void 0;
4
4
  exports.probeAdb = probeAdb;
5
5
  exports.escapeText = escapeText;
6
+ exports.adbTransport = adbTransport;
7
+ exports.severanceRisk = severanceRisk;
6
8
  const errors_1 = require("../errors");
7
9
  const exec_1 = require("../exec");
8
10
  const android_parse_1 = require("../ui/android-parse");
9
11
  const viewport_1 = require("../ui/viewport");
12
+ const settings_1 = require("../device/settings");
13
+ const output_1 = require("../output");
10
14
  const ADB = process.env.ADB || 'adb';
11
15
  const ADB_HINT = 'install the Android platform-tools (`brew install --cask android-platform-tools`), or point ADB at the binary';
12
16
  /** Is `adb` present and runnable? Shared by `vk doctor` and AdbDriver.preflight() so
@@ -93,6 +97,39 @@ function escapeText(s) {
93
97
  // `input` maps the literal token %s back to a space, so encode spaces last.
94
98
  .replace(/ /g, '%s');
95
99
  }
100
+ /**
101
+ * Classify a device serial by transport. Exported for the unit suite.
102
+ *
103
+ * Load-bearing default: an UNRECOGNIZED shape is `usb`. USB serials are the
104
+ * open-ended set (any vendor string), while TCP serials have exactly two forms —
105
+ * `host:port` and Android 11+ wireless-debugging mDNS names. This classifier only
106
+ * feeds a foot-gun guard, not a security boundary, so the failure we must avoid is
107
+ * misreading a real USB serial as wireless and blocking a legitimate run.
108
+ */
109
+ function adbTransport(serial) {
110
+ const s = serial.trim();
111
+ // An emulator is reached over a host-local console port, not through the guest's
112
+ // network stack, so cutting the guest's wifi cannot sever it.
113
+ if (/^emulator-\d+$/.test(s))
114
+ return 'emulator';
115
+ if (/_adb-tls-(connect|pairing)\._tcp\.?$/.test(s))
116
+ return 'tcp';
117
+ if (/:\d+$/.test(s))
118
+ return 'tcp';
119
+ return 'usb';
120
+ }
121
+ /**
122
+ * Would applying this setting sever adb's own link to the device? Only cutting the
123
+ * radios over a wireless transport does: the command would kill the channel carrying
124
+ * the next command, and nothing could turn it back on remotely.
125
+ */
126
+ function severanceRisk(transport, key, value) {
127
+ return transport === 'tcp' && key === 'airplane' && value === 'on';
128
+ }
129
+ /** How long a written setting has to read back before we call it refused. These are
130
+ * local writes, so they land in well under a second or not at all. */
131
+ const VERIFY_TIMEOUT_MS = 4000;
132
+ const VERIFY_INTERVAL_MS = 200;
96
133
  class AdbDriver {
97
134
  platform = 'android';
98
135
  requested;
@@ -397,5 +434,145 @@ class AdbDriver {
397
434
  return '';
398
435
  }
399
436
  }
437
+ // --- device settings ------------------------------------------------------
438
+ //
439
+ // Every device token below is a constant (the value domain is a closed enum in
440
+ // device/settings.ts), so no caller-supplied string reaches the device shell and
441
+ // no escaping gate is needed. The one non-constant, font-scale's number, has
442
+ // already been reduced to /^\d+(\.\d+)?$/ by the table's parse().
443
+ /** Like shell(), but keeps stderr and the exit code — needed because a refusal
444
+ * ("Permission denial", "cmd: Can't find service") arrives on stderr and would
445
+ * otherwise be invisible in the error we raise. */
446
+ shellFull(args, timeout) {
447
+ return (0, exec_1.runText)(ADB, this.withSerial(['shell', ...args]), { timeout });
448
+ }
449
+ /** `settings get <ns> <key>`, with Android's literal "null" (unset) mapped to null. */
450
+ readSetting(ns, key) {
451
+ const v = this.shell(['settings', 'get', ns, key]).trim();
452
+ return v === '' || v === 'null' ? null : v;
453
+ }
454
+ /** Poll a readback until it satisfies `ok`. Returns the final value (or null). */
455
+ pollSetting(read, ok) {
456
+ const deadline = Date.now() + VERIFY_TIMEOUT_MS;
457
+ let last = read();
458
+ while (!ok(last) && Date.now() < deadline) {
459
+ (0, exec_1.sleepSync)(VERIFY_INTERVAL_MS);
460
+ last = read();
461
+ }
462
+ return last;
463
+ }
464
+ /** Apply a change, then prove it landed. `svc` / `cmd` / `settings put` are all
465
+ * fire-and-forget and are silently ignored on some OEM skins, so the readback is
466
+ * the actual contract — without it we would report success for a no-op. */
467
+ applyAndVerify(what, mutate, read, ok, hint) {
468
+ const r = mutate();
469
+ const final = this.pollSetting(read, ok);
470
+ if (ok(final))
471
+ return;
472
+ const why = `${r.stderr}\n${r.stdout}`.replace(/\s+/g, ' ').trim();
473
+ throw new errors_1.CliError(`Failed to set ${what}: the command ran but the device still reports ${JSON.stringify(final)} ` +
474
+ `after ${VERIFY_TIMEOUT_MS}ms.` +
475
+ (why ? `\nDevice said: ${why}` : '') +
476
+ `\n${hint}`, 3);
477
+ }
478
+ getDeviceSetting(key) {
479
+ switch (key) {
480
+ case 'airplane':
481
+ return this.readSetting('global', 'airplane_mode_on') === '1' ? 'on' : 'off';
482
+ case 'dark': {
483
+ // `cmd uimode night` prints e.g. "Night mode: no". Some builds also report
484
+ // "auto"/"custom", which our on|off domain cannot express — report null
485
+ // rather than guessing, so a snapshot declines to restore it.
486
+ const out = this.shell(['cmd', 'uimode', 'night']).trim().toLowerCase();
487
+ if (/\byes\b/.test(out))
488
+ return 'on';
489
+ if (/\bno\b/.test(out))
490
+ return 'off';
491
+ return null;
492
+ }
493
+ case 'font-scale': {
494
+ // Unset means Android's default of 1.0 (the setting row simply doesn't exist
495
+ // yet). Report the effective value, not the absence, so restore is correct.
496
+ const raw = this.readSetting('system', 'font_scale');
497
+ const n = raw === null ? 1.0 : Number(raw);
498
+ return Number.isFinite(n) ? (0, settings_1.canonicalFontScale)(n) : null;
499
+ }
500
+ case 'rotation': {
501
+ if (this.readSetting('system', 'accelerometer_rotation') === '1')
502
+ return settings_1.ROTATION_AUTO;
503
+ const v = this.readSetting('system', 'user_rotation');
504
+ return v === null ? null : (0, settings_1.userRotationToRotation)(v);
505
+ }
506
+ case 'stay-awake': {
507
+ // A bitmask of the charging types it applies to (1=AC, 2=USB, 4=wireless);
508
+ // any non-zero value means "stays on", which is all our on|off domain claims.
509
+ const v = this.readSetting('global', 'stay_on_while_plugged_in');
510
+ return v === null ? 'off' : v !== '0' ? 'on' : 'off';
511
+ }
512
+ }
513
+ }
514
+ setDeviceSetting(key, value) {
515
+ switch (key) {
516
+ case 'airplane':
517
+ return this.setAirplane(value === 'on');
518
+ case 'dark':
519
+ return this.applyAndVerify(`dark=${value}`, () => this.shellFull(['cmd', 'uimode', 'night', value === 'on' ? 'yes' : 'no']), () => this.getDeviceSetting('dark'), (v) => v === value, 'Some OEM skins override night mode from their own theme engine.');
520
+ case 'font-scale':
521
+ return this.applyAndVerify(`font-scale=${value}`, () => this.shellFull(['settings', 'put', 'system', 'font_scale', value]), () => this.getDeviceSetting('font-scale'), (v) => v === value, 'Writing system settings requires an unrestricted adb shell.');
522
+ case 'rotation':
523
+ return this.setRotation(value);
524
+ case 'stay-awake':
525
+ 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.');
526
+ }
527
+ }
528
+ /**
529
+ * Airplane mode, reconciled against the radios that can actually survive it.
530
+ *
531
+ * Android publishes `airplane_mode_toggleable_radios` — the radios a user is allowed
532
+ * to switch back ON while airplane mode is active (typically `bluetooth,wifi,nfc`) —
533
+ * and it REMEMBERS that choice. So on a phone where wifi was once re-enabled mid-
534
+ * flight, `airplane-mode enable` leaves wifi UP. Reporting "offline" while the app is
535
+ * still online would make an offline test pass for the wrong reason, which is the
536
+ * worst failure mode a testing tool has. So: flip the flag, then force any toggleable
537
+ * radio that ignored it.
538
+ *
539
+ * Only radios in that list are reconciled. Cellular is not one of them — the flag
540
+ * cuts it outright — and `mobile_data` is a stored user PREFERENCE rather than live
541
+ * radio state, so it keeps reading 1 on a SIM-less device that is plainly offline.
542
+ * Probing it would fail a perfectly good offline state.
543
+ *
544
+ * The inverse is symmetric: a radio we forced off by hand will not come back on its
545
+ * own, so leaving airplane mode re-enables it. That can turn a radio back on that the
546
+ * user had off before the run, so it is announced on stderr rather than done quietly.
547
+ */
548
+ setAirplane(on) {
549
+ this.applyAndVerify(`airplane=${on ? 'on' : 'off'}`, () => this.shellFull(['cmd', 'connectivity', 'airplane-mode', on ? 'enable' : 'disable']), () => this.readSetting('global', 'airplane_mode_on'), (v) => v === (on ? '1' : '0'), 'Airplane mode is settable via `cmd connectivity` on API 30+; older devices need root.');
550
+ // Read the toggleable list from the device rather than assuming it — it varies by
551
+ // build, and a device that does not let wifi survive airplane mode needs no fixup.
552
+ const toggleable = (this.readSetting('global', 'airplane_mode_toggleable_radios') ?? '')
553
+ .split(',')
554
+ .map((s) => s.trim().toLowerCase());
555
+ if (!toggleable.includes('wifi'))
556
+ return;
557
+ // wifi_on is tri-state on some builds (2 = on, pending airplane-mode exit), so ask
558
+ // "is it off?" rather than comparing against a single expected value.
559
+ const isOff = (v) => v === null || v === '0';
560
+ const settled = this.pollSetting(() => this.readSetting('global', 'wifi_on'), (v) => isOff(v) === on);
561
+ if (isOff(settled) === on)
562
+ return;
563
+ (0, output_1.err)(`note: wifi was still ${on ? 'up' : 'down'} after airplane mode ${on ? 'on' : 'off'} — ` +
564
+ `forcing it ${on ? 'off' : 'on'} so the device state matches the request`);
565
+ this.applyAndVerify(`wifi ${on ? 'off' : 'on'}`, () => this.shellFull(['svc', 'wifi', on ? 'disable' : 'enable']), () => this.readSetting('global', 'wifi_on'), (v) => isOff(v) === on, '`svc wifi` is refused by some OEM skins; toggle it in Settings instead.');
566
+ }
567
+ /** A fixed orientation also pins auto-rotate off, or the accelerometer would
568
+ * immediately undo it the moment the device moves. */
569
+ setRotation(value) {
570
+ if (value === settings_1.ROTATION_AUTO) {
571
+ return this.applyAndVerify('rotation=auto', () => this.shellFull(['settings', 'put', 'system', 'accelerometer_rotation', '1']), () => this.readSetting('system', 'accelerometer_rotation'), (v) => v === '1', 'Writing system settings requires an unrestricted adb shell.');
572
+ }
573
+ this.applyAndVerify('auto-rotate off', () => this.shellFull(['settings', 'put', 'system', 'accelerometer_rotation', '0']), () => this.readSetting('system', 'accelerometer_rotation'), (v) => v === '0', 'Writing system settings requires an unrestricted adb shell.');
574
+ const target = String((0, settings_1.rotationToUserRotation)(value));
575
+ 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.');
576
+ }
400
577
  }
401
578
  exports.AdbDriver = AdbDriver;
@@ -11,6 +11,8 @@ const errors_1 = require("../errors");
11
11
  const exec_1 = require("../exec");
12
12
  const ios_parse_1 = require("../ui/ios-parse");
13
13
  const viewport_1 = require("../ui/viewport");
14
+ const settings_1 = require("../device/settings");
15
+ const output_1 = require("../output");
14
16
  // iOS driver. `xcrun simctl` / `devicectl` cover device discovery and — on a
15
17
  // simulator — screenshots, app lifecycle, and logs (no extra install needed).
16
18
  // Everything interactive (UI hierarchy, tap, type, swipe, keys, screen size) and
@@ -459,5 +461,90 @@ class IdbDriver {
459
461
  return '';
460
462
  }
461
463
  }
464
+ // --- device settings ------------------------------------------------------
465
+ //
466
+ // Only two of the five keys exist on iOS, and only on a SIMULATOR: `simctl ui`
467
+ // offers appearance / content_size / increase_contrast and nothing else, while
468
+ // `idb ui` is purely interaction (tap/text/key/swipe/describe). A physical device
469
+ // has no scriptable settings surface at all. Rather than fake the gap — a
470
+ // status-bar override would repaint the wifi glyph without cutting any traffic —
471
+ // the unsupported keys refuse with exit 3 and name the manual equivalent, the way
472
+ // clearApp does.
473
+ /** `simctl ui <udid> <option>` with no argument reads the current value. */
474
+ simctlUi(option, value) {
475
+ const args = ['simctl', 'ui', this.udid(), option, ...(value ? [value] : [])];
476
+ const r = (0, exec_1.runText)(XCRUN, args);
477
+ if (r.code !== 0) {
478
+ throw new errors_1.CliError(`simctl ui ${option} failed: ${r.stderr.trim() || `exit code ${r.code}`}`, 3);
479
+ }
480
+ return r.stdout.trim();
481
+ }
482
+ /** Shared refusal for a key iOS cannot honor. */
483
+ unsupportedSetting(key, detail) {
484
+ throw new errors_1.CliError(`Device setting '${key}' is not supported on iOS.\n${detail}`, 3);
485
+ }
486
+ assertSimulator(key) {
487
+ if (!this.isSimulator()) {
488
+ this.unsupportedSetting(key, 'A physical iOS device exposes no scriptable settings surface (simctl drives simulators ' +
489
+ 'only, and idb covers interaction, not preferences). Change it by hand in Settings.');
490
+ }
491
+ }
492
+ getDeviceSetting(key) {
493
+ // Best-effort by contract: never throw, so a snapshot of a key this platform
494
+ // cannot answer simply declines to restore it rather than aborting the run.
495
+ try {
496
+ if (!this.isSimulator())
497
+ return null;
498
+ switch (key) {
499
+ case 'dark': {
500
+ const v = this.simctlUi('appearance').toLowerCase();
501
+ return v === 'dark' ? 'on' : v === 'light' ? 'off' : null;
502
+ }
503
+ case 'font-scale': {
504
+ // simctl also answers 'unknown' / 'unsupported'; both map to null.
505
+ const scale = (0, settings_1.contentSizeToFontScale)(this.simctlUi('content_size'));
506
+ return scale === null ? null : (0, settings_1.canonicalFontScale)(scale);
507
+ }
508
+ case 'airplane':
509
+ case 'rotation':
510
+ case 'stay-awake':
511
+ return null;
512
+ }
513
+ }
514
+ catch {
515
+ return null;
516
+ }
517
+ }
518
+ setDeviceSetting(key, value) {
519
+ switch (key) {
520
+ case 'dark': {
521
+ this.assertSimulator(key);
522
+ this.simctlUi('appearance', value === 'on' ? 'dark' : 'light');
523
+ return;
524
+ }
525
+ case 'font-scale': {
526
+ this.assertSimulator(key);
527
+ // iOS has named Dynamic Type categories where Android has a float, so the
528
+ // value is mapped — and the category we actually applied is echoed, because
529
+ // silently landing on a different size than asked for would be a lie.
530
+ const category = (0, settings_1.fontScaleToContentSize)(Number(value));
531
+ this.simctlUi('content_size', category);
532
+ (0, output_1.err)(`note: font-scale ${value} applied on iOS as content size '${category}'`);
533
+ return;
534
+ }
535
+ case 'stay-awake':
536
+ // Honest no-op: a simulator never sleeps, so the intent is already satisfied.
537
+ this.assertSimulator(key);
538
+ (0, output_1.err)('note: stay-awake is a no-op on iOS — simulators do not sleep');
539
+ return;
540
+ case 'airplane':
541
+ return this.unsupportedSetting(key, 'A simulator has no radio to switch off (`simctl status_bar override --wifiMode failed` ' +
542
+ 'only repaints the status bar). Use Xcode > Open Developer Tool > Network Link ' +
543
+ "Conditioner, or toggle the host Mac's own network — the simulator shares it.");
544
+ case 'rotation':
545
+ return this.unsupportedSetting(key, 'Neither `simctl ui` (appearance/content_size/increase_contrast only) nor `idb ui` ' +
546
+ 'exposes orientation. Rotate the Simulator window by hand (Cmd+Left / Cmd+Right).');
547
+ }
548
+ }
462
549
  }
463
550
  exports.IdbDriver = IdbDriver;
package/dist/exec.js CHANGED
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sleepSync = sleepSync;
3
4
  exports.runText = runText;
4
5
  exports.commandExists = commandExists;
5
6
  exports.runBinary = runBinary;
@@ -8,6 +9,17 @@ const node_fs_1 = require("node:fs");
8
9
  const node_path_1 = require("node:path");
9
10
  const errors_1 = require("./errors");
10
11
  const MAX_BUFFER = 64 * 1024 * 1024; // screenshots can be a few MB
12
+ /**
13
+ * Block the calling thread for `ms`. Needed because the Driver interface is entirely
14
+ * synchronous (every device call is a spawnSync), so a readback poll cannot await a
15
+ * timer. Uses Atomics.wait on a throwaway SharedArrayBuffer — a Node builtin, keeping
16
+ * with the zero-runtime-dependency rule, and cheaper than spawning `sleep`.
17
+ */
18
+ function sleepSync(ms) {
19
+ if (ms <= 0)
20
+ return;
21
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
22
+ }
11
23
  function describeError(cmd, args, err) {
12
24
  if (err.code === 'ENOENT') {
13
25
  return new errors_1.CliError(`'${cmd}' was not found on PATH. Is it installed and on your PATH?`, 3);
package/dist/run.js CHANGED
@@ -30,8 +30,17 @@ const RECORDABLE = new Set([
30
30
  'wait', 'assert',
31
31
  'launch', 'open', 'stop', 'clear',
32
32
  'log', 'logs',
33
+ // `device` changes the environment the app runs in. A report showing "checkout
34
+ // failed" without "we cut the network two steps earlier" would be misleading.
35
+ 'device',
33
36
  ]);
34
- function isRecordable(command) {
37
+ function isRecordable(command, positionals = []) {
38
+ // `device` is the one command that is recordable only in part: `set`/`reset` change
39
+ // the environment the app runs in and belong in the report, while `get`/`caps` are
40
+ // pure inspection — recording those would auto-start a test run just for asking what
41
+ // the device supports, exactly the noise `ui`/`find` are excluded to avoid.
42
+ if (command === 'device')
43
+ return ['set', 'reset'].includes((positionals[0] ?? '').toLowerCase());
35
44
  return RECORDABLE.has(command);
36
45
  }
37
46
  const HIERARCHY_CAP = 24000; // chars of failure hierarchy kept inline in run.json
@@ -226,6 +235,10 @@ function stepName(command, positionals, flags) {
226
235
  case 'home':
227
236
  case 'enter':
228
237
  return command;
238
+ case 'device':
239
+ // The payload IS the story here — "device set" alone would tell a report
240
+ // reader nothing about what changed, so keep the whole assignment list.
241
+ return `device ${p.join(' ')}`.trim();
229
242
  default:
230
243
  return `${command} ${p[0] ?? ''}`.trim();
231
244
  }
@@ -260,9 +273,30 @@ class Recorder {
260
273
  const session = currentSession();
261
274
  let rolledOver = false;
262
275
  // Close a stale / context-mismatched run before continuing.
276
+ let carriedOverrides;
263
277
  if (state) {
264
278
  const reason = rolloverReason(state, serial, session);
265
279
  if (reason) {
280
+ // A sealed run takes its deviceOverrides with it, and `device reset` reads the
281
+ // ACTIVE run — so without this the device silently stays modified and reset
282
+ // cheerfully reports "nothing to restore". Same device: carry the snapshot into
283
+ // the fresh run so reset still works. Different device: we cannot drive the old
284
+ // one from here, so say exactly what was left changed and where.
285
+ const stranded = state.deviceOverrides ?? {};
286
+ if (Object.keys(stranded).length > 0) {
287
+ // Same predicate as the log-attribution check: "is the device in front of us
288
+ // still the one this run was bound to?"
289
+ const sameDevice = rolloverLogsSameDevice(state.device, serial);
290
+ if (sameDevice) {
291
+ carriedOverrides = stranded;
292
+ }
293
+ else {
294
+ const listed = Object.entries(stranded).map(([k, v]) => `${k} (was ${v})`).join(', ');
295
+ (0, output_1.err)(`[verikun] WARNING: run for device ${state.device} is being closed while it still has ` +
296
+ `device settings applied: ${listed}. That device was NOT restored — put it back with ` +
297
+ `\`vk device set ${Object.entries(stranded).map(([k, v]) => `${k}=${v}`).join(' ')} --device ${state.device}\``);
298
+ }
299
+ }
266
300
  try {
267
301
  // Only reuse this step's driver when it still targets the run's device.
268
302
  // A device-change rollover must not write the new device's logcat into
@@ -290,6 +324,9 @@ class Recorder {
290
324
  device: serial || deviceReq,
291
325
  session,
292
326
  implicit: true,
327
+ // Survives a same-device rollover, so `device reset` can still undo what an
328
+ // earlier run applied to the device now in front of us.
329
+ ...(carriedOverrides ? { deviceOverrides: carriedOverrides } : {}),
293
330
  steps: [],
294
331
  };
295
332
  if (!rolledOver) {
@@ -567,6 +604,42 @@ class Recorder {
567
604
  static status() {
568
605
  return loadState(activeDir());
569
606
  }
607
+ // --- device overrides ---------------------------------------------------
608
+ //
609
+ // These are INSTANCE methods on purpose. A recorder holds the run state in memory
610
+ // for the duration of a step and writes the whole thing in commit(), so a static
611
+ // that loaded-mutated-saved the file mid-step would be silently overwritten a
612
+ // moment later by that commit — the snapshot would vanish and `device reset` would
613
+ // have nothing to undo. Mutating this.state lets commit() persist it.
614
+ /**
615
+ * Record what a device setting held BEFORE this run changed it. Keeps the EARLIEST
616
+ * original: setting `dark` twice must still restore to the pre-run appearance, not
617
+ * to the intermediate value.
618
+ */
619
+ rememberDeviceOverride(key, original) {
620
+ this.state.deviceOverrides ??= {};
621
+ if (!(key in this.state.deviceOverrides))
622
+ this.state.deviceOverrides[key] = original;
623
+ }
624
+ /** The pre-run values of every device setting this run changed. */
625
+ deviceOverrides() {
626
+ return this.state.deviceOverrides ?? {};
627
+ }
628
+ /** Drop override records once they have been restored (all, or a named subset). */
629
+ forgetDeviceOverrides(keys) {
630
+ if (!this.state.deviceOverrides)
631
+ return;
632
+ if (keys)
633
+ for (const k of keys)
634
+ delete this.state.deviceOverrides[k];
635
+ else
636
+ this.state.deviceOverrides = {};
637
+ }
638
+ /** Whether the run on disk has anything to restore. Used OUTSIDE a step — by the
639
+ * batch/ai/suite teardown deciding whether a `device reset` is worth issuing. */
640
+ static hasDeviceOverrides() {
641
+ return Object.keys(Recorder.status()?.deviceOverrides ?? {}).length > 0;
642
+ }
570
643
  /** Merge a patch into the active run (used by `vk ai` to attach its summary
571
644
  * before archiving). No-op if there is no active run. */
572
645
  static annotateRun(patch) {
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.18.1';
6
+ exports.VERSION = '0.19.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "verikun",
3
- "version": "0.18.1",
3
+ "version": "0.19.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",