verikun 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -286,16 +286,30 @@ vk suite tests/ --app com.example.app --server "$VERIKUN_SERVER" # remote devi
286
286
  - **Each test is a full `vk ai` run** — plan cache, self-healing, cost budget, and
287
287
  its own archived JUnit + HTML report under `./.verikun/runs/<id>/`. A test that
288
288
  fails (or errors) doesn't stop the suite; the rest still run.
289
+ - **But a broken *environment* does stop it.** If a test dies from an environment
290
+ error (exit 3 — tool gone, device unplugged, server unreachable), the toolchain is
291
+ re-probed; only if it is *still* broken does the suite abort. That re-probe matters:
292
+ a transient `uiautomator` dump failure also exits 3, and shouldn't vaporize a
293
+ 20-test run. Continuing on a genuinely dead box just produces one identical red row
294
+ per remaining test — noise that reads exactly like a mass regression.
289
295
  - **The suite writes an overview** to `./.verikun/suites/<id>/`:
290
296
  - **`index.json`** — a stable, `schemaVersion`ed manifest: per-test pass/fail,
291
297
  steps, model repairs, cost, duration, and the run id, plus suite totals. This
292
298
  is the **output contract for reporting** — upload/publish steps compose over
293
299
  it (see the [CI recipe](#ci-recipe)) instead of verikun growing upload plugins.
294
- - **`index.html`** a summary page linking every test's `report.html`.
295
- - **Exit code is the CI gate:** `1` if any test failed, `0` all green, `2` bad/empty
296
- directory. All `ai` flags (`--model`, `--max-cost-usd`, `--timeout`, …) apply to
297
- every test; the provider (`ANTHROPIC_API_KEY` / `OPENAI_API_KEY`, or the `codex` /
298
- `cursor-agent` CLI for `--model codex-cli` / `cursor-cli`) is checked up front.
300
+ On an abort it also carries `aborted: {reason, notRun}`; the not-run tests get
301
+ **no rows and no place in `totals`**, so `passed + failed === tests` still holds
302
+ and nothing downstream mistakes a skipped test for a regression.
303
+ - **`index.html`** a summary page linking every test's `report.html`, with a
304
+ banner naming the not-run tests when the suite aborted.
305
+ - **Exit code is the CI gate:** `0` all green · `1` a test failed · `2` bad/empty
306
+ directory · `3` environment (the provider or the device toolchain is unavailable,
307
+ or the box broke mid-run). The `1`-vs-`3` split is the point: `1` is a regression
308
+ to investigate, `3` is a machine to fix. All `ai` flags (`--model`,
309
+ `--max-cost-usd`, `--timeout`, …) apply to every test; both the provider
310
+ (`ANTHROPIC_API_KEY` / `OPENAI_API_KEY`, or the `codex` / `cursor-agent` CLI for
311
+ `--model codex-cli` / `cursor-cli`) **and** the device toolchain (`adb` / `idb` +
312
+ a resolvable device) are checked up front, before anything is compiled.
299
313
 
300
314
  ## Remote devices — `vk server`
301
315
 
@@ -435,7 +449,7 @@ condition as a step in its own right.
435
449
  | `0` | success / found / assertion passed |
436
450
  | `1` | not found / assertion failed / wait timeout |
437
451
  | `2` | usage error or ambiguous selector (caller must refine) |
438
- | `3` | environment error (adb/simctl missing, no/multiple devices, dump failed) |
452
+ | `3` | environment error (adb/idb/simctl missing, no/multiple devices, dump failed). `ai`, `suite`, `install` and `server` verify the toolchain up front, so this arrives immediately with an install hint instead of mid-flow — and a `suite` whose device dies mid-run stops with `3` rather than reporting the rest as failures |
439
453
 
440
454
  Data goes to stdout; diagnostics/errors go to stderr.
441
455
 
@@ -6,6 +6,29 @@ const node_crypto_1 = require("node:crypto");
6
6
  const selector_1 = require("../ui/selector");
7
7
  const errors_1 = require("../errors");
8
8
  const ir_1 = require("./ir");
9
+ /** An outcome is environment-flavoured if it carries an exit-3 CliError, or simply
10
+ * reported code 3 — the latter also catches a remote step whose error crossed the
11
+ * wire as a plain Error (rebuildError drops exitCode) and a non-CliError throw,
12
+ * both of which the top-level contract already maps to exit 3. Over-classifying is
13
+ * safe here because `vk suite` re-probes before treating it as fatal. */
14
+ const isEnvOutcome = (outcome) => outcome.code === 3 || (0, errors_1.isEnvError)(outcome.error);
15
+ /**
16
+ * A control-flow guard could not read the screen even once, because the environment is
17
+ * broken (exit 3). Thrown out of `present()` and converted to a `status: 'env'` step
18
+ * result by the single catch in runPlan's step loop.
19
+ *
20
+ * A throw rather than a wider `present()` return type on purpose: `present()` has seven
21
+ * call sites across `if-present` / `when` / `repeat` / `while-present` / the guard-race
22
+ * check, and every one of them would need the same three-line env branch — repetition in
23
+ * the most intricate code here, and a silent false green at whichever site someone forgot.
24
+ * runPlan still RETURNS its result; this never escapes the engine.
25
+ */
26
+ class GuardBlindError extends Error {
27
+ constructor(selector, cause) {
28
+ super(`could not read the screen to evaluate '${selector}': ${cause.message.split('\n')[0]}`);
29
+ this.name = 'GuardBlindError';
30
+ }
31
+ }
9
32
  const describe = (leaf) => [leaf.command, ...leaf.positionals, ...leaf.flags.map((f) => (f.value === 'true' ? `--${f.name}` : `--${f.name} ${f.value}`))]
10
33
  .join(' ')
11
34
  .trim();
@@ -153,7 +176,10 @@ async function runPlan(plan, deps) {
153
176
  * - a dump that SUCCEEDS but does not match is re-polled only while settleMs
154
177
  * remains. At settleMs=0 that means exactly one pass — the fast, single-shot
155
178
  * probe a loop-exit check needs.
156
- * So one dump attempt always happens regardless of the window. */
179
+ * So one dump attempt always happens regardless of the window.
180
+ *
181
+ * Throws GuardBlindError when the window closes having NEVER once read the screen
182
+ * and the failure was an environment error — see that class for why. */
157
183
  const present = async (selector, settleMs) => {
158
184
  let sel;
159
185
  try {
@@ -173,15 +199,21 @@ async function runPlan(plan, deps) {
173
199
  // swings ~10x across devices, so "how long to wait" cannot be expressed in wall clock
174
200
  // alone without making the guard's patience device-dependent.
175
201
  let looks = 0;
202
+ // Did ANY dump in this whole call come back? A successful-but-empty tree counts: that
203
+ // is a bad read of a live screen, not a blind one, and it is already handled below.
204
+ let everRead = false;
205
+ let lastErr;
176
206
  const minLooks = settleMs > 0 ? 2 : 1;
177
207
  for (;;) {
178
208
  let els;
179
209
  for (let i = 0; i < 2; i++) {
180
210
  try {
181
211
  els = await deps.getElements();
212
+ everRead = true;
182
213
  }
183
- catch {
214
+ catch (e) {
184
215
  els = undefined; // transient dump failure — retry once before concluding "absent"
216
+ lastErr = e;
185
217
  }
186
218
  // An EMPTY tree is not a screen, it is a bad read: a live app always has nodes, and
187
219
  // this device routinely returns a partial/blank dump mid-transition (measured: `ui`
@@ -196,8 +228,14 @@ async function runPlan(plan, deps) {
196
228
  if (els !== undefined && (0, selector_1.matchElements)(els, sel).matches.length > 0)
197
229
  return true;
198
230
  const remaining = deadline - Date.now();
199
- if (looks >= minLooks && remaining <= 0)
231
+ if (looks >= minLooks && remaining <= 0) {
232
+ // The window closed having NEVER once read the screen, because the environment is
233
+ // broken. Answering "absent" here is a lie that silently skips the body — and a
234
+ // guard-heavy plan would then finish fully GREEN having executed nothing.
235
+ if (!everRead && (0, errors_1.isEnvError)(lastErr))
236
+ throw new GuardBlindError(selector, lastErr);
200
237
  return false;
238
+ }
201
239
  await sleep(Math.max(0, Math.min(GUARD_POLL_MS, remaining)));
202
240
  }
203
241
  };
@@ -300,9 +338,12 @@ async function runPlan(plan, deps) {
300
338
  if (isHealable(outcome)) {
301
339
  return { status: 'fail', where, reason: `unresolved after ${maxRepairs} repair attempt(s): ${outcome.error.message.split('\n')[0]}` };
302
340
  }
303
- // Terminal: an assertion failure (exit 1, no throw) or an environment error.
341
+ // Terminal: an assertion failure (exit 1, no throw) or an environment error. The
342
+ // two are reported differently — an assertion failure is a regression to fix, an
343
+ // environment error means the harness is broken and nothing downstream is
344
+ // trustworthy, so the caller aborts rather than banking a red result.
304
345
  const reason = outcome.error ? outcome.error.message.split('\n')[0] : `exited ${outcome.code}`;
305
- return { status: 'fail', where, reason };
346
+ return { status: isEnvOutcome(outcome) ? 'env' : 'fail', where, reason };
306
347
  }
307
348
  async function walkBody(body, parentWhere, guard) {
308
349
  for (let j = 0; j < body.length; j++) {
@@ -496,13 +537,28 @@ async function runPlan(plan, deps) {
496
537
  deps.log(`[ai] run timeout reached before steps[${i}] — aborting`);
497
538
  return { ok: false, plan, modelRepairs, improvements, abortedForTimeout: true };
498
539
  }
499
- const res = await walkNode(plan.steps[i], `steps[${i}]`, (l) => (plan.steps[i] = l));
540
+ // The one place a blind guard becomes a step result — see GuardBlindError.
541
+ let res;
542
+ try {
543
+ res = await walkNode(plan.steps[i], `steps[${i}]`, (l) => (plan.steps[i] = l));
544
+ }
545
+ catch (e) {
546
+ if (!(e instanceof GuardBlindError))
547
+ throw e;
548
+ res = { status: 'env', where: `steps[${i}]`, reason: e.message };
549
+ }
500
550
  if (res.status === 'budget') {
501
551
  return { ok: false, plan, modelRepairs, improvements, abortedForBudget: true };
502
552
  }
503
553
  if (res.status === 'timeout') {
504
554
  return { ok: false, plan, modelRepairs, improvements, abortedForTimeout: true };
505
555
  }
556
+ if (res.status === 'env') {
557
+ // `failure` is populated as well as the flag: the suite report still wants a row
558
+ // reason, and callers that only know about `failure` keep working unchanged.
559
+ deps.log(`[ai] ABORTED at ${res.where} — environment: ${res.reason}`);
560
+ return { ok: false, plan, modelRepairs, improvements, abortedForEnv: true, failure: { where: res.where, reason: res.reason } };
561
+ }
506
562
  if (res.status === 'fail') {
507
563
  deps.log(`[ai] FAILED at ${res.where}: ${res.reason}`);
508
564
  return { ok: false, plan, modelRepairs, improvements, failure: { where: res.where, reason: res.reason } };
package/dist/cli.js CHANGED
@@ -238,54 +238,41 @@ function formatDeviceTable(devices) {
238
238
  // Pad every cell except the last shown column (no trailing whitespace); join with 2 spaces.
239
239
  return rows.map((r) => r.map((cell, i) => (i === r.length - 1 ? cell : cell.padEnd(widths[i]))).join(' ').trimEnd());
240
240
  }
241
+ /** Render one shared ToolProbe the way doctor always has: present -> stdout, failure +
242
+ * hint -> stderr. Unlike `Driver.preflight()` (which throws on the first failure),
243
+ * doctor reports every probe so one run lists everything that needs fixing. */
244
+ function reportProbe(p) {
245
+ if (p.ok) {
246
+ // A multi-line detail (simctl's booted-device listing) reads as its own block
247
+ // rather than smashed onto the `name:` line.
248
+ if (p.detail.includes('\n')) {
249
+ (0, output_1.out)(`${p.name}: present`);
250
+ (0, output_1.out)(p.detail);
251
+ }
252
+ else {
253
+ (0, output_1.out)(`${p.name}: ${p.detail}`);
254
+ }
255
+ }
256
+ else {
257
+ (0, output_1.err)(`${p.name}: ${p.detail}`);
258
+ if (p.hint)
259
+ (0, output_1.err)(` ${p.hint}`);
260
+ }
261
+ return p.ok;
262
+ }
241
263
  function cmdDoctor(ctx) {
242
264
  if (ctx.platform === 'ios') {
243
- try {
244
- const r = (0, exec_1.runText)('xcrun', ['simctl', 'list', 'devices', 'booted']);
245
- (0, output_1.out)('xcrun: present');
246
- (0, output_1.out)(r.stdout.trim() || '(no booted simulators)');
247
- }
248
- catch (e) {
249
- // Not necessarily missing: runText also throws on a spawn timeout or other exec
250
- // failure, so surface the real reason rather than always claiming "NOT FOUND".
251
- (0, output_1.err)(`xcrun: ${e.message}`);
252
- (0, output_1.err)(' (if the Xcode command-line tools are not installed: `xcode-select --install`)');
265
+ // xcrun is the floor: without it there is no device list to reason about.
266
+ if (!reportProbe((0, drivers_1.probeXcrun)()))
253
267
  return 3;
254
- }
255
268
  // idb (+ its companion) powers everything interactive: ui/tap/text/swipe/key/logs.
256
- const idb = process.env.IDB || 'idb';
257
- let idbOk = true;
258
- try {
259
- (0, exec_1.runText)(idb, ['--help']); // idb has no --version; --help confirms the binary runs
260
- (0, output_1.out)('idb: present');
261
- }
262
- catch (e) {
263
- (0, output_1.err)(`idb: ${e.message}`);
264
- (0, output_1.err)(' needed for ui/tap/text/swipe/key/logs — install: `brew install idb-companion` then `pip install fb-idb`');
265
- idbOk = false;
266
- }
267
- try {
268
- (0, exec_1.runText)('idb_companion', ['--help']);
269
- (0, output_1.out)('idb_companion: present');
270
- }
271
- catch (e) {
272
- (0, output_1.err)(`idb_companion: ${e.message}`);
273
- (0, output_1.err)(' install: `brew install idb-companion`');
274
- idbOk = false;
275
- }
269
+ const idbOk = [(0, drivers_1.probeIdb)(), (0, drivers_1.probeIdbCompanion)()].map(reportProbe).every(Boolean);
276
270
  (0, output_1.out)('note: simulator screenshots + launch/stop work via simctl; ui/tap/text/swipe/key/logs use idb.');
277
271
  return idbOk ? 0 : 3;
278
272
  }
279
273
  const adb = process.env.ADB || 'adb';
280
- try {
281
- (0, output_1.out)('adb: ' + (0, exec_1.runText)(adb, ['version']).stdout.split('\n')[0]);
282
- }
283
- catch (e) {
284
- // Not necessarily missing: runText also throws on a spawn timeout or other exec
285
- // failure — surface the real reason rather than always claiming "NOT FOUND".
286
- (0, output_1.err)(`adb: ${e.message}`);
274
+ if (!reportProbe((0, drivers_1.probeAdb)()))
287
275
  return 3;
288
- }
289
276
  const devices = ctx.driver.listDevices();
290
277
  const usable = devices.filter((d) => d.state === 'device');
291
278
  (0, output_1.out)(`devices: ${devices.length} attached, ${usable.length} usable`);
@@ -1113,6 +1100,11 @@ async function resolveBackend(platform, device, flags) {
1113
1100
  const server = (0, args_1.flagStr)(flags, 'server') || process.env.VERIKUN_SERVER || undefined;
1114
1101
  if (!server) {
1115
1102
  const driver = (0, drivers_1.getDriver)(platform, device);
1103
+ // Fail fast on a broken toolchain BEFORE any model spend — the mirror of the
1104
+ // remote branch's pingServer below. Without this, a missing `idb` isn't noticed
1105
+ // until the first step that reads the hierarchy (launch/screenshot go through
1106
+ // simctl on a simulator), by which point every test in a suite has been compiled.
1107
+ driver.preflight();
1116
1108
  return {
1117
1109
  backend: {
1118
1110
  exec: (command, positionals, f) => executeOutcome(command, positionals, f, driver),
@@ -1126,6 +1118,7 @@ async function resolveBackend(platform, device, flags) {
1126
1118
  else
1127
1119
  driver.clearApp(appId);
1128
1120
  },
1121
+ preflight: () => driver.preflight(),
1129
1122
  },
1130
1123
  platform,
1131
1124
  device,
@@ -1142,8 +1135,20 @@ async function resolveBackend(platform, device, flags) {
1142
1135
  const health = await (0, remote_1.pingServer)(opts); // fails fast (exit 3) on a bad URL or key
1143
1136
  runCtx = { platform: health.platform, device: health.serial };
1144
1137
  (0, output_1.err)(`[verikun] server ${server}: ${health.platform} · device ${health.serial} · verikun ${health.version}`);
1138
+ const remote = (0, remote_1.createRemoteBackend)(opts, health);
1145
1139
  return {
1146
- backend: (0, remote_1.createRemoteBackend)(opts, health),
1140
+ backend: {
1141
+ ...remote,
1142
+ // Ping first (URL, key, version), then fetch the hierarchy once. The ping alone
1143
+ // is NOT enough as a health probe: /v1/health answers from config captured at
1144
+ // server startup and never touches the device, so it cannot see a phone that was
1145
+ // unplugged next to the server — the suite's mid-run re-probe would call it
1146
+ // healthy and keep grinding. One dump is the cheap call that actually proves it.
1147
+ preflight: async () => {
1148
+ await (0, remote_1.pingServer)(opts);
1149
+ await remote.getElements();
1150
+ },
1151
+ },
1147
1152
  platform: health.platform,
1148
1153
  device: health.serial,
1149
1154
  remote: { url: server, version: health.version },
@@ -1249,7 +1254,9 @@ async function runAiTest(file, opts, backend, platform, device) {
1249
1254
  ? `ABORTED — cost ceiling $${opts.maxCostUsd} reached`
1250
1255
  : result.abortedForTimeout
1251
1256
  ? `ABORTED — run timeout (${Math.round(opts.timeoutMs / 1000)}s) reached`
1252
- : `FAIL at ${result.failure?.where}: ${result.failure?.reason}`;
1257
+ : result.abortedForEnv
1258
+ ? `ABORTED — environment: ${result.failure?.reason}`
1259
+ : `FAIL at ${result.failure?.where}: ${result.failure?.reason}`;
1253
1260
  (0, output_1.err)(`[ai] ${status} · ${costLine}`);
1254
1261
  (0, output_1.err)(`[ai] report: ${htmlPath}`);
1255
1262
  if (result.improvements.length) {
@@ -1271,6 +1278,7 @@ async function runAiTest(file, opts, backend, platform, device) {
1271
1278
  ...(result.failure ? { failure: result.failure } : {}),
1272
1279
  ...(result.abortedForBudget ? { abortedForBudget: true } : {}),
1273
1280
  ...(result.abortedForTimeout ? { abortedForTimeout: true } : {}),
1281
+ ...(result.abortedForEnv ? { abortedForEnv: true } : {}),
1274
1282
  };
1275
1283
  }
1276
1284
  async function cmdAi(positionals, flags) {
@@ -1311,12 +1319,15 @@ async function cmdAi(positionals, flags) {
1311
1319
  ...(result.failure ? { failure: result.failure } : {}),
1312
1320
  ...(result.abortedForBudget ? { abortedForBudget: true } : {}),
1313
1321
  ...(result.abortedForTimeout ? { abortedForTimeout: true } : {}),
1322
+ ...(result.abortedForEnv ? { abortedForEnv: true } : {}),
1314
1323
  });
1315
1324
  }
1316
1325
  else if (result.reportHtml) {
1317
1326
  (0, output_1.out)(result.reportHtml); // primary machine result: the report path
1318
1327
  }
1319
- return result.ok ? 0 : 1;
1328
+ // An environment failure is exit 3, not 1: the box is broken, not the app. Exit 1
1329
+ // here would be indistinguishable from a real regression and page the wrong person.
1330
+ return result.abortedForEnv ? 3 : result.ok ? 0 : 1;
1320
1331
  }
1321
1332
  // ---------------------------------------------------------------------------
1322
1333
  // install — put an app build on the device (local driver or remote vk server)
@@ -1369,6 +1380,7 @@ async function cmdSuiteEntry(positionals, flags) {
1369
1380
  // Reset app state between tests only when the app id is known; without --app,
1370
1381
  // each test is responsible for its own isolation (e.g. `launch --clear`).
1371
1382
  reset: app ? () => backend.reset(app) : undefined,
1383
+ preflight: () => backend.preflight?.(),
1372
1384
  });
1373
1385
  }
1374
1386
  finally {
@@ -1,11 +1,31 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.AdbDriver = void 0;
4
+ exports.probeAdb = probeAdb;
4
5
  exports.escapeText = escapeText;
5
6
  const errors_1 = require("../errors");
6
7
  const exec_1 = require("../exec");
7
8
  const android_parse_1 = require("../ui/android-parse");
8
9
  const ADB = process.env.ADB || 'adb';
10
+ const ADB_HINT = 'install the Android platform-tools (`brew install --cask android-platform-tools`), or point ADB at the binary';
11
+ /** Is `adb` present and runnable? Shared by `vk doctor` and AdbDriver.preflight() so
12
+ * the two can't drift on what "the Android toolchain works" means. */
13
+ function probeAdb() {
14
+ try {
15
+ const r = (0, exec_1.runText)(ADB, ['version']);
16
+ // runText only throws when the binary can't be SPAWNED, so a broken-but-present adb
17
+ // needs its exit code checked too — otherwise it reports as healthy.
18
+ if (r.code !== 0) {
19
+ return { name: 'adb', ok: false, detail: `adb version exited ${r.code}: ${r.stderr.trim()}`, hint: ADB_HINT };
20
+ }
21
+ return { name: 'adb', ok: true, detail: r.stdout.split('\n')[0] };
22
+ }
23
+ catch (e) {
24
+ // Not necessarily missing: runText also throws on a spawn timeout or other exec
25
+ // failure — surface the real reason rather than always claiming "NOT FOUND".
26
+ return { name: 'adb', ok: false, detail: e.message, hint: ADB_HINT };
27
+ }
28
+ }
9
29
  // Named keys -> Android keycodes. Numeric codes are also accepted directly.
10
30
  const KEYCODES = {
11
31
  enter: 66,
@@ -79,6 +99,27 @@ class AdbDriver {
79
99
  constructor(serial) {
80
100
  this.requested = serial;
81
101
  }
102
+ preflight() {
103
+ const adb = probeAdb();
104
+ if (!adb.ok)
105
+ throw (0, errors_1.probeFailure)(adb);
106
+ // Resolving the serial is the other half of "can I drive anything?": it throws
107
+ // exit 3 with no device attached and exit 2 when several are and none was chosen.
108
+ const serial = this.resolvedSerial();
109
+ // …but that answer is cached, so ask the device itself. That is what lets a suite's
110
+ // mid-run re-probe notice a phone that was unplugged or an emulator that died,
111
+ // rather than replaying the resolution it made before anything went wrong.
112
+ const state = (0, exec_1.runText)(ADB, ['-s', serial, 'get-state']);
113
+ const got = state.stdout.trim();
114
+ if (state.code !== 0 || got !== 'device') {
115
+ throw (0, errors_1.probeFailure)({
116
+ name: 'adb',
117
+ ok: false,
118
+ detail: `device ${serial} is not ready (${got || state.stderr.trim().split('\n')[0] || `adb get-state exited ${state.code}`})`,
119
+ hint: 'reconnect it (check `verikun devices`); an unauthorized device needs the USB-debugging prompt accepted',
120
+ });
121
+ }
122
+ }
82
123
  listDevices() {
83
124
  const { stdout } = (0, exec_1.runText)(ADB, ['devices', '-l']);
84
125
  const devices = [];
@@ -1,13 +1,17 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.IdbDriver = exports.AdbDriver = void 0;
3
+ exports.probeIdbCompanion = exports.probeIdb = exports.probeXcrun = exports.IdbDriver = exports.probeAdb = exports.AdbDriver = void 0;
4
4
  exports.getDriver = getDriver;
5
5
  const adb_1 = require("./adb");
6
6
  const ios_1 = require("./ios");
7
7
  var adb_2 = require("./adb");
8
8
  Object.defineProperty(exports, "AdbDriver", { enumerable: true, get: function () { return adb_2.AdbDriver; } });
9
+ Object.defineProperty(exports, "probeAdb", { enumerable: true, get: function () { return adb_2.probeAdb; } });
9
10
  var ios_2 = require("./ios");
10
11
  Object.defineProperty(exports, "IdbDriver", { enumerable: true, get: function () { return ios_2.IdbDriver; } });
12
+ Object.defineProperty(exports, "probeXcrun", { enumerable: true, get: function () { return ios_2.probeXcrun; } });
13
+ Object.defineProperty(exports, "probeIdb", { enumerable: true, get: function () { return ios_2.probeIdb; } });
14
+ Object.defineProperty(exports, "probeIdbCompanion", { enumerable: true, get: function () { return ios_2.probeIdbCompanion; } });
11
15
  function getDriver(platform, device) {
12
16
  return platform === 'ios' ? new ios_1.IdbDriver(device) : new adb_1.AdbDriver(device);
13
17
  }
@@ -1,6 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.IdbDriver = void 0;
4
+ exports.probeXcrun = probeXcrun;
5
+ exports.probeIdb = probeIdb;
6
+ exports.probeIdbCompanion = probeIdbCompanion;
4
7
  const node_os_1 = require("node:os");
5
8
  const node_path_1 = require("node:path");
6
9
  const node_fs_1 = require("node:fs");
@@ -21,6 +24,50 @@ const ios_parse_1 = require("../ui/ios-parse");
21
24
  // never feed back into a tap.)
22
25
  const XCRUN = 'xcrun';
23
26
  const IDB = process.env.IDB || 'idb';
27
+ // Tool probes, shared by `vk doctor --ios` (which renders every one and keeps going)
28
+ // and IdbDriver.preflight() (which throws on the first failure) so the two can't drift
29
+ // on what "the iOS toolchain works" means — or on the install hints.
30
+ const IDB_HINT = 'needed for ui/tap/text/swipe/key/logs — install: `brew install idb-companion` then `pip install fb-idb`';
31
+ const XCRUN_HINT = 'if the Xcode command-line tools are not installed: `xcode-select --install`';
32
+ function probeXcrun() {
33
+ try {
34
+ const r = (0, exec_1.runText)(XCRUN, ['simctl', 'list', 'devices', 'booted']);
35
+ // runText only throws when the binary can't be SPAWNED, so a tool that exists but
36
+ // fails (broken install, missing Xcode selection) needs the exit code checked too.
37
+ if (r.code !== 0) {
38
+ return { name: 'xcrun', ok: false, detail: `xcrun simctl exited ${r.code}: ${r.stderr.trim()}`, hint: XCRUN_HINT };
39
+ }
40
+ return { name: 'xcrun', ok: true, detail: r.stdout.trim() || '(no booted simulators)' };
41
+ }
42
+ catch (e) {
43
+ // Not necessarily missing: runText also throws on a spawn timeout or other exec
44
+ // failure, so surface the real reason rather than always claiming "NOT FOUND".
45
+ return { name: 'xcrun', ok: false, detail: e.message, hint: XCRUN_HINT };
46
+ }
47
+ }
48
+ function probeIdb() {
49
+ try {
50
+ const r = (0, exec_1.runText)(IDB, ['--help']); // idb has no --version; --help confirms the binary runs
51
+ if (r.code !== 0)
52
+ return { name: 'idb', ok: false, detail: `idb --help exited ${r.code}: ${r.stderr.trim()}`, hint: IDB_HINT };
53
+ return { name: 'idb', ok: true, detail: 'present' };
54
+ }
55
+ catch (e) {
56
+ return { name: 'idb', ok: false, detail: e.message, hint: IDB_HINT };
57
+ }
58
+ }
59
+ function probeIdbCompanion() {
60
+ try {
61
+ // Spawn-only on purpose: idb_companion prints its usage to stderr and exits 1 for
62
+ // --help, so its exit code says nothing about health. Presence is all we can cheaply
63
+ // assert here — idb itself is the probe that has to actually work.
64
+ (0, exec_1.runText)('idb_companion', ['--help']);
65
+ return { name: 'idb_companion', ok: true, detail: 'present' };
66
+ }
67
+ catch (e) {
68
+ return { name: 'idb_companion', ok: false, detail: e.message, hint: 'install: `brew install idb-companion`' };
69
+ }
70
+ }
24
71
  const DEFAULT_LOG_LINES = 200;
25
72
  const DEFAULT_LOG_WINDOW = '5m';
26
73
  // Named keys -> USB-HID keyboard usage IDs, handed to `idb ui key <code>`. Numeric
@@ -104,6 +151,34 @@ class IdbDriver {
104
151
  // 'booted' is a simctl-only alias idb can't address, so treat it as "auto-resolve".
105
152
  this.requested = device && device !== 'booted' ? device : undefined;
106
153
  }
154
+ preflight() {
155
+ // resolvedSerial() shells to simctl, so it already covers a missing xcrun, no
156
+ // booted simulator / connected device, and an ambiguous target (exit 2).
157
+ this.resolvedSerial();
158
+ // idb is required to drive iOS AT ALL — simulator or not. simctl covers launch,
159
+ // stop and screenshots, so without this a missing idb goes unnoticed until the
160
+ // first step that reads the hierarchy, long after a compile has been paid for.
161
+ //
162
+ // `describe` rather than doctor's `idb --help`: one round-trip that proves idb runs
163
+ // AND that the target is still reachable through its companion. That second half is
164
+ // what lets `vk suite`'s mid-run re-probe notice a simulator that died — `--help`
165
+ // would keep answering happily with the device long gone. (doctor keeps `--help`
166
+ // because it must report on idb with no device booted at all.)
167
+ let r;
168
+ try {
169
+ // Default 30s timeout, same as screenSize()'s identical call: preflight is the
170
+ // FIRST idb call of the process, so it is the one that pays idb_companion's
171
+ // cold start — the last place to shave the budget.
172
+ r = (0, exec_1.runText)(IDB, ['describe', '--udid', this.udid()]);
173
+ }
174
+ catch (e) {
175
+ throw (0, errors_1.probeFailure)({ name: 'idb', ok: false, detail: e.message, hint: IDB_HINT });
176
+ }
177
+ if (r.code !== 0) {
178
+ const why = r.stderr.trim().split('\n')[0] || `exit code ${r.code}`;
179
+ throw (0, errors_1.probeFailure)({ name: 'idb', ok: false, detail: `idb cannot reach ${this.udid()}: ${why}`, hint: IDB_HINT });
180
+ }
181
+ }
107
182
  /** All available simulators (booted or shutdown) via simctl. Tolerates odd output. */
108
183
  simulators() {
109
184
  const devices = [];
package/dist/errors.js CHANGED
@@ -6,7 +6,8 @@
6
6
  // 2 usage error or ambiguous selector (caller must refine)
7
7
  // 3 environment error (adb/simctl missing, no/multiple devices, dump failed)
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.AmbiguousSelectorError = exports.SelectorNotFoundError = exports.envError = exports.notFound = exports.usageError = exports.CliError = void 0;
9
+ exports.AmbiguousSelectorError = exports.SelectorNotFoundError = exports.probeFailure = exports.envError = exports.notFound = exports.usageError = exports.CliError = void 0;
10
+ exports.isEnvError = isEnvError;
10
11
  class CliError extends Error {
11
12
  exitCode;
12
13
  constructor(message, exitCode) {
@@ -22,6 +23,20 @@ const notFound = (m) => new CliError(m, 1);
22
23
  exports.notFound = notFound;
23
24
  const envError = (m) => new CliError(m, 3);
24
25
  exports.envError = envError;
26
+ /**
27
+ * An environment failure (exit 3): a tool missing from PATH, no/ambiguous device, a
28
+ * hierarchy dump or capture that failed. The one predicate every layer shares to tell
29
+ * "the box is broken" from "the app is broken" — the agent runner aborts on it instead
30
+ * of recording a regression, and `vk suite` stops rather than reporting N phantom
31
+ * failures. Accepts `unknown` so `catch (e)` blocks can pass their binding directly.
32
+ */
33
+ function isEnvError(e) {
34
+ return e instanceof CliError && e.exitCode === 3;
35
+ }
36
+ /** Turn a failed tool probe into the environment error both drivers' preflight throws,
37
+ * so the install hint reads the same whether it came from `vk doctor` or a preflight. */
38
+ const probeFailure = (p) => (0, exports.envError)(`${p.detail}${p.hint ? `\n ${p.hint}` : ''}`);
39
+ exports.probeFailure = probeFailure;
25
40
  // --- Selector-resolution errors (heal triggers for the agent runner) --------
26
41
  //
27
42
  // A selector miss (zero matches) and an ambiguous match (>1) are still ordinary
package/dist/report.js CHANGED
@@ -206,6 +206,9 @@ const SUITE_STYLE = `
206
206
  table.tests td.num { text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; }
207
207
  table.tests a { color:inherit; }
208
208
  .fail-reason { color:var(--fail); font-size:12px; margin-top:2px; }
209
+ .aborted { background:#fff4e5; border:1px solid #f0b429; border-radius:8px; padding:12px 14px; margin:0 0 14px; font-size:13px; }
210
+ .aborted strong { color:#8a5300; }
211
+ .aborted ul { margin:6px 0 0; padding-left:20px; color:var(--muted); }
209
212
  `;
210
213
  function suiteTestRow(t, linkBase) {
211
214
  // A test that errored before its run started (id '') has no report to link.
@@ -233,10 +236,22 @@ function toSuiteHtml(suite, opts = {}) {
233
236
  const chips = [
234
237
  `<span class="chip pass">${t.passed} passed</span>`,
235
238
  t.failed ? `<span class="chip fail">${t.failed} failed</span>` : '',
239
+ suite.aborted ? `<span class="chip fail">ABORTED</span>` : '',
236
240
  `<span class="chip muted">${t.tests} tests &middot; ${t.steps} steps &middot; ${fmtDuration(t.durationMs)} &middot; $${t.costUsd.toFixed(4)}</span>`,
237
241
  ]
238
242
  .filter(Boolean)
239
243
  .join('\n ');
244
+ // The banner, not a table row per skipped file: a not-run test is not a result, and
245
+ // faking a FAIL row for one would be the very "phantom regression" this prevents.
246
+ const abortedBanner = suite.aborted
247
+ ? ` <div class="aborted">
248
+ <strong>Suite aborted — the device environment broke mid-run.</strong>
249
+ <div>${htmlEsc(suite.aborted.reason)}</div>
250
+ ${suite.aborted.notRun.length
251
+ ? ` <ul>${suite.aborted.notRun.map((f) => `<li>${htmlEsc(f)} — not run</li>`).join('')}</ul>\n`
252
+ : ''} </div>
253
+ `
254
+ : '';
240
255
  const metaBits = [
241
256
  `<code>${htmlEsc(suite.id)}</code>`,
242
257
  htmlEsc(suite.platform) + (suite.device ? ` · ${htmlEsc(suite.device)}` : ''),
@@ -259,7 +274,7 @@ function toSuiteHtml(suite, opts = {}) {
259
274
  <div class="summary">
260
275
  ${chips}
261
276
  </div>
262
- <table class="tests">
277
+ ${abortedBanner} <table class="tests">
263
278
  <thead><tr><th></th><th>Test</th><th>Steps</th><th>Repairs</th><th>Cost</th><th>Duration</th></tr></thead>
264
279
  <tbody>
265
280
  ${suite.tests.map((x) => suiteTestRow(x, linkBase)).join('\n')}
package/dist/run.js CHANGED
@@ -340,12 +340,15 @@ class Recorder {
340
340
  this.step.status = exitCode === 1 ? 'failed' : 'error';
341
341
  if (!this.step.message)
342
342
  this.step.message = e.message;
343
- this.capture(driver);
343
+ // The step failed BECAUSE the environment is broken, so evidence capture is
344
+ // near-certain to fail the same way. Still attempt it (a screencap can succeed
345
+ // where a dump doesn't), but don't narrate two more copies of the same error.
346
+ this.capture(driver, (0, errors_1.isEnvError)(e));
344
347
  this.commit();
345
348
  }
346
349
  // Best-effort: grab a screenshot and the UI hierarchy of the failing page.
347
350
  // The device may be unreachable (that may be why we failed) — swallow errors.
348
- capture(driver) {
351
+ capture(driver, quiet = false) {
349
352
  if (!driver)
350
353
  return;
351
354
  try {
@@ -355,14 +358,16 @@ class Recorder {
355
358
  catch (e) {
356
359
  // Best-effort evidence: the device may be gone (often why the step failed). Surface
357
360
  // it so a screenshot bug isn't hidden, but never let it derail failure recording.
358
- (0, output_1.err)(`[verikun] could not capture failure screenshot (${e.message})`);
361
+ if (!quiet)
362
+ (0, output_1.err)(`[verikun] could not capture failure screenshot (${e.message})`);
359
363
  }
360
364
  try {
361
365
  const text = (0, format_1.formatCompact)(driver.getElements({ all: false }));
362
366
  this.step.failHierarchy = text.length > HIERARCHY_CAP ? text.slice(0, HIERARCHY_CAP) + '\n…(truncated)' : text;
363
367
  }
364
368
  catch (e) {
365
- (0, output_1.err)(`[verikun] could not capture failure hierarchy (${e.message})`);
369
+ if (!quiet)
370
+ (0, output_1.err)(`[verikun] could not capture failure hierarchy (${e.message})`);
366
371
  }
367
372
  }
368
373
  writeArtifact(rel, buf) {
package/dist/server.js CHANGED
@@ -310,9 +310,12 @@ async function cmdServer(positionals, flags) {
310
310
  authKey = (0, node_crypto_1.randomBytes)(32).toString('base64url');
311
311
  generated = true;
312
312
  }
313
- // Build the ONE driver the server will ever use, and fail fast (exit 2/3) if no
314
- // device resolves — before binding a port.
313
+ // Build the ONE driver the server will ever use, and fail fast (exit 2/3) if the
314
+ // toolchain can't drive it — before binding a port. preflight() covers resolving the
315
+ // device AND the tools; without it the server happily listens on a box with no idb
316
+ // and then 500s every /v1/exec.
315
317
  const driver = (0, drivers_1.getDriver)(platform, device);
318
+ driver.preflight();
316
319
  const serial = driver.resolvedSerial();
317
320
  // Handlers print "tapped …" confirmations via out(); a server's stdout is not a
318
321
  // data channel, so silence them — request logging goes to stderr instead.
package/dist/suite.js CHANGED
@@ -22,6 +22,38 @@ const output_1 = require("./output");
22
22
  const run_1 = require("./run");
23
23
  const report_1 = require("./report");
24
24
  const version_1 = require("./version");
25
+ /** Gap between the two health probes below. Long enough to outlast a USB
26
+ * re-enumeration or a simulator relaunch, short enough not to pad a real abort. */
27
+ const PROBE_RETRY_MS = 1000;
28
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
29
+ /**
30
+ * An environment-flavoured failure is only FATAL if the toolchain is STILL broken when
31
+ * we re-probe. This distinction is load-bearing: a transient uiautomator dump failure
32
+ * also surfaces as exit 3 (matchWaiting/resolveOneWaiting don't catch a thrown
33
+ * getElements), so aborting on the exit code alone would let one flaky dump vaporize a
34
+ * 20-test suite. Returns the reason when broken, undefined when it was transient.
35
+ */
36
+ async function stillBroken(deps) {
37
+ if (!deps.preflight)
38
+ return undefined; // not wired -> preserve continue-on-failure
39
+ // Two attempts a second apart, because the probe is the ONLY thing separating a
40
+ // momentary blip from a dead box, and killing a 20-test suite is the expensive
41
+ // mistake. A USB re-enumeration or a simulator mid-relaunch can fail one probe and
42
+ // pass the next; a genuinely missing tool fails both in a few milliseconds.
43
+ let last = '';
44
+ for (let attempt = 0; attempt < 2; attempt++) {
45
+ if (attempt > 0)
46
+ await sleep(deps.probeRetryMs ?? PROBE_RETRY_MS);
47
+ try {
48
+ await deps.preflight();
49
+ return undefined;
50
+ }
51
+ catch (e) {
52
+ last = e.message.split('\n')[0];
53
+ }
54
+ }
55
+ return last;
56
+ }
25
57
  /** Lexicographic order, so authors sequence flows with 01-…, 02-… prefixes. */
26
58
  function sortTestFiles(files) {
27
59
  return [...files].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
@@ -45,13 +77,15 @@ function listTestFiles(dir) {
45
77
  function toSuiteResult(file, r, durationMs) {
46
78
  const steps = r.state?.steps ?? [];
47
79
  const passedSteps = steps.filter((s) => s.status === 'passed').length;
48
- const failure = r.failure
49
- ? `FAIL at ${r.failure.where}: ${r.failure.reason}`
50
- : r.abortedForBudget
51
- ? 'aborted: cost ceiling reached'
52
- : r.abortedForTimeout
53
- ? 'aborted: run timeout reached'
54
- : undefined;
80
+ const failure = r.abortedForEnv
81
+ ? `aborted: environment ${r.failure?.reason ?? 'device unavailable'}`
82
+ : r.failure
83
+ ? `FAIL at ${r.failure.where}: ${r.failure.reason}`
84
+ : r.abortedForBudget
85
+ ? 'aborted: cost ceiling reached'
86
+ : r.abortedForTimeout
87
+ ? 'aborted: run timeout reached'
88
+ : undefined;
55
89
  return {
56
90
  id: r.runDir ? (0, node_path_1.basename)(r.runDir) : '',
57
91
  file,
@@ -80,7 +114,8 @@ async function cmdSuite(dirArg, flags, deps) {
80
114
  const startedAt = new Date().toISOString();
81
115
  (0, output_1.err)(`[suite] '${name}': ${files.length} test(s) from ${dirArg} (${deps.platform}${deps.device ? ` · ${deps.device}` : ''})`);
82
116
  const results = [];
83
- for (let i = 0; i < files.length; i++) {
117
+ let aborted;
118
+ for (let i = 0; i < files.length && !aborted; i++) {
84
119
  const file = files[i];
85
120
  (0, output_1.err)(`[suite] ── (${i + 1}/${files.length}) ${file} ──`);
86
121
  if (deps.reset) {
@@ -89,8 +124,16 @@ async function cmdSuite(dirArg, flags, deps) {
89
124
  (0, output_1.err)('[suite] app state reset');
90
125
  }
91
126
  catch (e) {
92
- // Surface but continue: a flaky reset should not zero out the whole suite
93
- // the test itself will fail loudly if the stale state actually matters.
127
+ // A reset that failed because the BOX is broken means nothing after it is
128
+ // trustworthy but only if a re-probe agrees. Otherwise surface and continue:
129
+ // a flaky reset should not zero out the whole suite, and the test itself will
130
+ // fail loudly if the stale state actually matters.
131
+ const broken = (0, errors_1.isEnvError)(e) ? await stillBroken(deps) : undefined;
132
+ if (broken) {
133
+ // This test never ran, so it gets no row — notRun starts at the CURRENT file.
134
+ aborted = { reason: `reset failed: ${broken}`, notRun: files.slice(i) };
135
+ break;
136
+ }
94
137
  (0, output_1.err)(`[suite] reset failed (${e.message}) — continuing`);
95
138
  }
96
139
  }
@@ -98,11 +141,19 @@ async function cmdSuite(dirArg, flags, deps) {
98
141
  try {
99
142
  const r = await deps.runTest((0, node_path_1.join)(dir, file));
100
143
  results.push(toSuiteResult(file, r, Date.now() - t0));
144
+ // The test itself reported an environment abort (exit 3 mid-plan). Same rule:
145
+ // fatal only if the box is still broken. This test HAS a row and a real report,
146
+ // so notRun starts after it.
147
+ if (r.abortedForEnv) {
148
+ const broken = await stillBroken(deps);
149
+ if (broken)
150
+ aborted = { reason: broken, notRun: files.slice(i + 1) };
151
+ }
101
152
  }
102
153
  catch (e) {
103
- // A test that THREW (env error: device gone, server unreachable, bad file)
104
- // still becomes a failed row — one broken test must not vaporize the suite
105
- // report for the tests that already ran.
154
+ // A test that THREW (device gone, server unreachable, bad file) still becomes a
155
+ // failed row — one broken test must not vaporize the suite report for the tests
156
+ // that already ran. But if it threw because the environment is gone, stop.
106
157
  const msg = e instanceof Error ? e.message : String(e);
107
158
  (0, output_1.err)(`[suite] ${file} errored: ${msg}`);
108
159
  results.push({
@@ -118,8 +169,14 @@ async function cmdSuite(dirArg, flags, deps) {
118
169
  modelRepairs: 0,
119
170
  failure: msg.split('\n')[0],
120
171
  });
172
+ const broken = (0, errors_1.isEnvError)(e) ? await stillBroken(deps) : undefined;
173
+ if (broken)
174
+ aborted = { reason: broken, notRun: files.slice(i + 1) };
121
175
  }
122
176
  }
177
+ if (aborted) {
178
+ (0, output_1.err)(`[suite] ABORTED — environment: ${aborted.reason} (${aborted.notRun.length} test(s) not run)`);
179
+ }
123
180
  const suite = {
124
181
  schemaVersion: 1,
125
182
  id: suiteId,
@@ -131,6 +188,7 @@ async function cmdSuite(dirArg, flags, deps) {
131
188
  verikun: version_1.VERSION,
132
189
  totals: (0, report_1.suiteTotals)(results),
133
190
  tests: results,
191
+ ...(aborted ? { aborted } : {}),
134
192
  };
135
193
  // .verikun/suites/<id>/ sits beside .verikun/runs/<id>/, so index.html reaches a
136
194
  // test report at ../../runs/<id>/report.html — the linkBase below.
@@ -147,6 +205,8 @@ async function cmdSuite(dirArg, flags, deps) {
147
205
  (0, output_1.json)(suite);
148
206
  else
149
207
  (0, output_1.out)(outDir); // primary machine result: the suite directory
150
- // The CI gate: any failed test fails the invocation (mirrors `vk run archive`).
151
- return t.failed > 0 ? 1 : 0;
208
+ // The CI gate: any failed test fails the invocation (mirrors `vk run archive`). An
209
+ // environment abort exits 3 instead, so CI can tell "the runner is broken" from "the
210
+ // app regressed" — the whole point of stopping early.
211
+ return aborted ? 3 : t.failed > 0 ? 1 : 0;
152
212
  }
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.11.0';
6
+ exports.VERSION = '0.12.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "verikun",
3
- "version": "0.11.0",
3
+ "version": "0.12.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",