verikun 0.22.1 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/verikun/SKILL.md +59 -11
- package/CHANGELOG.md +62 -16
- package/README.md +14 -3
- package/dist/agent/remote.js +44 -0
- package/dist/args.js +8 -0
- package/dist/cli.js +588 -46
- package/dist/device/claims.js +12 -2
- package/dist/device/prep.js +156 -0
- package/dist/device/settings.js +82 -0
- package/dist/drivers/adb.js +574 -33
- package/dist/drivers/index.js +4 -1
- package/dist/drivers/ios.js +152 -25
- package/dist/drivers/lifecycle.js +188 -0
- package/dist/exec.js +48 -0
- package/dist/run.js +3 -1
- package/dist/server.js +306 -18
- package/dist/version.js +1 -1
- package/dist/wait.js +61 -0
- package/package.json +4 -1
package/dist/cli.js
CHANGED
|
@@ -50,6 +50,8 @@ exports.chooseLogOpts = chooseLogOpts;
|
|
|
50
50
|
exports.evalAssert = evalAssert;
|
|
51
51
|
exports.tokenizeLine = tokenizeLine;
|
|
52
52
|
exports.withBatchGlobals = withBatchGlobals;
|
|
53
|
+
exports.serverFromFlags = serverFromFlags;
|
|
54
|
+
exports.ensureDeviceTarget = ensureDeviceTarget;
|
|
53
55
|
exports.terminalFailure = terminalFailure;
|
|
54
56
|
exports.executeForServer = executeForServer;
|
|
55
57
|
exports.run = run;
|
|
@@ -60,8 +62,10 @@ const errors_1 = require("./errors");
|
|
|
60
62
|
const exec_1 = require("./exec");
|
|
61
63
|
const drivers_1 = require("./drivers");
|
|
62
64
|
const adb_1 = require("./drivers/adb");
|
|
65
|
+
const lifecycle_1 = require("./drivers/lifecycle");
|
|
63
66
|
const settings_1 = require("./device/settings");
|
|
64
67
|
const claims_1 = require("./device/claims");
|
|
68
|
+
const prep_1 = require("./device/prep");
|
|
65
69
|
const selector_1 = require("./ui/selector");
|
|
66
70
|
const state_support_1 = require("./ui/state-support");
|
|
67
71
|
const format_1 = require("./ui/format");
|
|
@@ -79,9 +83,9 @@ const cache_1 = require("./agent/cache");
|
|
|
79
83
|
const cost_1 = require("./agent/cost");
|
|
80
84
|
const remote_1 = require("./agent/remote");
|
|
81
85
|
const suite_1 = require("./suite");
|
|
86
|
+
const wait_1 = require("./wait");
|
|
82
87
|
const version_1 = require("./version");
|
|
83
88
|
const update_check_1 = require("./update-check");
|
|
84
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
85
89
|
// Exported for src/server.ts (which resolves its own platform/device at startup).
|
|
86
90
|
function platformFromFlags(flags) {
|
|
87
91
|
if ((0, args_1.flagBool)(flags, 'ios'))
|
|
@@ -217,7 +221,7 @@ async function matchWaiting(ctx, sel, opts = {}) {
|
|
|
217
221
|
const res = (0, selector_1.matchElements)(readForPoll(ctx, opts), sel);
|
|
218
222
|
if (res.matches.length > 0 || Date.now() >= deadline)
|
|
219
223
|
return res;
|
|
220
|
-
await sleep(pollStep(ctx.flags, deadline));
|
|
224
|
+
await (0, wait_1.sleep)(pollStep(ctx.flags, deadline));
|
|
221
225
|
}
|
|
222
226
|
}
|
|
223
227
|
/**
|
|
@@ -242,7 +246,7 @@ async function resolveOneWaiting(ctx, sel, opts = {}) {
|
|
|
242
246
|
const waited = windowMs > 0 ? ` after ${(windowMs / 1000).toFixed(1)}s` : '';
|
|
243
247
|
throw new errors_1.SelectorNotFoundError(`No element matched selector '${sel.raw}'${waited}. Run \`verikun ui\` to inspect the current screen.`);
|
|
244
248
|
}
|
|
245
|
-
await sleep(pollStep(ctx.flags, deadline));
|
|
249
|
+
await (0, wait_1.sleep)(pollStep(ctx.flags, deadline));
|
|
246
250
|
}
|
|
247
251
|
}
|
|
248
252
|
// --- Auto scroll-into-view --------------------------------------------------
|
|
@@ -299,7 +303,7 @@ async function scrollIntoView(ctx, sel, target, elements, screen, opts = {}) {
|
|
|
299
303
|
break; // in view, or no swipe big enough to be worth making
|
|
300
304
|
ctx.driver.swipe(plan.from.x, plan.from.y, plan.to.x, plan.to.y, (0, viewport_1.swipeDurationMs)(plan.distance));
|
|
301
305
|
swipes++;
|
|
302
|
-
await sleep(SCROLL_SETTLE_MS);
|
|
306
|
+
await (0, wait_1.sleep)(SCROLL_SETTLE_MS);
|
|
303
307
|
const before = current.bounds;
|
|
304
308
|
snapshot = ctx.driver.getElements(opts);
|
|
305
309
|
// An empty tree is a bad read, not a screen (the device returns partial dumps
|
|
@@ -312,7 +316,7 @@ async function scrollIntoView(ctx, sel, target, elements, screen, opts = {}) {
|
|
|
312
316
|
// (half of it, to land between the two) and look once more before giving up.
|
|
313
317
|
const mid = { x: Math.round((plan.from.x + plan.to.x) / 2), y: Math.round((plan.from.y + plan.to.y) / 2) };
|
|
314
318
|
ctx.driver.swipe(plan.to.x, plan.to.y, mid.x, mid.y, (0, viewport_1.swipeDurationMs)(plan.distance / 2));
|
|
315
|
-
await sleep(SCROLL_SETTLE_MS);
|
|
319
|
+
await (0, wait_1.sleep)(SCROLL_SETTLE_MS);
|
|
316
320
|
snapshot = ctx.driver.getElements(opts);
|
|
317
321
|
if ((0, selector_1.matchElements)(snapshot, sel).matches.length === 0) {
|
|
318
322
|
throw new errors_1.SelectorNotFoundError(`'${sel.raw}' left the hierarchy while being scrolled into view (after ${swipes} swipe(s)) — ` +
|
|
@@ -388,10 +392,28 @@ async function resolveTappable(ctx, sel, opts = {}) {
|
|
|
388
392
|
// ---------------------------------------------------------------------------
|
|
389
393
|
// Commands
|
|
390
394
|
// ---------------------------------------------------------------------------
|
|
391
|
-
function cmdDevices(
|
|
395
|
+
function cmdDevices(flags) {
|
|
396
|
+
const all = (0, args_1.flagBool)(flags, 'all');
|
|
392
397
|
const allDevices = [];
|
|
393
398
|
try {
|
|
394
|
-
|
|
399
|
+
const android = new drivers_1.AdbDriver().listDevices();
|
|
400
|
+
// Name each running emulator by its AVD, so the listing shows the token you'd
|
|
401
|
+
// pass to `vk devices stop|restart`. Best-effort: the console may not answer.
|
|
402
|
+
for (const d of android) {
|
|
403
|
+
if (d.kind === 'emulator')
|
|
404
|
+
d.name = (0, adb_1.avdNameOf)(d.serial) || undefined;
|
|
405
|
+
}
|
|
406
|
+
allDevices.push(...android);
|
|
407
|
+
if (all) {
|
|
408
|
+
// Startable-but-not-running AVDs have no adb address yet — they are addressed
|
|
409
|
+
// by name, so `serial` stays empty rather than being invented.
|
|
410
|
+
const running = new Set(android.map((d) => (d.name ?? '').toLowerCase()).filter(Boolean));
|
|
411
|
+
for (const name of (0, adb_1.listAvds)()) {
|
|
412
|
+
if (running.has(name.toLowerCase()))
|
|
413
|
+
continue;
|
|
414
|
+
allDevices.push({ serial: '', state: 'shutdown', platform: 'android', kind: 'emulator', name });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
395
417
|
}
|
|
396
418
|
catch (e) {
|
|
397
419
|
// adb not on PATH is the common (expected) case, but surface anything else so a real
|
|
@@ -399,8 +421,10 @@ function cmdDevices(ctx) {
|
|
|
399
421
|
(0, output_1.err)(`devices: adb backend unavailable (${e.message})`);
|
|
400
422
|
}
|
|
401
423
|
try {
|
|
402
|
-
//
|
|
403
|
-
|
|
424
|
+
// By default only booted simulators (a shutdown one isn't drivable); physical
|
|
425
|
+
// devices always show (they carry a note). --all lists everything startable.
|
|
426
|
+
const ios = new drivers_1.IdbDriver().listDevices();
|
|
427
|
+
allDevices.push(...(all ? ios : ios.filter((d) => d.state === 'booted' || d.note)));
|
|
404
428
|
}
|
|
405
429
|
catch (e) {
|
|
406
430
|
(0, output_1.err)(`devices: iOS backend unavailable (${e.message})`);
|
|
@@ -414,22 +438,140 @@ function cmdDevices(ctx) {
|
|
|
414
438
|
d.claim = claim;
|
|
415
439
|
}
|
|
416
440
|
}
|
|
417
|
-
if ((0, args_1.flagBool)(
|
|
441
|
+
if ((0, args_1.flagBool)(flags, 'json')) {
|
|
418
442
|
(0, output_1.json)(allDevices);
|
|
419
443
|
return 0;
|
|
420
444
|
}
|
|
421
445
|
if (!allDevices.length) {
|
|
422
|
-
(0, output_1.err)('No devices found.');
|
|
446
|
+
(0, output_1.err)(all ? 'No devices or startable AVDs/simulators found.' : 'No devices found.');
|
|
423
447
|
return 0;
|
|
424
448
|
}
|
|
425
449
|
for (const line of formatDeviceTable(allDevices))
|
|
426
450
|
(0, output_1.out)(line);
|
|
427
451
|
return 0;
|
|
428
452
|
}
|
|
453
|
+
/** Aliases for the lifecycle verbs, mirroring the top-level command aliases. */
|
|
454
|
+
const DEVICE_VERBS = {
|
|
455
|
+
start: 'start',
|
|
456
|
+
boot: 'start',
|
|
457
|
+
stop: 'stop',
|
|
458
|
+
shutdown: 'stop',
|
|
459
|
+
kill: 'stop',
|
|
460
|
+
restart: 'restart',
|
|
461
|
+
reboot: 'restart',
|
|
462
|
+
};
|
|
463
|
+
/**
|
|
464
|
+
* `vk devices [list|start|stop|restart]`. Dispatched as a META-command (before the
|
|
465
|
+
* recording machinery) whenever a subcommand is present: lifecycle needs no local
|
|
466
|
+
* driver — it is what you run when there ISN'T one — and a `--server` invocation
|
|
467
|
+
* must not build one either. Bare `vk devices` keeps the ordinary path.
|
|
468
|
+
*/
|
|
469
|
+
async function cmdDevicesEntry(positionals, flags) {
|
|
470
|
+
const sub = String(positionals[0] ?? 'list').toLowerCase();
|
|
471
|
+
const server = serverFromFlags(flags);
|
|
472
|
+
if (sub === 'list')
|
|
473
|
+
return server ? cmdDevicesRemoteList(server, flags) : cmdDevices(flags);
|
|
474
|
+
const verb = DEVICE_VERBS[sub];
|
|
475
|
+
if (!verb) {
|
|
476
|
+
throw new errors_1.CliError(`Unknown 'devices' subcommand '${sub}'. Use: list | start | stop | restart.`, 2);
|
|
477
|
+
}
|
|
478
|
+
return server
|
|
479
|
+
? cmdDevicesRemote(verb, positionals[1], server, flags)
|
|
480
|
+
: cmdDeviceLifecycle(verb, positionals[1], flags);
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Refuse to power-cycle a device another job is actively driving.
|
|
484
|
+
*
|
|
485
|
+
* The local counterpart of the server's `409`: you may restart YOUR OWN device, never
|
|
486
|
+
* someone else's. Without this, device claims and device lifecycle would each work and
|
|
487
|
+
* together be a hole — one job could reboot the phone another job is mid-suite on,
|
|
488
|
+
* which is precisely the collision claims exist to prevent.
|
|
489
|
+
*
|
|
490
|
+
* `start` is exempt: it only ever brings up something that is not running, so there is
|
|
491
|
+
* nothing to pull out from under anyone.
|
|
492
|
+
*/
|
|
493
|
+
function assertNotClaimedByOthers(target, verb) {
|
|
494
|
+
if (verb === 'start' || !target.serial || !(0, claims_1.claimsEnabled)())
|
|
495
|
+
return;
|
|
496
|
+
const claim = (0, claims_1.summarize)(target.serial);
|
|
497
|
+
if (!claim || claim.mine)
|
|
498
|
+
return;
|
|
499
|
+
throw new errors_1.CliError(`${(0, lifecycle_1.targetLabel)(target)} (${target.serial}) is in use by ${claim.by} — ` +
|
|
500
|
+
`${verb} would pull the device out from under it.\n` +
|
|
501
|
+
`If that job is gone, hand the device back first: vk device release ${target.serial}`, 2);
|
|
502
|
+
}
|
|
503
|
+
async function cmdDeviceLifecycle(verb, rawTarget, flags) {
|
|
504
|
+
const wipe = (0, args_1.flagBool)(flags, 'wipe');
|
|
505
|
+
const wait = !(0, args_1.flagBool)(flags, 'no-wait');
|
|
506
|
+
const timeoutFlag = (0, args_1.flagStr)(flags, 'timeout');
|
|
507
|
+
const timeoutMs = timeoutFlag
|
|
508
|
+
? parseDuration(timeoutFlag, 'timeout')
|
|
509
|
+
: verb === 'stop'
|
|
510
|
+
? wait_1.DEFAULT_STOP_TIMEOUT_MS
|
|
511
|
+
: wait_1.DEFAULT_BOOT_TIMEOUT_MS;
|
|
512
|
+
// An explicit --ios/--android/--platform narrows the search; otherwise probe both
|
|
513
|
+
// backends, exactly as bare `vk devices` does, so `devices start Pixel_6_API_34`
|
|
514
|
+
// works without also naming the platform. A name that hits BOTH is still exit 2.
|
|
515
|
+
const explicitPlatform = (0, args_1.flagBool)(flags, 'ios') || (0, args_1.flagBool)(flags, 'android') || (0, args_1.flagStr)(flags, 'platform') !== undefined;
|
|
516
|
+
const lifecycles = explicitPlatform ? [(0, lifecycle_1.lifecycleFor)(platformFromFlags(flags))] : (0, lifecycle_1.allLifecycles)();
|
|
517
|
+
const targets = lifecycles.flatMap((l) => l.targets());
|
|
518
|
+
if (!rawTarget) {
|
|
519
|
+
const startable = targets.filter((t) => t.kind !== 'physical').map(lifecycle_1.targetLabel).filter(Boolean);
|
|
520
|
+
const hint = startable.length
|
|
521
|
+
? `\nStartable: ${[...new Set(startable)].join(', ')}`
|
|
522
|
+
: '\nRun `vk devices --all` to list startable devices.';
|
|
523
|
+
throw new errors_1.CliError(`Usage: verikun devices ${verb} <name|serial> [--timeout <dur>]${hint}`, 2);
|
|
524
|
+
}
|
|
525
|
+
// `start` wants the one that ISN'T up; `stop`/`restart` want the live one.
|
|
526
|
+
const chosen = (0, lifecycle_1.chooseTarget)(targets, rawTarget, { prefer: verb === 'start' ? 'startable' : 'running' });
|
|
527
|
+
(0, lifecycle_1.assertActionable)(chosen, verb, { wipe });
|
|
528
|
+
assertNotClaimedByOthers(chosen, verb);
|
|
529
|
+
const lc = (0, lifecycle_1.lifecycleFor)(chosen.platform);
|
|
530
|
+
const onProgress = (m) => (0, output_1.err)(`[verikun] ${m}`);
|
|
531
|
+
const asJson = (0, args_1.flagBool)(flags, 'json');
|
|
532
|
+
if (verb === 'stop') {
|
|
533
|
+
const r = await lc.shutdown(chosen, { timeoutMs, onProgress });
|
|
534
|
+
if (asJson) {
|
|
535
|
+
(0, output_1.json)({ action: 'stop', requested: rawTarget, platform: chosen.platform, kind: chosen.kind, serial: r.serial, name: chosen.name, stopped: r.stopped });
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
(0, output_1.err)(r.stopped ? `stopped ${(0, lifecycle_1.targetLabel)(chosen)}` : `${(0, lifecycle_1.targetLabel)(chosen)} was not running`);
|
|
539
|
+
(0, output_1.out)(r.serial);
|
|
540
|
+
}
|
|
541
|
+
return 0;
|
|
542
|
+
}
|
|
543
|
+
const opts = { timeoutMs, wait, wipe, onProgress };
|
|
544
|
+
const r = verb === 'restart'
|
|
545
|
+
? await (0, lifecycle_1.restartTarget)(lc, chosen, opts)
|
|
546
|
+
: { ...(await lc.boot(chosen, opts)), stopped: false };
|
|
547
|
+
if (asJson) {
|
|
548
|
+
(0, output_1.json)({
|
|
549
|
+
action: verb,
|
|
550
|
+
requested: rawTarget,
|
|
551
|
+
platform: chosen.platform,
|
|
552
|
+
kind: chosen.kind,
|
|
553
|
+
serial: r.serial,
|
|
554
|
+
name: chosen.name,
|
|
555
|
+
started: r.started,
|
|
556
|
+
ready: r.ready,
|
|
557
|
+
waitedMs: r.waitedMs,
|
|
558
|
+
...(r.logPath ? { logPath: r.logPath } : {}),
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
else {
|
|
562
|
+
if (verb === 'start' && !r.started)
|
|
563
|
+
(0, output_1.err)(`${(0, lifecycle_1.targetLabel)(chosen)} is already running`);
|
|
564
|
+
// stdout is the one machine-usable datum: the serial, so
|
|
565
|
+
// `vk -d "$(vk devices start X)" ui` composes. Under --no-wait on Android there
|
|
566
|
+
// is genuinely no serial yet, so print nothing rather than invent one.
|
|
567
|
+
(0, output_1.out)(r.serial ?? '');
|
|
568
|
+
}
|
|
569
|
+
return 0;
|
|
570
|
+
}
|
|
429
571
|
/**
|
|
430
572
|
* Render the device list as an aligned, headed table (header line first, then one
|
|
431
|
-
* line per device). Optional columns (MODEL/PRODUCT/NOTE) are dropped when no
|
|
432
|
-
* populates them; every shown cell is padded to its column width so columns line up
|
|
573
|
+
* line per device). Optional columns (KIND/NAME/MODEL/PRODUCT/NOTE) are dropped when no
|
|
574
|
+
* device populates them; every shown cell is padded to its column width so columns line up
|
|
433
575
|
* regardless of which cells are empty — the previous `.filter(Boolean).join('\t')`
|
|
434
576
|
* dropped empty cells, sliding later cells into earlier tab stops. Exported for unit
|
|
435
577
|
* testing.
|
|
@@ -437,8 +579,10 @@ function cmdDevices(ctx) {
|
|
|
437
579
|
function formatDeviceTable(devices) {
|
|
438
580
|
const columns = [
|
|
439
581
|
{ header: 'PLATFORM', get: (d) => d.platform },
|
|
582
|
+
{ header: 'KIND', get: (d) => d.kind ?? '', optional: true },
|
|
440
583
|
{ header: 'SERIAL', get: (d) => d.serial },
|
|
441
584
|
{ header: 'STATE', get: (d) => d.state },
|
|
585
|
+
{ header: 'NAME', get: (d) => d.name ?? '', optional: true },
|
|
442
586
|
{ header: 'MODEL', get: (d) => d.model ?? '', optional: true },
|
|
443
587
|
{ header: 'PRODUCT', get: (d) => d.product ?? '', optional: true },
|
|
444
588
|
// Optional like the rest, which is load-bearing twice over: nothing changes for a
|
|
@@ -513,10 +657,38 @@ async function cmdDoctor(ctx) {
|
|
|
513
657
|
const c = claims ? (0, claims_1.summarize)(serial) : undefined;
|
|
514
658
|
return c && !c.mine ? c : undefined;
|
|
515
659
|
};
|
|
660
|
+
// Blank lines separate doctor's three blocks — toolchain, device inventory, target detail.
|
|
661
|
+
// Without them the target's indented lines read as a continuation of the last device row.
|
|
662
|
+
(0, output_1.out)('');
|
|
516
663
|
(0, output_1.out)(`devices: ${devices.length} attached, ${usable.length} usable`);
|
|
517
|
-
|
|
664
|
+
// Probe each device, not just the one an interaction command would land on: "is there a
|
|
665
|
+
// screen lock" and "is this prepared" are facts about a PHONE, and a host driving three of
|
|
666
|
+
// them cannot act on an answer that does not say which. The lock read is one `dumpsys` per
|
|
667
|
+
// device and the prep read is a local file, which is affordable in an explicit setup check.
|
|
668
|
+
const locks = new Map();
|
|
669
|
+
const rows = devices.map((d) => {
|
|
670
|
+
const facts = [];
|
|
671
|
+
if (d.state === 'device') {
|
|
672
|
+
facts.push((0, prep_1.readPrep)(d.serial) ? 'prepared' : 'not prepared');
|
|
673
|
+
const lock = (0, adb_1.lockKindOf)(d.serial);
|
|
674
|
+
locks.set(d.serial, lock);
|
|
675
|
+
if (lock !== 'none' && lock !== 'unknown')
|
|
676
|
+
facts.push(`screen lock: ${lock}`);
|
|
677
|
+
}
|
|
518
678
|
const claim = claims ? (0, claims_1.summarize)(d.serial) : undefined;
|
|
519
|
-
|
|
679
|
+
return {
|
|
680
|
+
serial: d.serial,
|
|
681
|
+
what: `${d.state}${d.model ? ` (${d.model})` : ''}`,
|
|
682
|
+
facts: facts.join(' · '),
|
|
683
|
+
claim: claim ? `[${claim.by}]` : '',
|
|
684
|
+
};
|
|
685
|
+
});
|
|
686
|
+
// Pad each column to its widest cell so the per-device facts line up and can be scanned
|
|
687
|
+
// down; trimEnd so a device with no facts and no claim leaves no trailing whitespace.
|
|
688
|
+
const w = (pick) => Math.max(...rows.map((r) => pick(r).length), 0);
|
|
689
|
+
const [wSerial, wWhat, wFacts] = [w((r) => r.serial), w((r) => r.what), w((r) => r.facts)];
|
|
690
|
+
for (const r of rows) {
|
|
691
|
+
(0, output_1.out)(` ${r.serial.padEnd(wSerial)} ${r.what.padEnd(wWhat)} ${r.facts.padEnd(wFacts)} ${r.claim}`.trimEnd());
|
|
520
692
|
}
|
|
521
693
|
let ok = true;
|
|
522
694
|
const free = usable.filter((d) => !heldByOther(d.serial));
|
|
@@ -538,25 +710,57 @@ async function cmdDoctor(ctx) {
|
|
|
538
710
|
// the first FREE one (which is what auto-selection picks), not merely the first attached.
|
|
539
711
|
const target = ctx.device || free[0]?.serial;
|
|
540
712
|
if (target) {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
const
|
|
547
|
-
|
|
548
|
-
if ((0, args_1.flagBool)(ctx.flags, 'fix') && !off) {
|
|
549
|
-
for (const k of keys)
|
|
550
|
-
(0, exec_1.runText)(adb, ['-s', serial, 'shell', 'settings', 'put', 'global', k, '0']);
|
|
551
|
-
(0, output_1.out)('animations: disabled (good)');
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
catch {
|
|
555
|
-
(0, output_1.err)('animations: could not read device settings');
|
|
713
|
+
reportTargetStatus(target, adb, locks.get(target) ?? 'unknown');
|
|
714
|
+
// `--fix` is now an alias for `device prep`, so it inherits prep's gate: on a physical
|
|
715
|
+
// device it refuses unless the serial was named. That refusal is the point — the old
|
|
716
|
+
// `--fix` wrote permanently to whichever phone happened to be unclaimed, with no undo.
|
|
717
|
+
if ((0, args_1.flagBool)(ctx.flags, 'fix')) {
|
|
718
|
+
const prepFlags = (0, args_1.flagBool)(ctx.flags, 'no-sleep-when-idle') ? { 'no-sleep-when-idle': true } : {};
|
|
719
|
+
return devicePrep({ ...ctx, flags: prepFlags });
|
|
556
720
|
}
|
|
557
721
|
}
|
|
558
722
|
return ok ? 0 : 3;
|
|
559
723
|
}
|
|
724
|
+
/**
|
|
725
|
+
* The extra detail for the ONE device an interaction command would land on. Every line names
|
|
726
|
+
* that serial, because the listing above may have shown several and an advisory you cannot
|
|
727
|
+
* attribute to a phone is not actionable.
|
|
728
|
+
*
|
|
729
|
+
* Reads NOTHING through `ctx.driver`, deliberately — resolving a driver claims the device, and
|
|
730
|
+
* doctor surveys the host without taking anything (see the note at the device listing above).
|
|
731
|
+
* The prep record is a plain file read; the animation probe goes through `adb -s <serial>`
|
|
732
|
+
* directly. `lock` is passed in rather than re-probed, so the listing's read is the only one.
|
|
733
|
+
*/
|
|
734
|
+
function reportTargetStatus(serial, adb, lock) {
|
|
735
|
+
(0, output_1.out)('');
|
|
736
|
+
(0, output_1.out)(`target: ${serial} — the device an interaction command would use`);
|
|
737
|
+
const rec = (0, prep_1.readPrep)(serial);
|
|
738
|
+
const when = rec?.preparedAt ? ` (${rec.preparedAt.slice(0, 10)})` : '';
|
|
739
|
+
(0, output_1.out)(rec
|
|
740
|
+
? ` prepared${when} — undo with \`verikun device prep --revert --device ${serial}\``
|
|
741
|
+
: ` not prepared — run \`verikun device prep --device ${serial}\``);
|
|
742
|
+
// Animations are still called out by name rather than folded into "not prepared": they are
|
|
743
|
+
// the single most common cause of a flaky dump, and a device can be prepped and then drift.
|
|
744
|
+
try {
|
|
745
|
+
const vals = ['window_animation_scale', 'transition_animation_scale', 'animator_duration_scale'].map((k) => (0, exec_1.runText)(adb, ['-s', serial, 'shell', 'settings', 'get', 'global', k]).stdout.trim());
|
|
746
|
+
const off = vals.every((v) => Number(v) === 0);
|
|
747
|
+
(0, output_1.out)(` animations: ${vals.join('/')} ${off ? '(off, good)' : '(ON — flaky dumps; run `verikun device prep`)'}`);
|
|
748
|
+
}
|
|
749
|
+
catch {
|
|
750
|
+
(0, output_1.err)(` animations: could not read settings on ${serial}`);
|
|
751
|
+
}
|
|
752
|
+
// Advisory, never a failure: a screen lock is a property of someone's phone, not a broken
|
|
753
|
+
// machine. It is worth saying because of what a slept device actually does — the read
|
|
754
|
+
// SUCCEEDS and returns the keyguard, so selectors miss for a reason unrelated to the app.
|
|
755
|
+
if (lock !== 'none' && lock !== 'unknown') {
|
|
756
|
+
// Indented to sit with the target's other lines, but it still NAMES the serial: this goes
|
|
757
|
+
// to stderr (it is a diagnostic, not data), so under `vk doctor > out.txt` it appears on
|
|
758
|
+
// its own with no `target:` header above it to say which phone it means.
|
|
759
|
+
(0, output_1.err)(` screen lock on ${serial} (${lock}) — while it is up, a read returns the lock screen ` +
|
|
760
|
+
'rather than the app.\n' +
|
|
761
|
+
' Remove it on a test device (Settings > Security). verikun never asks for a PIN.');
|
|
762
|
+
}
|
|
763
|
+
}
|
|
560
764
|
function cmdUi(ctx) {
|
|
561
765
|
const els = ctx.driver.getElements({ all: (0, args_1.flagBool)(ctx.flags, 'all') });
|
|
562
766
|
if ((0, args_1.flagBool)(ctx.flags, 'json')) {
|
|
@@ -650,13 +854,13 @@ async function cmdText(ctx) {
|
|
|
650
854
|
});
|
|
651
855
|
ctx.driver.tap(point.x, point.y);
|
|
652
856
|
// Wait for field to be focused after tap
|
|
653
|
-
await sleep(100);
|
|
857
|
+
await (0, wait_1.sleep)(100);
|
|
654
858
|
if ((0, args_1.flagBool)(ctx.flags, 'clear') && target.text) {
|
|
655
859
|
ctx.driver.pressKey('move_end');
|
|
656
860
|
for (let i = 0; i < target.text.length + 2; i++)
|
|
657
861
|
ctx.driver.pressKey('del');
|
|
658
862
|
// Wait for field to settle after clearing before typing
|
|
659
|
-
await sleep(200);
|
|
863
|
+
await (0, wait_1.sleep)(200);
|
|
660
864
|
}
|
|
661
865
|
// Prime the input method with a space, then delete it, to avoid losing first character
|
|
662
866
|
// (workaround for adb input text behavior where first char is sometimes lost)
|
|
@@ -887,7 +1091,7 @@ async function cmdWait(ctx) {
|
|
|
887
1091
|
(0, output_1.out)((0, format_1.formatCompact)(matches));
|
|
888
1092
|
return 0;
|
|
889
1093
|
}
|
|
890
|
-
await sleep(interval);
|
|
1094
|
+
await (0, wait_1.sleep)(interval);
|
|
891
1095
|
}
|
|
892
1096
|
ctx.record?.note({ selector: sel, message: `timeout after ${timeout}ms${gone ? ' (still present)' : ' (never appeared)'}` });
|
|
893
1097
|
(0, output_1.err)(`timeout after ${timeout}ms waiting for '${sel.raw}'${gone ? ' to disappear' : ''}`);
|
|
@@ -938,7 +1142,7 @@ async function cmdAssert(ctx) {
|
|
|
938
1142
|
const deadline = Date.now() + waitWindowMs(ctx.flags);
|
|
939
1143
|
let result = evalAssert(readForPoll(ctx), sel, ctx.flags);
|
|
940
1144
|
while (!result.pass && Date.now() < deadline) {
|
|
941
|
-
await sleep(pollStep(ctx.flags, deadline));
|
|
1145
|
+
await (0, wait_1.sleep)(pollStep(ctx.flags, deadline));
|
|
942
1146
|
result = evalAssert(readForPoll(ctx), sel, ctx.flags);
|
|
943
1147
|
}
|
|
944
1148
|
const { pass, reason, matches } = result;
|
|
@@ -1015,7 +1219,8 @@ function cmdClear(ctx) {
|
|
|
1015
1219
|
// of applying dark mode and then dying — a half-applied device is worse than none.
|
|
1016
1220
|
// - SNAPSHOT FIRST. The pre-change value is persisted before the change is made, so
|
|
1017
1221
|
// `device reset` can undo it even from a later process.
|
|
1018
|
-
const DEVICE_USAGE = 'Usage: verikun device set <key>=<value> [<key>=<value> ...] | device get [key] | device reset [key ...] |
|
|
1222
|
+
const DEVICE_USAGE = 'Usage: verikun device set <key>=<value> [<key>=<value> ...] | device get [key] | device reset [key ...] | ' +
|
|
1223
|
+
'device prep [--revert] [--dry-run] | device caps | device release [serial]\n' +
|
|
1019
1224
|
`Keys: ${settings_1.SETTING_KEYS.join(', ')}`;
|
|
1020
1225
|
function cmdDevice(ctx) {
|
|
1021
1226
|
const sub = (ctx.positionals[0] ?? '').toLowerCase();
|
|
@@ -1028,6 +1233,8 @@ function cmdDevice(ctx) {
|
|
|
1028
1233
|
return deviceGet(ctx, rest);
|
|
1029
1234
|
case 'reset':
|
|
1030
1235
|
return deviceReset(ctx, rest);
|
|
1236
|
+
case 'prep':
|
|
1237
|
+
return devicePrep(ctx);
|
|
1031
1238
|
case 'caps':
|
|
1032
1239
|
return deviceCaps(ctx);
|
|
1033
1240
|
case 'release':
|
|
@@ -1168,6 +1375,143 @@ function deviceReset(ctx, args) {
|
|
|
1168
1375
|
(0, output_1.out)(summary ? `device reset ${summary}` : 'device reset: nothing to restore');
|
|
1169
1376
|
return 0;
|
|
1170
1377
|
}
|
|
1378
|
+
/**
|
|
1379
|
+
* `vk device prep` — establish the test-device knob set, stickily.
|
|
1380
|
+
*
|
|
1381
|
+
* The whole point is that this OUTLIVES the run, so unlike `deviceSet` the snapshot does not
|
|
1382
|
+
* go into the run file (which `ai`/`suite`/`batch` auto-restore from a `finally`) but into the
|
|
1383
|
+
* host-global prep store. See device/prep.ts for why the two stores exist.
|
|
1384
|
+
*
|
|
1385
|
+
* Unsupported and no-op knobs are SKIPPED with a note rather than failing the command, which
|
|
1386
|
+
* is the difference between prep and `device set`: `set` was told which key to change and must
|
|
1387
|
+
* refuse if it cannot, while prep means "establish what this platform can" — so on iOS, where
|
|
1388
|
+
* every knob is a noop or unsupported, it honestly reports doing nothing instead of erroring.
|
|
1389
|
+
*/
|
|
1390
|
+
function devicePrep(ctx) {
|
|
1391
|
+
const asJson = (0, args_1.flagBool)(ctx.flags, 'json');
|
|
1392
|
+
const dryRun = (0, args_1.flagBool)(ctx.flags, 'dry-run');
|
|
1393
|
+
const serial = ctx.driver.resolvedSerial();
|
|
1394
|
+
if ((0, args_1.flagBool)(ctx.flags, 'revert'))
|
|
1395
|
+
return devicePrepRevert(ctx, serial, dryRun, asJson);
|
|
1396
|
+
// Partition by what this platform can actually do, so the gate below counts real writes.
|
|
1397
|
+
const skipped = [];
|
|
1398
|
+
const applicable = [];
|
|
1399
|
+
for (const knob of prep_1.PREP_KNOBS) {
|
|
1400
|
+
const spec = settings_1.SETTINGS[knob.key];
|
|
1401
|
+
const support = spec.support[ctx.platform];
|
|
1402
|
+
if (support === 'unsupported') {
|
|
1403
|
+
skipped.push({ key: knob.key, reason: spec.manual[ctx.platform] ?? `not supported on ${ctx.platform}` });
|
|
1404
|
+
continue;
|
|
1405
|
+
}
|
|
1406
|
+
if (support === 'noop') {
|
|
1407
|
+
skipped.push({ key: knob.key, reason: spec.note[ctx.platform] ?? 'already satisfied on this platform' });
|
|
1408
|
+
continue;
|
|
1409
|
+
}
|
|
1410
|
+
// parse() so the target compares equal to a readback (`max` -> the millisecond string).
|
|
1411
|
+
applicable.push({
|
|
1412
|
+
key: knob.key,
|
|
1413
|
+
target: spec.parse(knob.value),
|
|
1414
|
+
current: ctx.driver.getDeviceSetting(knob.key),
|
|
1415
|
+
why: knob.why,
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
const changes = applicable.filter((k) => k.current !== k.target);
|
|
1419
|
+
// `--dry-run` is deliberately NOT gated: it writes nothing, and refusing it would mean you
|
|
1420
|
+
// could not find out what prep would do to a phone without first asserting it is a test one.
|
|
1421
|
+
if (dryRun) {
|
|
1422
|
+
if (asJson) {
|
|
1423
|
+
(0, output_1.json)({ serial, prepared: (0, prep_1.isPrepared)(serial), dryRun: true, changes, skipped });
|
|
1424
|
+
}
|
|
1425
|
+
else {
|
|
1426
|
+
(0, output_1.out)(`device prep ${serial} (dry run — nothing written)`);
|
|
1427
|
+
for (const k of applicable) {
|
|
1428
|
+
const state = k.current === k.target ? 'already' : `${showValue(k.current)} -> ${k.target}`;
|
|
1429
|
+
(0, output_1.out)(` ${k.key.padEnd(15)} ${state}`);
|
|
1430
|
+
if (k.current !== k.target)
|
|
1431
|
+
(0, output_1.out)(` ${' '.repeat(15)} ${k.why}`);
|
|
1432
|
+
}
|
|
1433
|
+
for (const s of skipped)
|
|
1434
|
+
(0, output_1.out)(` ${s.key.padEnd(15)} skipped: ${s.reason.replace(/\n/g, ' ')}`);
|
|
1435
|
+
}
|
|
1436
|
+
return 0;
|
|
1437
|
+
}
|
|
1438
|
+
// The gate, immediately before the first write. `!!ctx.device` is true for --device AND
|
|
1439
|
+
// VERIKUN_DEVICE: both are a deliberate act naming one phone, which is the property asked for.
|
|
1440
|
+
const kind = ctx.driver.listDevices().find((d) => d.serial === serial)?.kind ?? 'physical';
|
|
1441
|
+
(0, prep_1.assertPreppable)(kind, serial, !!ctx.device, applicable.length);
|
|
1442
|
+
const original = {};
|
|
1443
|
+
for (const k of applicable) {
|
|
1444
|
+
// Snapshot even a knob already at target: `--revert` has to put back what the device
|
|
1445
|
+
// held before prep, and "it was already off" is exactly as much a fact as a change.
|
|
1446
|
+
if (k.current !== null)
|
|
1447
|
+
original[k.key] = k.current;
|
|
1448
|
+
if (k.current !== k.target)
|
|
1449
|
+
ctx.driver.setDeviceSetting(k.key, k.target);
|
|
1450
|
+
}
|
|
1451
|
+
// Earliest wins across re-preps, or `--revert` would restore the device to prepped.
|
|
1452
|
+
const prior = (0, prep_1.readPrep)(serial);
|
|
1453
|
+
const sleepWhenIdle = !(0, args_1.flagBool)(ctx.flags, 'no-sleep-when-idle');
|
|
1454
|
+
(0, prep_1.writePrep)((0, prep_1.newPrepRecord)(serial, ctx.platform, (0, prep_1.mergeOriginals)(prior?.original ?? {}, original), sleepWhenIdle));
|
|
1455
|
+
const applied = Object.fromEntries(applicable.map((k) => [k.key, k.target]));
|
|
1456
|
+
ctx.record?.note({ message: `device prep ${serial} (${changes.length} changed)` });
|
|
1457
|
+
if (asJson) {
|
|
1458
|
+
(0, output_1.json)({ serial, applied, changed: changes.map((c) => c.key), skipped, sleepWhenIdle });
|
|
1459
|
+
}
|
|
1460
|
+
else {
|
|
1461
|
+
(0, output_1.out)(`device prep ${serial}: ${changes.length ? changes.map((c) => `${c.key}=${c.target}`).join(' ') : 'already prepared'}`);
|
|
1462
|
+
for (const s of skipped)
|
|
1463
|
+
(0, output_1.err)(`note: ${s.key} skipped — ${s.reason.replace(/\n/g, ' ')}`);
|
|
1464
|
+
if (sleepWhenIdle)
|
|
1465
|
+
(0, output_1.err)('note: this device will be put to sleep when a run using it finishes');
|
|
1466
|
+
(0, output_1.err)(`undo with: verikun device prep --revert --device ${serial}`);
|
|
1467
|
+
}
|
|
1468
|
+
return 0;
|
|
1469
|
+
}
|
|
1470
|
+
/** Put a prepared device back the way it was found, and forget it. */
|
|
1471
|
+
function devicePrepRevert(ctx, serial, dryRun, asJson) {
|
|
1472
|
+
const rec = (0, prep_1.readPrep)(serial);
|
|
1473
|
+
if (!rec) {
|
|
1474
|
+
if (asJson)
|
|
1475
|
+
(0, output_1.json)({ serial, prepared: false, restored: {} });
|
|
1476
|
+
else
|
|
1477
|
+
(0, output_1.out)(`device prep --revert ${serial}: not prepared, nothing to restore`);
|
|
1478
|
+
return 0;
|
|
1479
|
+
}
|
|
1480
|
+
const entries = Object.entries(rec.original);
|
|
1481
|
+
if (dryRun) {
|
|
1482
|
+
if (asJson)
|
|
1483
|
+
(0, output_1.json)({ serial, prepared: true, dryRun: true, wouldRestore: rec.original });
|
|
1484
|
+
else {
|
|
1485
|
+
(0, output_1.out)(`device prep --revert ${serial} (dry run — nothing written)`);
|
|
1486
|
+
for (const [key, value] of entries)
|
|
1487
|
+
(0, output_1.out)(` ${key.padEnd(15)} ${showValue(ctx.driver.getDeviceSetting(key))} -> ${value}`);
|
|
1488
|
+
}
|
|
1489
|
+
return 0;
|
|
1490
|
+
}
|
|
1491
|
+
const restored = {};
|
|
1492
|
+
const failed = [];
|
|
1493
|
+
for (const [key, value] of entries) {
|
|
1494
|
+
try {
|
|
1495
|
+
ctx.driver.setDeviceSetting(key, value);
|
|
1496
|
+
restored[key] = value;
|
|
1497
|
+
}
|
|
1498
|
+
catch (e) {
|
|
1499
|
+
// Best-effort, like `device reset`: one stubborn knob must not strand the rest.
|
|
1500
|
+
failed.push(`${key} (${e instanceof Error ? e.message.split('\n')[0] : String(e)})`);
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
// Forget the device even if a knob refused. Keeping the record would leave `vk doctor`
|
|
1504
|
+
// reporting it as prepped forever, and the values that DID go back are already back.
|
|
1505
|
+
(0, prep_1.clearPrep)(serial);
|
|
1506
|
+
ctx.record?.note({ message: `device prep --revert ${serial}` });
|
|
1507
|
+
if (failed.length)
|
|
1508
|
+
(0, output_1.err)(`warning: could not restore ${failed.join(', ')}`);
|
|
1509
|
+
if (asJson)
|
|
1510
|
+
(0, output_1.json)({ serial, restored, ...(failed.length ? { failed } : {}) });
|
|
1511
|
+
else
|
|
1512
|
+
(0, output_1.out)(`device prep --revert ${serial}: ${Object.entries(restored).map(([k, v]) => `${k}=${v}`).join(' ') || 'nothing to restore'}`);
|
|
1513
|
+
return 0;
|
|
1514
|
+
}
|
|
1171
1515
|
function deviceCaps(ctx) {
|
|
1172
1516
|
const rows = settings_1.SETTING_KEYS.map((key) => {
|
|
1173
1517
|
const spec = settings_1.SETTINGS[key];
|
|
@@ -1212,6 +1556,35 @@ function requireSettingKey(v) {
|
|
|
1212
1556
|
* same code. (Remote is a known gap: the overrides live in the *server's* run file, so
|
|
1213
1557
|
* a locally-empty snapshot means this correctly skips — see the issue's Out of scope.)
|
|
1214
1558
|
*/
|
|
1559
|
+
/**
|
|
1560
|
+
* Park prepared devices when the flow that used them ends — #97's "in sleep mode when they're
|
|
1561
|
+
* not [in use]".
|
|
1562
|
+
*
|
|
1563
|
+
* Three properties worth keeping:
|
|
1564
|
+
*
|
|
1565
|
+
* * It can only ever touch a device you explicitly PREPPED, and only if that prep did not
|
|
1566
|
+
* pass `--no-sleep-when-idle`. A borrowed phone that was never prepped is never slept.
|
|
1567
|
+
* * It goes through the driver rather than the command dispatcher, so it does not appear as
|
|
1568
|
+
* a `key sleep` testcase in the report. Parking is host hygiene, not a test step.
|
|
1569
|
+
* * `ownClaimedSerials()` is empty in `--server` mode (the local process never resolves a
|
|
1570
|
+
* device), so the remote path degrades to doing nothing on its own, with no branch here.
|
|
1571
|
+
* Managing a remote device's power is `--allow-device-control`'s job.
|
|
1572
|
+
*
|
|
1573
|
+
* Must run BEFORE `releaseOwnClaims()`, which is what it reads its serials from.
|
|
1574
|
+
*/
|
|
1575
|
+
function parkPreparedDevices(platform) {
|
|
1576
|
+
for (const serial of (0, claims_1.ownClaimedSerials)()) {
|
|
1577
|
+
if (!(0, prep_1.readPrep)(serial)?.sleepWhenIdle)
|
|
1578
|
+
continue;
|
|
1579
|
+
try {
|
|
1580
|
+
(0, output_1.err)(`[verikun] parking prepared device ${serial} (sleep)`);
|
|
1581
|
+
(0, drivers_1.getDriver)(platform, serial).pressKey('sleep');
|
|
1582
|
+
}
|
|
1583
|
+
catch {
|
|
1584
|
+
/* teardown must never throw — the device may be exactly why we are unwinding */
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1215
1588
|
async function restoreDeviceOverrides(backend) {
|
|
1216
1589
|
if (!run_1.Recorder.hasDeviceOverrides())
|
|
1217
1590
|
return;
|
|
@@ -1492,6 +1865,7 @@ async function cmdBatch(positionals, batchFlags) {
|
|
|
1492
1865
|
/* the device may be exactly why we are unwinding — never mask the real error */
|
|
1493
1866
|
}
|
|
1494
1867
|
}
|
|
1868
|
+
parkPreparedDevices(platformFromFlags(batchFlags));
|
|
1495
1869
|
(0, claims_1.releaseOwnClaims)();
|
|
1496
1870
|
}
|
|
1497
1871
|
}
|
|
@@ -1550,8 +1924,9 @@ function providerRequirement(model) {
|
|
|
1550
1924
|
return id === 'openai' ? 'OPENAI_API_KEY is not set' : 'ANTHROPIC_API_KEY is not set';
|
|
1551
1925
|
}
|
|
1552
1926
|
/** Route the model to its backend. HTTP providers read their own key; a CLI provider shells
|
|
1553
|
-
* out to its logged-in binary. Unavailable → null (compile/repair off)
|
|
1554
|
-
*
|
|
1927
|
+
* out to its logged-in binary. Unavailable → null (compile/repair off). That is NOT a free
|
|
1928
|
+
* replay path: runAiTest turns a null provider into exit 3 even at a 100% cache hit, because
|
|
1929
|
+
* it must be able to repair a drifted step at runtime — only --show-plan degrades gracefully.
|
|
1555
1930
|
* CLI providers get no `model`, so the CLI picks its own default — the "I just have a
|
|
1556
1931
|
* subscription" path; --effort is likewise inapplicable to them. */
|
|
1557
1932
|
function makeProvider(opts) {
|
|
@@ -1624,9 +1999,136 @@ async function obtainPlan(key, file, opts, cost, provider) {
|
|
|
1624
1999
|
}
|
|
1625
2000
|
return { plan: compiled.plan, cached: false };
|
|
1626
2001
|
}
|
|
2002
|
+
/** The `--server` URL, or VERIKUN_SERVER. Exported-shape helper so `resolveBackend`
|
|
2003
|
+
* and `vk devices --server` can never disagree about what "remote" means. */
|
|
2004
|
+
function serverFromFlags(flags) {
|
|
2005
|
+
return (0, args_1.flagStr)(flags, 'server') || process.env.VERIKUN_SERVER || undefined;
|
|
2006
|
+
}
|
|
2007
|
+
function remoteOptsFrom(url, flags) {
|
|
2008
|
+
return { url, authKey: (0, args_1.flagStr)(flags, 'auth-key') || process.env.VERIKUN_SERVER_AUTH_KEY || undefined };
|
|
2009
|
+
}
|
|
2010
|
+
/**
|
|
2011
|
+
* `--ensure-device[=<target>]` — the requested target, or `undefined` for the bare
|
|
2012
|
+
* form, or `null` when the flag is absent. Exported for unit tests.
|
|
2013
|
+
*/
|
|
2014
|
+
function ensureDeviceTarget(flags) {
|
|
2015
|
+
const raw = flags['ensure-device'];
|
|
2016
|
+
if (raw === undefined || raw === false)
|
|
2017
|
+
return null;
|
|
2018
|
+
if (raw === true || raw === 'true')
|
|
2019
|
+
return undefined;
|
|
2020
|
+
return String(raw);
|
|
2021
|
+
}
|
|
2022
|
+
/** The one target that is startable without guessing, else exit 2 with the options. */
|
|
2023
|
+
function soleStartable(targets) {
|
|
2024
|
+
const startable = targets.filter((t) => t.kind !== 'physical' && !(0, lifecycle_1.isRunning)(t));
|
|
2025
|
+
if (startable.length === 1)
|
|
2026
|
+
return startable[0];
|
|
2027
|
+
const names = [...new Set(startable.map(lifecycle_1.targetLabel).filter(Boolean))];
|
|
2028
|
+
throw new errors_1.CliError(startable.length === 0
|
|
2029
|
+
? '--ensure-device: no startable device found. Run `vk devices --all` to check the toolchain.'
|
|
2030
|
+
: `--ensure-device: ${startable.length} devices could be started — name one, ` +
|
|
2031
|
+
`e.g. --ensure-device="${names[0]}"\n ${names.join('\n ')}`, 2);
|
|
2032
|
+
}
|
|
2033
|
+
/**
|
|
2034
|
+
* Boot a device before the first step, when `--ensure-device` asks for it. Runs here
|
|
2035
|
+
* — inside resolveBackend, before runCtx is fixed — rather than inside `vk suite`,
|
|
2036
|
+
* so the booted serial is the one every recorded step is attributed to. It is
|
|
2037
|
+
* structurally unable to turn a red test green: it happens before any step executes,
|
|
2038
|
+
* never between tests and never mid-run.
|
|
2039
|
+
*/
|
|
2040
|
+
async function ensureLocalDevice(platform, device, flags) {
|
|
2041
|
+
const want = ensureDeviceTarget(flags);
|
|
2042
|
+
if (want === null)
|
|
2043
|
+
return;
|
|
2044
|
+
try {
|
|
2045
|
+
const serial = (0, drivers_1.getDriver)(platform, device).resolvedSerial();
|
|
2046
|
+
// `--ensure-device=X` means "boot X if nothing is usable", not "make X the
|
|
2047
|
+
// device" — so say so plainly when a different device is already serving,
|
|
2048
|
+
// rather than reporting a serial the caller didn't ask for.
|
|
2049
|
+
(0, output_1.err)(`[verikun] --ensure-device: ${serial} is already available${want ? ` — not booting '${want}'` : ''}`);
|
|
2050
|
+
return;
|
|
2051
|
+
}
|
|
2052
|
+
catch (e) {
|
|
2053
|
+
// Ambiguity (exit 2) is an operator error — booting another device makes it worse.
|
|
2054
|
+
if (!(e instanceof errors_1.CliError) || e.exitCode !== 3)
|
|
2055
|
+
throw e;
|
|
2056
|
+
}
|
|
2057
|
+
const lc = (0, lifecycle_1.lifecycleFor)(platform);
|
|
2058
|
+
const targets = lc.targets();
|
|
2059
|
+
const chosen = want ? (0, lifecycle_1.chooseTarget)(targets, want, { prefer: 'startable' }) : soleStartable(targets);
|
|
2060
|
+
(0, lifecycle_1.assertActionable)(chosen, 'start');
|
|
2061
|
+
(0, output_1.err)(`[verikun] --ensure-device: booting ${(0, lifecycle_1.targetLabel)(chosen)}…`);
|
|
2062
|
+
await lc.boot(chosen, {
|
|
2063
|
+
timeoutMs: wait_1.DEFAULT_BOOT_TIMEOUT_MS,
|
|
2064
|
+
wait: true,
|
|
2065
|
+
wipe: false,
|
|
2066
|
+
onProgress: (m) => (0, output_1.err)(`[verikun] ${m}`),
|
|
2067
|
+
});
|
|
2068
|
+
}
|
|
2069
|
+
/** The remote half of `--ensure-device`: ask the server to boot, then re-ping so the
|
|
2070
|
+
* run context carries the serial the device actually came up as. */
|
|
2071
|
+
async function ensureRemoteDevice(health, opts, url, flags) {
|
|
2072
|
+
const want = ensureDeviceTarget(flags);
|
|
2073
|
+
if (want === null)
|
|
2074
|
+
return health;
|
|
2075
|
+
if (health.serial) {
|
|
2076
|
+
(0, output_1.err)(`[verikun] --ensure-device: ${health.serial} is already bound on ${url}${want ? ` — not booting '${want}'` : ''}`);
|
|
2077
|
+
return health;
|
|
2078
|
+
}
|
|
2079
|
+
assertDeviceControl(health, url, want);
|
|
2080
|
+
(0, output_1.err)(`[verikun] --ensure-device: asking ${url} to boot a device…`);
|
|
2081
|
+
await (0, remote_1.remoteDeviceOp)(opts, 'start', want ? { target: want } : {});
|
|
2082
|
+
return (0, remote_1.pingServer)(opts);
|
|
2083
|
+
}
|
|
2084
|
+
/** Feature-detect device control from /v1/health. Never compare versions: a client
|
|
2085
|
+
* cannot tell "old server" from "new server, flag off", and both need this answer. */
|
|
2086
|
+
function assertDeviceControl(health, url, target) {
|
|
2087
|
+
if (!health.deviceControlEnabled) {
|
|
2088
|
+
throw new errors_1.CliError(`device control is not available on the verikun server at ${url} (verikun ${health.version}). ` +
|
|
2089
|
+
'Restart it with --allow-device-control.', 3);
|
|
2090
|
+
}
|
|
2091
|
+
if (target && !health.deviceNamingEnabled) {
|
|
2092
|
+
throw new errors_1.CliError(`the server at ${url} was started with a bare --allow-device-control — it can only act on its own ` +
|
|
2093
|
+
`device, not '${target}'. Restart it with --allow-device-control=<names>.`, 2);
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
async function cmdDevicesRemote(verb, target, url, flags) {
|
|
2097
|
+
const opts = remoteOptsFrom(url, flags);
|
|
2098
|
+
const health = await (0, remote_1.pingServer)(opts); // fails fast (exit 3) on a bad URL or key
|
|
2099
|
+
assertDeviceControl(health, url, target);
|
|
2100
|
+
const wipe = (0, args_1.flagBool)(flags, 'wipe');
|
|
2101
|
+
(0, output_1.err)(`[verikun] ${verb} device on ${url}${target ? ` (${target})` : ''}${wipe ? ' with WIPE' : ''}…`);
|
|
2102
|
+
const r = await (0, remote_1.remoteDeviceOp)(opts, verb, { ...(target ? { target } : {}), ...(wipe ? { wipe } : {}) });
|
|
2103
|
+
(0, output_1.err)(`[verikun] ${r.changed ? `${verb} ok` : 'already running'} · ${r.serial ?? '(none)'} · ` +
|
|
2104
|
+
`${(r.durationMs / 1000).toFixed(1)}s`);
|
|
2105
|
+
if ((0, args_1.flagBool)(flags, 'json'))
|
|
2106
|
+
(0, output_1.json)({ action: verb, server: url, ...r });
|
|
2107
|
+
else
|
|
2108
|
+
(0, output_1.out)(r.serial ?? '');
|
|
2109
|
+
return 0;
|
|
2110
|
+
}
|
|
2111
|
+
async function cmdDevicesRemoteList(url, flags) {
|
|
2112
|
+
const opts = remoteOptsFrom(url, flags);
|
|
2113
|
+
const health = await (0, remote_1.pingServer)(opts);
|
|
2114
|
+
assertDeviceControl(health, url);
|
|
2115
|
+
const r = await (0, remote_1.remoteDeviceList)(opts);
|
|
2116
|
+
if ((0, args_1.flagBool)(flags, 'json')) {
|
|
2117
|
+
(0, output_1.json)({ server: url, ...r });
|
|
2118
|
+
return 0;
|
|
2119
|
+
}
|
|
2120
|
+
if (r.devices.length)
|
|
2121
|
+
for (const line of formatDeviceTable(r.devices))
|
|
2122
|
+
(0, output_1.out)(line);
|
|
2123
|
+
else
|
|
2124
|
+
(0, output_1.err)('No devices visible on the server.');
|
|
2125
|
+
(0, output_1.err)(`[verikun] bound: ${r.bound ?? '(none)'}${r.startable.length ? ` · startable: ${r.startable.join(', ')}` : ''}`);
|
|
2126
|
+
return 0;
|
|
2127
|
+
}
|
|
1627
2128
|
async function resolveBackend(platform, device, flags) {
|
|
1628
|
-
const server = (
|
|
2129
|
+
const server = serverFromFlags(flags);
|
|
1629
2130
|
if (!server) {
|
|
2131
|
+
await ensureLocalDevice(platform, device, flags);
|
|
1630
2132
|
const driver = (0, drivers_1.getDriver)(platform, device);
|
|
1631
2133
|
// Fail fast on a broken toolchain BEFORE any model spend — the mirror of the
|
|
1632
2134
|
// remote branch's pingServer below. Without this, a missing `idb` isn't noticed
|
|
@@ -1680,9 +2182,13 @@ async function resolveBackend(platform, device, flags) {
|
|
|
1680
2182
|
// so archive-time / vk log scoping works without a local driver.
|
|
1681
2183
|
onStep: (step, artifacts, logStart) => run_1.Recorder.appendForeignStep(step, artifacts, { ...runCtx, logStart }),
|
|
1682
2184
|
};
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
2185
|
+
let health = await (0, remote_1.pingServer)(opts); // fails fast (exit 3) on a bad URL or key
|
|
2186
|
+
// `--ensure-device` boots BEFORE runCtx is fixed: resolveBackend bakes the serial
|
|
2187
|
+
// into the run context and the returned device, so booting afterwards would
|
|
2188
|
+
// attribute every spliced step to a device that didn't exist yet.
|
|
2189
|
+
health = await ensureRemoteDevice(health, opts, server, flags);
|
|
2190
|
+
runCtx = { platform: health.platform, device: health.serial ?? undefined };
|
|
2191
|
+
(0, output_1.err)(`[verikun] server ${server}: ${health.platform} · device ${health.serial ?? '(none)'} · verikun ${health.version}`);
|
|
1686
2192
|
// Say the read path once, here. Reads execute server-side, so this is the only end of the
|
|
1687
2193
|
// connection that knows it — and without it a companion that had silently stood down was
|
|
1688
2194
|
// indistinguishable from one that never engaged, for a whole suite (issue #77). An older
|
|
@@ -1715,7 +2221,7 @@ async function resolveBackend(platform, device, flags) {
|
|
|
1715
2221
|
},
|
|
1716
2222
|
},
|
|
1717
2223
|
platform: health.platform,
|
|
1718
|
-
device: health.serial,
|
|
2224
|
+
device: health.serial ?? undefined,
|
|
1719
2225
|
remote: { url: server, version: health.version, reads: health.reads },
|
|
1720
2226
|
};
|
|
1721
2227
|
}
|
|
@@ -1956,6 +2462,7 @@ async function cmdAi(positionals, flags) {
|
|
|
1956
2462
|
// otherwise an unattended run leaves the phone offline or in dark mode.
|
|
1957
2463
|
await restoreDeviceOverrides(backend);
|
|
1958
2464
|
await backend.close?.(); // frees a remote server's device lock for the next command
|
|
2465
|
+
parkPreparedDevices(platform); // a prepped device goes back to sleep between runs
|
|
1959
2466
|
(0, claims_1.releaseOwnClaims)(); // and the host-level claim, so the next job can have the device
|
|
1960
2467
|
}
|
|
1961
2468
|
if ((0, args_1.flagBool)(flags, 'json')) {
|
|
@@ -2044,6 +2551,7 @@ async function cmdSuiteEntry(positionals, flags) {
|
|
|
2044
2551
|
finally {
|
|
2045
2552
|
await restoreDeviceOverrides(backend);
|
|
2046
2553
|
await backend.close?.();
|
|
2554
|
+
parkPreparedDevices(platform);
|
|
2047
2555
|
(0, claims_1.releaseOwnClaims)();
|
|
2048
2556
|
}
|
|
2049
2557
|
}
|
|
@@ -2094,8 +2602,6 @@ function cmdCompanion(ctx) {
|
|
|
2094
2602
|
// ---------------------------------------------------------------------------
|
|
2095
2603
|
async function executeCommand(command, ctx) {
|
|
2096
2604
|
switch (command) {
|
|
2097
|
-
case 'devices':
|
|
2098
|
-
return cmdDevices(ctx);
|
|
2099
2605
|
case 'doctor':
|
|
2100
2606
|
return cmdDoctor(ctx);
|
|
2101
2607
|
case 'companion':
|
|
@@ -2280,6 +2786,19 @@ async function executeParsed(command, positionals, flags) {
|
|
|
2280
2786
|
return cmdRun(positionals, flags, platform, device);
|
|
2281
2787
|
if (command === 'batch')
|
|
2282
2788
|
return cmdBatch(positionals, flags);
|
|
2789
|
+
// `devices` is wholly a meta-command: it must not build a local driver. Lifecycle is
|
|
2790
|
+
// what you run when there ISN'T one, a `--server` call needs no local device at all,
|
|
2791
|
+
// and even the bare listing probes both backends itself rather than using ctx.driver.
|
|
2792
|
+
// Dispatched in exactly one place on purpose — a command split across the switch AND
|
|
2793
|
+
// this chain reads as two commands to anything parsing the dispatch (tests/docs-coverage).
|
|
2794
|
+
if (command === 'devices') {
|
|
2795
|
+
try {
|
|
2796
|
+
return await cmdDevicesEntry(positionals, flags);
|
|
2797
|
+
}
|
|
2798
|
+
catch (e) {
|
|
2799
|
+
return mapError(e, flags);
|
|
2800
|
+
}
|
|
2801
|
+
}
|
|
2283
2802
|
// `ai`/`suite`/`install`/`server` orchestrate their own steps; map their thrown
|
|
2284
2803
|
// CliErrors to exit codes here (usage 2 / env 3 / …) so they honor the exit-code
|
|
2285
2804
|
// contract instead of escaping to the top-level "Fatal" handler (exit 3).
|
|
@@ -2380,6 +2899,13 @@ DEVICE STATE (change the device the app runs on, then put it back)
|
|
|
2380
2899
|
device reset [key ...] Restore what this run changed. batch/ai/suite also
|
|
2381
2900
|
do this automatically when the flow ends OR fails,
|
|
2382
2901
|
so a dead test can't leave the phone offline.
|
|
2902
|
+
device prep [--dry-run] [--json] Prepare a TEST device once, stickily: animations off,
|
|
2903
|
+
display kept awake, Do Not Disturb on, battery idle off.
|
|
2904
|
+
Survives the run (unlike \`device set\`), so it is undone
|
|
2905
|
+
only by \`--revert\`. A PHYSICAL device must be named with
|
|
2906
|
+
--device — prep must never land on a personal phone.
|
|
2907
|
+
--no-sleep-when-idle keeps the screen on after a run.
|
|
2908
|
+
device prep --revert [--dry-run] Put a prepared device back the way it was found
|
|
2383
2909
|
device caps [--json] What this platform supports, and the manual
|
|
2384
2910
|
equivalent where it doesn't
|
|
2385
2911
|
Refuses \`airplane=on\` over wireless adb (it would cut
|
|
@@ -2435,6 +2961,7 @@ SUITE (run a directory of natural-language tests as one gated suite)
|
|
|
2435
2961
|
|
|
2436
2962
|
SERVER (expose a locally-connected device to remote verikun clients)
|
|
2437
2963
|
server [--bind addr] [--port n] [--auth-key k] [--allow-install]
|
|
2964
|
+
[--allow-device-control[=names]]
|
|
2438
2965
|
[--allow-unsafe-anonymous] Serve THIS machine's device over HTTP+JSON for
|
|
2439
2966
|
\`vk ai/suite/install --server <url>\`. Only
|
|
2440
2967
|
verikun's validated action grammar is runnable
|
|
@@ -2446,16 +2973,31 @@ SERVER (expose a locally-connected device to remote verikun clients)
|
|
|
2446
2973
|
Env: VERIKUN_SERVER_AUTH_KEY (keeps it off argv).
|
|
2447
2974
|
Clients: pass --server <url> (or VERIKUN_SERVER) + --auth-key (or
|
|
2448
2975
|
VERIKUN_SERVER_AUTH_KEY) to ai/suite/install. The server's device+platform apply.
|
|
2976
|
+
--allow-device-control lets a client restart/stop the server's OWN device (for a
|
|
2977
|
+
device gone flaky mid-suite); --allow-device-control=<avd,sim> additionally lets it
|
|
2978
|
+
START one of those named targets — and, with it, ERASE the device (--wipe). With
|
|
2979
|
+
the flag the server also starts even when no device is attached, so a client can
|
|
2980
|
+
boot one: \`vk devices start|stop|restart [name] --server <url>\`, or add
|
|
2981
|
+
--ensure-device[=name] to ai/suite/install to boot once before the first step.
|
|
2449
2982
|
|
|
2450
2983
|
ENVIRONMENT
|
|
2451
|
-
devices [--json]
|
|
2452
|
-
already driving each (USED BY)
|
|
2984
|
+
devices [--all] [--json] List attached devices/simulators, and which job is
|
|
2985
|
+
already driving each (USED BY); --all also lists
|
|
2986
|
+
startable (not-yet-booted) AVDs/simulators
|
|
2987
|
+
devices start <name> [--wipe] Boot an AVD/simulator; prints its serial. Already
|
|
2988
|
+
[--timeout dur] [--no-wait] running = a no-op. --wipe erases its data first
|
|
2989
|
+
devices stop <name|serial> Shut a running emulator/simulator down
|
|
2990
|
+
devices restart <name> [--wipe] Stop then boot (use for a wedged/flaky device)
|
|
2453
2991
|
doctor [--fix] Diagnose adb/device, and warn if this CLI or the
|
|
2454
2992
|
Claude Code plugin is out of date (a warning only —
|
|
2455
2993
|
it never changes the exit code). --fix disables
|
|
2456
2994
|
animations. VERIKUN_NO_UPDATE_CHECK skips the check
|
|
2457
2995
|
companion <status|stop> [--json] On-device hierarchy reader (Android, on by default;
|
|
2458
2996
|
VERIKUN_COMPANION=0 opts out)
|
|
2997
|
+
Note: \`devices stop\` powers a DEVICE off; \`stop <appId>\` force-stops an APP, and
|
|
2998
|
+
\`device set\` (singular) changes settings on the device you are driving.
|
|
2999
|
+
Physical devices are never power-cycled. Env: VERIKUN_EMULATOR (path to the SDK's
|
|
3000
|
+
\`emulator\` binary, if it isn't on PATH or under \$ANDROID_HOME).
|
|
2459
3001
|
|
|
2460
3002
|
TEST RUNS (actions are recorded; a run auto-starts on first action)
|
|
2461
3003
|
run start [name] [--force] Begin a named run (else one starts implicitly)
|