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 +66 -0
- package/dist/agent/grammar.js +14 -0
- package/dist/agent/ir.js +52 -3
- package/dist/args.js +1 -0
- package/dist/cli.js +252 -23
- package/dist/device/settings.js +252 -0
- package/dist/drivers/adb.js +177 -0
- package/dist/drivers/ios.js +87 -0
- package/dist/exec.js +12 -0
- package/dist/run.js +74 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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
|
|
package/dist/agent/grammar.js
CHANGED
|
@@ -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'
|
|
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
|
-
|
|
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
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
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
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 (
|
|
1165
|
-
(0, output_1.err)(`[verikun] batch
|
|
1166
|
-
|
|
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
|
+
}
|
package/dist/drivers/adb.js
CHANGED
|
@@ -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;
|
package/dist/drivers/ios.js
CHANGED
|
@@ -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.
|
|
6
|
+
exports.VERSION = '0.19.0';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "verikun",
|
|
3
|
-
"version": "0.
|
|
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",
|