verikun 0.18.0 → 0.18.1

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
@@ -144,11 +144,17 @@ skips the archive dump on green runs (failures still capture).
144
144
  - **`report.html`** — a self-contained report: every step, the identifiers used,
145
145
  any screenshots taken, the screenshot + hierarchy of any failed page, a link to
146
146
  the full device log, an app-scoped log accordion when the run launched an app,
147
- and any per-step logs from `vk log`.
147
+ and any per-step logs from `vk log`. A run that did not pass says so in a banner
148
+ at the top.
148
149
  - **`artifacts/logcat.txt`** — full device log for the run window (default).
149
150
  - **`artifacts/logcat-app.txt`** — app-scoped log (when a package/bundle was launched).
150
151
  - **`run.json`** — the raw recording.
151
152
 
153
+ A `vk ai` run can also fail where no single command did — a `repeat` that never
154
+ sees its target, a cost/timeout abort. That verdict is recorded too (as
155
+ `run.json`'s `failure`, plus a failed step carrying the reason and a screenshot),
156
+ so a failed test is never reported as `failures="0"` in the JUnit that CI reads.
157
+
152
158
  `vk run archive` exits non-zero when the run contained failures, so the same
153
159
  command both produces the report and gates CI.
154
160
 
package/dist/cli.js CHANGED
@@ -50,6 +50,7 @@ exports.chooseLogOpts = chooseLogOpts;
50
50
  exports.evalAssert = evalAssert;
51
51
  exports.tokenizeLine = tokenizeLine;
52
52
  exports.withBatchGlobals = withBatchGlobals;
53
+ exports.terminalFailure = terminalFailure;
53
54
  exports.executeForServer = executeForServer;
54
55
  exports.run = run;
55
56
  const node_fs_1 = require("node:fs");
@@ -1322,6 +1323,24 @@ async function resolveBackend(platform, device, flags) {
1322
1323
  driver.clearApp(appId);
1323
1324
  },
1324
1325
  preflight: () => driver.preflight(),
1326
+ captureFailure: async () => {
1327
+ // Two independent tries: a screencap can succeed where a dump doesn't (and
1328
+ // vice versa), and neither is allowed to derail recording the failure.
1329
+ const out = {};
1330
+ try {
1331
+ out.png = driver.screenshot();
1332
+ }
1333
+ catch {
1334
+ /* device may be gone — that may be why we failed */
1335
+ }
1336
+ try {
1337
+ out.hierarchy = driver.getElements({ all: false });
1338
+ }
1339
+ catch {
1340
+ /* ditto */
1341
+ }
1342
+ return out;
1343
+ },
1325
1344
  },
1326
1345
  platform,
1327
1346
  device,
@@ -1352,12 +1371,52 @@ async function resolveBackend(platform, device, flags) {
1352
1371
  await (0, remote_1.pingServer)(opts);
1353
1372
  await remote.getElements();
1354
1373
  },
1374
+ // Hierarchy only: the server exposes no screenshot route, so a remote run's
1375
+ // engine failure archives without a picture. Honest degrade over a protocol
1376
+ // change here — tracked in #48.
1377
+ captureFailure: async () => {
1378
+ try {
1379
+ return { hierarchy: await remote.getElements() };
1380
+ }
1381
+ catch {
1382
+ return {};
1383
+ }
1384
+ },
1355
1385
  },
1356
1386
  platform: health.platform,
1357
1387
  device: health.serial,
1358
1388
  remote: { url: server, version: health.version },
1359
1389
  };
1360
1390
  }
1391
+ /**
1392
+ * The one terminal-failure record for a non-ok engine result — `null` when the run
1393
+ * passed. Exported for the unit suite.
1394
+ *
1395
+ * Budget and timeout aborts come back from the engine as a bare flag with NO `failure`
1396
+ * object, so their reason is composed here; `where` is `run` because the abort is not
1397
+ * attributable to one node. Both the recorded failure and the `[ai] …` status line are
1398
+ * built from this, so the archive and the console cannot disagree about why a run died.
1399
+ */
1400
+ function terminalFailure(r, opts) {
1401
+ if (r.ok)
1402
+ return null;
1403
+ if (r.abortedForEnv) {
1404
+ return { where: r.failure?.where ?? 'run', reason: r.failure?.reason ?? 'device unavailable', kind: 'env' };
1405
+ }
1406
+ if (r.abortedForBudget) {
1407
+ return { where: r.failure?.where ?? 'run', reason: `cost ceiling $${opts.maxCostUsd} reached`, kind: 'budget' };
1408
+ }
1409
+ if (r.abortedForTimeout) {
1410
+ return { where: 'run', reason: `run timeout (${Math.round(opts.timeoutMs / 1000)}s) reached`, kind: 'timeout' };
1411
+ }
1412
+ return { where: r.failure?.where ?? 'run', reason: r.failure?.reason ?? 'failed', kind: 'fail' };
1413
+ }
1414
+ /** The `[ai] …` console verdict for a terminal failure, phrased as it always was. */
1415
+ function terminalStatusLine(t) {
1416
+ if (t.kind === 'fail')
1417
+ return `FAIL at ${t.where}: ${t.reason}`;
1418
+ return `ABORTED — ${t.kind === 'env' ? `environment: ${t.reason}` : t.reason}`;
1419
+ }
1361
1420
  /**
1362
1421
  * Best-effort archive-time device-log capture via an ExecBackend (local driver or
1363
1422
  * remote `/v1/logs`). Writes `artifacts/logcat.txt` onto the active run so a later
@@ -1470,6 +1529,13 @@ async function runAiTest(file, opts, backend, platform, device) {
1470
1529
  // seal the run so it is not left dangling in .verikun/run/ for the next command
1471
1530
  // to roll over. Then let the error map to an exit code as usual.
1472
1531
  run_1.Recorder.annotateRun({ ai: { ok: false, cost: cost.summaryLine(), modelRepairs: 0, improvements: [] } });
1532
+ // No evidence capture here: a throw at this level usually IS the device dying, so
1533
+ // the capture would fail the same way and only add noise to the error path.
1534
+ run_1.Recorder.recordTerminalFailure({
1535
+ where: 'run',
1536
+ reason: e.message,
1537
+ kind: (0, errors_1.isEnvError)(e) ? 'env' : 'fail',
1538
+ });
1473
1539
  try {
1474
1540
  await prefetchArchiveLogs(backend);
1475
1541
  run_1.Recorder.archive();
@@ -1496,21 +1562,18 @@ async function runAiTest(file, opts, backend, platform, device) {
1496
1562
  (0, output_1.err)(`[ai] could not cache plan: ${e.message}`);
1497
1563
  }
1498
1564
  }
1565
+ // A failure the engine produced (a control node giving up, a budget/timeout abort)
1566
+ // never ran through a command, so nothing recorded it — without this the archive
1567
+ // declares the failed test green. Must come BEFORE the archive that renders it.
1568
+ const terminal = terminalFailure(result, opts);
1569
+ if (terminal)
1570
+ run_1.Recorder.recordTerminalFailure(terminal, await backend.captureFailure?.());
1499
1571
  run_1.Recorder.annotateRun({
1500
1572
  ai: { ok: result.ok, cost: costLine, modelRepairs: result.modelRepairs, improvements: result.improvements },
1501
1573
  });
1502
1574
  await prefetchArchiveLogs(backend);
1503
1575
  const { dir, xmlPath, htmlPath, state } = run_1.Recorder.archive();
1504
- const status = result.ok
1505
- ? 'PASS'
1506
- : result.abortedForBudget
1507
- ? `ABORTED — cost ceiling $${opts.maxCostUsd} reached`
1508
- : result.abortedForTimeout
1509
- ? `ABORTED — run timeout (${Math.round(opts.timeoutMs / 1000)}s) reached`
1510
- : result.abortedForEnv
1511
- ? `ABORTED — environment: ${result.failure?.reason}`
1512
- : `FAIL at ${result.failure?.where}: ${result.failure?.reason}`;
1513
- (0, output_1.err)(`[ai] ${status} · ${costLine}`);
1576
+ (0, output_1.err)(`[ai] ${terminal ? terminalStatusLine(terminal) : 'PASS'} · ${costLine}`);
1514
1577
  (0, output_1.err)(`[ai] report: ${htmlPath}`);
1515
1578
  if (result.improvements.length) {
1516
1579
  (0, output_1.err)(`[ai] ${result.improvements.length} suggested improvement(s) (also in the report):`);
package/dist/report.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // recorder (run.ts) / suite runner (suite.ts) own all the I/O. The RunState data
6
6
  // model lives in run.ts; we import the types only.
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.runFailure = runFailure;
8
9
  exports.toJUnitXml = toJUnitXml;
9
10
  exports.suiteTotals = suiteTotals;
10
11
  exports.toSuiteIndexJson = toSuiteIndexJson;
@@ -41,12 +42,40 @@ function resolvedLabel(s) {
41
42
  function fmtDuration(ms) {
42
43
  return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(2)} s`;
43
44
  }
45
+ /**
46
+ * The run's verdict, taken from the ENGINE rather than inferred from step statuses.
47
+ *
48
+ * A `vk ai` run can fail outside any command — a `repeat` that never sees its target,
49
+ * a budget/timeout abort — and those record no step, so a tally-only report declared
50
+ * the run green (issue #41). `Recorder.recordTerminalFailure` now writes `failure`, and
51
+ * `ai.ok` is the older, coarser signal we still fall back to; between them an
52
+ * unrecorded failure can no longer read as success.
53
+ */
54
+ function runFailure(run) {
55
+ if (run.failure)
56
+ return run.failure;
57
+ if (run.ai && !run.ai.ok)
58
+ return { where: 'run', reason: 'the run did not pass (no step recorded the failure)' };
59
+ return null;
60
+ }
44
61
  function counts(run) {
45
62
  const passed = run.steps.filter((s) => s.status === 'passed').length;
46
63
  const failures = run.steps.filter((s) => s.status === 'failed').length;
47
64
  const errors = run.steps.filter((s) => s.status === 'error').length;
48
65
  const timeMs = run.steps.reduce((a, s) => a + s.durationMs, 0);
49
- return { tests: run.steps.length, passed, failures, errors, timeMs };
66
+ // Belt and braces. Normally the terminal failure IS a step by the time we render, so
67
+ // this stays false; it fires only if the failure never reached the recorder, and then
68
+ // both renderers emit one extra entry — hence `tests` grows too, so the tally never
69
+ // disagrees with the testcase list it is supposed to describe.
70
+ const unrecorded = failures + errors === 0 && runFailure(run) !== null;
71
+ return {
72
+ tests: run.steps.length + (unrecorded ? 1 : 0),
73
+ passed,
74
+ failures: failures + (unrecorded ? 1 : 0),
75
+ errors,
76
+ timeMs,
77
+ unrecorded,
78
+ };
50
79
  }
51
80
  // --- JUnit ----------------------------------------------------------------
52
81
  function toJUnitXml(run) {
@@ -93,6 +122,12 @@ function toJUnitXml(run) {
93
122
  return ` <testcase ${attrs}>${body}\n </testcase>`;
94
123
  })
95
124
  .join('\n');
125
+ const f = runFailure(run);
126
+ const unrecordedCase = c.unrecorded && f
127
+ ? ` <testcase name="${xmlAttr(`run did not pass (${f.where})`)}" classname="verikun.run" time="0.000">` +
128
+ `\n <failure message="${xmlAttr(f.reason)}" type="AssertionFailure">${xmlText(`${f.where}: ${f.reason}`)}</failure>\n </testcase>`
129
+ : '';
130
+ const allCases = [cases, unrecordedCase].filter(Boolean).join('\n');
96
131
  const suiteAttrs = `name="${xmlAttr(run.name)}" tests="${c.tests}" failures="${c.failures}" ` +
97
132
  `errors="${c.errors}" time="${suiteTime}" timestamp="${xmlAttr(run.startedAt)}"`;
98
133
  const suiteExtras = [];
@@ -106,7 +141,7 @@ function toJUnitXml(run) {
106
141
  return (`<?xml version="1.0" encoding="UTF-8"?>\n` +
107
142
  `<testsuites name="verikun" tests="${c.tests}" failures="${c.failures}" errors="${c.errors}" time="${suiteTime}">\n` +
108
143
  `<testsuite ${suiteAttrs}>\n` +
109
- `${cases}\n` +
144
+ `${allCases}\n` +
110
145
  (suiteExtras.length
111
146
  ? ` <system-out>${xmlText(suiteExtras.join('\n'))}</system-out>\n`
112
147
  : '') +
@@ -151,6 +186,9 @@ const STYLE = `
151
186
  .aibox { background:#fff; border:1px solid var(--line); border-radius:8px; padding:12px 14px; margin-bottom:20px; font-size:13px; }
152
187
  .aibox .cost { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; color:var(--muted); margin-top:4px; }
153
188
  .aibox ul { margin:8px 0 0; padding-left:18px; }
189
+ .failbox { background:#fff; border:1px solid var(--fail); border-left-width:4px; border-radius:8px; padding:12px 14px; margin-bottom:20px; font-size:13px; }
190
+ .failbox .where { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; color:var(--muted); }
191
+ .failbox .why { color:var(--fail); margin-top:4px; }
154
192
  `;
155
193
  function aiPanelHtml(ai) {
156
194
  const improvements = ai.improvements.length
@@ -164,6 +202,14 @@ function aiPanelHtml(ai) {
164
202
  ${improvements}
165
203
  </div>`;
166
204
  }
205
+ /** The run-level failure, stated once at the top. This page is where a human looks
206
+ * first, so the verdict has to be visible without reading 26 green rows. */
207
+ function failBoxHtml(f) {
208
+ return `<div class="failbox">
209
+ <div><strong>This run did not pass.</strong> <span class="where">${htmlEsc(f.where)}</span></div>
210
+ <div class="why">${htmlEsc(f.reason)}</div>
211
+ </div>`;
212
+ }
167
213
  function stepHtml(s) {
168
214
  const detail = [];
169
215
  if (selectorLabel(s))
@@ -333,6 +379,7 @@ ${suite.tests.map((x) => suiteTestRow(x, linkBase)).join('\n')}
333
379
  */
334
380
  function toHtml(run, opts = {}) {
335
381
  const c = counts(run);
382
+ const failure = runFailure(run);
336
383
  const chips = [
337
384
  `<span class="chip pass">${c.passed} passed</span>`,
338
385
  c.failures ? `<span class="chip fail">${c.failures} failed</span>` : '',
@@ -376,9 +423,27 @@ function toHtml(run, opts = {}) {
376
423
  <div class="summary">
377
424
  ${chips}
378
425
  </div>
426
+ ${failure ? failBoxHtml(failure) : ''}
379
427
  ${run.ai ? aiPanelHtml(run.ai) : ''}
380
428
  <ol class="steps">
381
- ${run.steps.map(stepHtml).join('\n ')}
429
+ ${[
430
+ ...run.steps.map(stepHtml),
431
+ // Only when the failure reached no step — otherwise it is already a red row.
432
+ ...(c.unrecorded && failure
433
+ ? [
434
+ stepHtml({
435
+ index: run.steps.length,
436
+ command: 'ai',
437
+ name: `run did not pass (${failure.where})`,
438
+ startedAt: run.startedAt,
439
+ durationMs: 0,
440
+ status: 'failed',
441
+ exitCode: 1,
442
+ message: failure.reason,
443
+ }),
444
+ ]
445
+ : []),
446
+ ].join('\n ')}
382
447
  </ol>${appLogPanel}
383
448
  </div>
384
449
  </body>
package/dist/run.js CHANGED
@@ -105,6 +105,19 @@ function loadState(dir) {
105
105
  return null;
106
106
  }
107
107
  }
108
+ /** Relative path of a step's failure screenshot. One place, because a synthetic
109
+ * terminal-failure step has to land on the same convention as a recorded one. */
110
+ const failImagePath = (index) => `artifacts/step-${index}-fail.png`;
111
+ function writeArtifactTo(dir, rel, buf) {
112
+ (0, node_fs_1.mkdirSync)((0, node_path_1.join)(dir, 'artifacts'), { recursive: true });
113
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(dir, rel), buf);
114
+ }
115
+ /** Compact hierarchy text, HEAD-capped — a failure dump is read top-down, so the
116
+ * first screenful is the useful part (unlike logs, which are kept tail-first). */
117
+ function capHierarchy(els) {
118
+ const text = (0, format_1.formatCompact)(els);
119
+ return text.length > HIERARCHY_CAP ? text.slice(0, HIERARCHY_CAP) + '\n…(truncated)' : text;
120
+ }
108
121
  function saveState(dir, state) {
109
122
  (0, node_fs_1.mkdirSync)(dir, { recursive: true });
110
123
  (0, node_fs_1.writeFileSync)(statePath(dir), JSON.stringify(state, null, 2));
@@ -427,8 +440,8 @@ class Recorder {
427
440
  if (!driver)
428
441
  return;
429
442
  try {
430
- this.writeArtifact(`artifacts/step-${this.step.index}-fail.png`, driver.screenshot());
431
- this.step.failImage = `artifacts/step-${this.step.index}-fail.png`;
443
+ this.writeArtifact(failImagePath(this.step.index), driver.screenshot());
444
+ this.step.failImage = failImagePath(this.step.index);
432
445
  }
433
446
  catch (e) {
434
447
  // Best-effort evidence: the device may be gone (often why the step failed). Surface
@@ -437,8 +450,7 @@ class Recorder {
437
450
  (0, output_1.err)(`[verikun] could not capture failure screenshot (${e.message})`);
438
451
  }
439
452
  try {
440
- const text = (0, format_1.formatCompact)(driver.getElements({ all: false }));
441
- this.step.failHierarchy = text.length > HIERARCHY_CAP ? text.slice(0, HIERARCHY_CAP) + '\n…(truncated)' : text;
453
+ this.step.failHierarchy = capHierarchy(driver.getElements({ all: false }));
442
454
  }
443
455
  catch (e) {
444
456
  if (!quiet)
@@ -450,8 +462,7 @@ class Recorder {
450
462
  this.sink[rel] = buf;
451
463
  return;
452
464
  }
453
- (0, node_fs_1.mkdirSync)((0, node_path_1.join)(this.dir, 'artifacts'), { recursive: true });
454
- (0, node_fs_1.writeFileSync)((0, node_path_1.join)(this.dir, rel), buf);
465
+ writeArtifactTo(this.dir, rel, buf);
455
466
  }
456
467
  commit() {
457
468
  this.step.durationMs = Date.now() - this.startMs;
@@ -647,6 +658,56 @@ class Recorder {
647
658
  last.message = message;
648
659
  saveState(dir, state);
649
660
  }
661
+ /**
662
+ * Record a terminal failure the `vk ai` ENGINE produced rather than a command — a
663
+ * control node that gave up (`repeat` exhausted, `when` matched no branch), a
664
+ * budget/timeout abort, an engine-internal throw. None of those go through
665
+ * beginStep, so before this existed nothing marked the run red and the archived
666
+ * report declared a failed test fully green (issue #41).
667
+ *
668
+ * Always records the run-level verdict. Appends a synthetic failed step ONLY when
669
+ * no step is already red — a leaf failure carries its own step and evidence, and
670
+ * counting it twice would be its own kind of lie.
671
+ */
672
+ static recordTerminalFailure(failure, evidence) {
673
+ if (process.env.VERIKUN_NO_RUN)
674
+ return;
675
+ const dir = activeDir();
676
+ const state = loadState(dir);
677
+ if (!state)
678
+ return;
679
+ state.failure = { where: failure.where, reason: failure.reason };
680
+ if (state.steps.every((s) => s.status === 'passed')) {
681
+ const index = state.steps.length;
682
+ // An environment abort is exit 3 (the box is broken, not the app), matching
683
+ // what `vk ai` itself returns; everything else is an exit-1 assertion failure.
684
+ const env = failure.kind === 'env';
685
+ const step = {
686
+ index,
687
+ command: 'ai',
688
+ name: `ai ${env ? 'aborted' : 'failed'} at ${failure.where}`,
689
+ startedAt: nowIso(),
690
+ durationMs: 0,
691
+ status: env ? 'error' : 'failed',
692
+ exitCode: env ? 3 : 1,
693
+ message: failure.reason,
694
+ };
695
+ if (evidence?.png) {
696
+ try {
697
+ writeArtifactTo(dir, failImagePath(index), evidence.png);
698
+ step.failImage = failImagePath(index);
699
+ }
700
+ catch (e) {
701
+ (0, output_1.err)(`[verikun] could not write the failure screenshot (${e.message})`);
702
+ }
703
+ }
704
+ if (evidence?.hierarchy)
705
+ step.failHierarchy = capHierarchy(evidence.hierarchy);
706
+ state.steps.push(step);
707
+ }
708
+ state.updatedAt = nowIso();
709
+ saveState(dir, state);
710
+ }
650
711
  static start(name, platform, device, force) {
651
712
  const dir = activeDir();
652
713
  const existing = loadState(dir);
package/dist/version.js CHANGED
@@ -3,4 +3,4 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.VERSION = void 0;
4
4
  // GENERATED by scripts/gen-version.mjs from package.json's "version" at build time
5
5
  // (the `prebuild` script). Do NOT edit by hand; bump package.json instead.
6
- exports.VERSION = '0.18.0';
6
+ exports.VERSION = '0.18.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "verikun",
3
- "version": "0.18.0",
3
+ "version": "0.18.1",
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",