verikun 0.16.0 → 0.17.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
@@ -104,7 +104,7 @@ vk screenshot # -> ./.verikun/screen.png
104
104
  | Command | Description |
105
105
  |---|---|
106
106
  | `ai <file> [--model m] [--max-cost-usd n] [--timeout dur] [--cost-override in/out] [--effort e] [--package pkg] [--app-build id] [--server url] [--show-plan] [--recompile] [--json]` | Run a plain-English test: compile it to a deterministic plan once, replay it model-free, and self-heal failures via the model. Needs `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` (per model), or no key with `--model codex-cli` / `cursor-cli` (a logged-in `codex` / `cursor-agent` CLI). See [AI](#ai--natural-language-tests). |
107
- | `suite <dir> [--app <id>] [--name n] [--server url] [--json]` (+ all `ai` flags) | Run every `*.md` in `<dir>` as one sequential suite with an overview report and a non-zero exit on failure — the CI gate. See [Suites](#suites--run-a-directory-of-tests). |
107
+ | `suite <dir> [--app <id>] [--name n] [--retries n] [--server url] [--json]` (+ all `ai` flags) | Run every `*.md` in `<dir>` as one sequential suite with an overview report and a non-zero exit on failure — the CI gate. See [Suites](#suites--run-a-directory-of-tests). |
108
108
 
109
109
  ### Remote
110
110
  | Command | Description |
@@ -287,12 +287,29 @@ vk suite tests/ --app com.example.app --server "$VERIKUN_SERVER" # remote devi
287
287
  - **Each test is a full `vk ai` run** — plan cache, self-healing, cost budget, and
288
288
  its own archived JUnit + HTML report under `./.verikun/runs/<id>/`. A test that
289
289
  fails (or errors) doesn't stop the suite; the rest still run.
290
- - **But a broken *environment* does stop it.** If a test dies from an environment
291
- error (exit 3tool gone, device unplugged, server unreachable), the toolchain is
292
- re-probed; only if it is *still* broken does the suite abort. That re-probe matters:
293
- a transient `uiautomator` dump failure also exits 3, and shouldn't vaporize a
294
- 20-test run. Continuing on a genuinely dead box just produces one identical red row
295
- per remaining test noise that reads exactly like a mass regression.
290
+ - **`--retries N` recovers from flakes.** A failed test is re-run up to N times
291
+ (default `0`opt-in, so CI cost/time stay predictable). If a later attempt
292
+ passes, the suite exits `0` and the flake is a **warning**, not a hard failure.
293
+ Failed attempt archives stay linked from the suite overview (`attempts` on the
294
+ test row + a `warnings` list on the manifest), so flakiness remains visible.
295
+ Cost and duration sum across attempts.
296
+ - **What earns a retry:** anything that might come out differently — a flaky
297
+ selector, a wedged app, and **a broken environment**, including a `vk server`
298
+ connection dropping mid-suite. The bias is intentional: an attempt costs one test,
299
+ giving up costs the whole suite plus a human rerunning it. Environment retries wait
300
+ a little longer each time (an outage that survives the health probe usually needs
301
+ seconds, not milliseconds) and each one lands in `warnings`, so riding out a wobble
302
+ is never silent. Exactly two failures are never retried, because a rerun cannot
303
+ change them: a **budget abort** (each attempt gets its own ceiling, so it would just
304
+ re-abort having spent twice) and a **usage error** (exit `2` — an unreadable test
305
+ file, a payload the server refuses).
306
+ - **But a broken *environment* does stop it, once the attempts are gone.** If a test
307
+ dies from an environment error (exit 3 — tool gone, device unplugged, server
308
+ unreachable), the toolchain is re-probed; only if it is *still* broken **and** no
309
+ retries remain does the suite abort. That re-probe matters: a transient
310
+ `uiautomator` dump failure also exits 3, and shouldn't vaporize a 20-test run.
311
+ Continuing on a genuinely dead box just produces one identical red row per remaining
312
+ test — noise that reads exactly like a mass regression.
296
313
  - **The suite writes an overview** to `./.verikun/suites/<id>/`:
297
314
  - **`index.json`** — a stable, `schemaVersion`ed manifest: per-test pass/fail,
298
315
  steps, model repairs, cost, duration, and the run id, plus suite totals. This
@@ -300,17 +317,21 @@ vk suite tests/ --app com.example.app --server "$VERIKUN_SERVER" # remote devi
300
317
  it (see the [CI recipe](#ci-recipe)) instead of verikun growing upload plugins.
301
318
  On an abort it also carries `aborted: {reason, notRun}`; the not-run tests get
302
319
  **no rows and no place in `totals`**, so `passed + failed === tests` still holds
303
- and nothing downstream mistakes a skipped test for a regression.
320
+ and nothing downstream mistakes a skipped test for a regression. Retried flakes
321
+ add `flaky` / `attempts` on the test row and suite-level `warnings` (additive;
322
+ `schemaVersion` stays `1`).
304
323
  - **`index.html`** — a summary page linking every test's `report.html`, with a
305
- banner naming the not-run tests when the suite aborted.
306
- - **Exit code is the CI gate:** `0` all green · `1` a test failed · `2` bad/empty
307
- directory · `3` environment (the provider or the device toolchain is unavailable,
308
- or the box broke mid-run). The `1`-vs-`3` split is the point: `1` is a regression
309
- to investigate, `3` is a machine to fix. All `ai` flags (`--model`,
310
- `--max-cost-usd`, `--timeout`, …) apply to every test; both the provider
311
- (`ANTHROPIC_API_KEY` / `OPENAI_API_KEY`, or the `codex` / `cursor-agent` CLI for
312
- `--model codex-cli` / `cursor-cli`) **and** the device toolchain (`adb` / `idb` +
313
- a resolvable device) are checked up front, before anything is compiled.
324
+ banner naming the not-run tests when the suite aborted, and a warnings banner
325
+ when a flake recovered on retry (prior failed attempts stay linked).
326
+ - **Exit code is the CI gate:** `0` all green (including flakes that recovered with
327
+ `--retries`) · `1` a test failed · `2` bad/empty directory · `3` environment (the
328
+ provider or the device toolchain is unavailable, or the box broke mid-run). The
329
+ `1`-vs-`3` split is the point: `1` is a regression to investigate, `3` is a
330
+ machine to fix. All `ai` flags (`--model`, `--max-cost-usd`, `--timeout`, …)
331
+ apply to every test; both the provider (`ANTHROPIC_API_KEY` / `OPENAI_API_KEY`,
332
+ or the `codex` / `cursor-agent` CLI for `--model codex-cli` / `cursor-cli`)
333
+ **and** the device toolchain (`adb` / `idb` + a resolvable device) are checked up
334
+ front, before anything is compiled.
314
335
 
315
336
  ## Remote devices — `vk server`
316
337
 
package/dist/cli.js CHANGED
@@ -1537,7 +1537,7 @@ async function cmdInstall(positionals, flags) {
1537
1537
  async function cmdSuiteEntry(positionals, flags) {
1538
1538
  const dirArg = positionals[0];
1539
1539
  if (!dirArg)
1540
- throw new errors_1.CliError('Usage: verikun suite <dir> [--app <id>] [--server url] [--name n] [--json]', 2);
1540
+ throw new errors_1.CliError('Usage: verikun suite <dir> [--app <id>] [--server url] [--name n] [--retries n] [--json]', 2);
1541
1541
  const opts = parseAiOptions(flags);
1542
1542
  // Pre-flight the provider BEFORE touching any device/server: every test needs it
1543
1543
  // to compile (on a cache miss) or to repair at runtime.
@@ -1849,14 +1849,19 @@ AI (run a natural-language test — compile once, replay model-free, self-heal)
1849
1849
  codex-cli | cursor-cli.
1850
1850
 
1851
1851
  SUITE (run a directory of natural-language tests as one gated suite)
1852
- suite <dir> [--app <id>] [--name n] [--json] (+ all \`ai\` flags, incl. --server)
1852
+ suite <dir> [--app <id>] [--name n] [--retries n] [--json]
1853
+ (+ all \`ai\` flags, incl. --server)
1853
1854
  Run every *.md in <dir> (lexicographic order —
1854
1855
  prefix 01-, 02- to sequence; README.md skipped)
1855
1856
  through \`vk ai\`. With --app, app data is reset
1856
- between tests (iOS: force-stop). Writes a suite
1857
- overview to ./.verikun/suites/<id>/{index.json,
1858
- index.html} linking each test's report. Exits 1
1859
- if any test failed the CI gate.
1857
+ between tests (iOS: force-stop). --retries N
1858
+ re-runs a failed test up to N times; a later
1859
+ pass recovers the suite (exit 0) and surfaces a
1860
+ warning, keeping failed-attempt evidence in the
1861
+ report. Writes a suite overview to
1862
+ ./.verikun/suites/<id>/{index.json, index.html}
1863
+ linking each test's report. Exits 1 if any test
1864
+ failed — the CI gate.
1860
1865
 
1861
1866
  SERVER (expose a locally-connected device to remote verikun clients)
1862
1867
  server [--bind addr] [--port n] [--auth-key k] [--allow-install]
package/dist/report.js CHANGED
@@ -118,6 +118,7 @@ const STYLE = `
118
118
  .summary { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom: 20px; }
119
119
  .chip { font-weight:600; font-size:13px; padding:4px 10px; border-radius:999px; color:#fff; }
120
120
  .chip.pass{background:var(--pass)} .chip.fail{background:var(--fail)} .chip.err{background:var(--err)}
121
+ .chip.warn{background:var(--err)}
121
122
  .chip.muted{ background:#eaeef2; color:var(--muted); }
122
123
  ol.steps { list-style:none; margin:0; padding:0; }
123
124
  li.step { background:#fff; border:1px solid var(--line); border-left-width:4px; border-radius:8px; margin-bottom:10px; padding:12px 14px; }
@@ -206,19 +207,40 @@ const SUITE_STYLE = `
206
207
  table.tests td.num { text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; }
207
208
  table.tests a { color:inherit; }
208
209
  .fail-reason { color:var(--fail); font-size:12px; margin-top:2px; }
210
+ .flake-note { color:var(--err); font-size:12px; margin-top:2px; }
211
+ .attempts { margin-top:4px; font-size:12px; color:var(--muted); }
212
+ .attempts a { color:var(--fail); }
209
213
  .aborted { background:#fff4e5; border:1px solid #f0b429; border-radius:8px; padding:12px 14px; margin:0 0 14px; font-size:13px; }
210
214
  .aborted strong { color:#8a5300; }
211
215
  .aborted ul { margin:6px 0 0; padding-left:20px; color:var(--muted); }
216
+ .warnings { background:#fff8c5; border:1px solid #d4a72c; border-radius:8px; padding:12px 14px; margin:0 0 14px; font-size:13px; }
217
+ .warnings strong { color:#7d4e00; }
218
+ .warnings ul { margin:6px 0 0; padding-left:20px; color:var(--muted); }
212
219
  `;
220
+ function suiteAttemptLinks(attempts, linkBase) {
221
+ const links = attempts
222
+ .map((a, i) => {
223
+ const label = `attempt ${i + 1}`;
224
+ if (!a.id)
225
+ return htmlEsc(label);
226
+ return `<a href="${htmlEsc(`${linkBase}runs/${encodeURIComponent(a.id)}/report.html`)}">${htmlEsc(label)}</a>`;
227
+ })
228
+ .join(', ');
229
+ return `<div class="attempts">prior failed: ${links}</div>`;
230
+ }
213
231
  function suiteTestRow(t, linkBase) {
214
232
  // A test that errored before its run started (id '') has no report to link.
215
233
  const label = t.id
216
234
  ? `<a href="${htmlEsc(`${linkBase}runs/${encodeURIComponent(t.id)}/report.html`)}">${htmlEsc(t.name)}</a>`
217
235
  : htmlEsc(t.name);
218
236
  const failure = t.failure ? `<div class="fail-reason">${htmlEsc(t.failure)}</div>` : '';
237
+ const flake = t.flaky ? `<div class="flake-note">passed on retry (flake)</div>` : '';
238
+ const prior = t.attempts?.length ? suiteAttemptLinks(t.attempts, linkBase) : '';
239
+ const status = t.flaky ? 'FLAKY' : t.ok ? 'PASS' : 'FAIL';
240
+ const statusClass = t.ok ? 'passed' : 'failed';
219
241
  return ` <tr>
220
- <td><span class="st ${t.ok ? 'passed' : 'failed'}">${t.ok ? 'PASS' : 'FAIL'}</span></td>
221
- <td>${label}${failure}</td>
242
+ <td><span class="st ${statusClass}">${status}</span></td>
243
+ <td>${label}${flake}${failure}${prior}</td>
222
244
  <td class="num">${t.passedSteps}/${t.steps}${t.failedSteps ? ` (${t.failedSteps} failed)` : ''}</td>
223
245
  <td class="num">${t.modelRepairs || ''}</td>
224
246
  <td class="num">$${t.costUsd.toFixed(4)}</td>
@@ -233,9 +255,11 @@ function suiteTestRow(t, linkBase) {
233
255
  function toSuiteHtml(suite, opts = {}) {
234
256
  const linkBase = opts.linkBase ?? '../../';
235
257
  const t = suite.totals;
258
+ const flaky = suite.tests.filter((x) => x.flaky).length;
236
259
  const chips = [
237
260
  `<span class="chip pass">${t.passed} passed</span>`,
238
261
  t.failed ? `<span class="chip fail">${t.failed} failed</span>` : '',
262
+ flaky ? `<span class="chip warn">${flaky} flaky</span>` : '',
239
263
  suite.aborted ? `<span class="chip fail">ABORTED</span>` : '',
240
264
  `<span class="chip muted">${t.tests} tests &middot; ${t.steps} steps &middot; ${fmtDuration(t.durationMs)} &middot; $${t.costUsd.toFixed(4)}</span>`,
241
265
  ]
@@ -250,6 +274,13 @@ function toSuiteHtml(suite, opts = {}) {
250
274
  ${suite.aborted.notRun.length
251
275
  ? ` <ul>${suite.aborted.notRun.map((f) => `<li>${htmlEsc(f)} — not run</li>`).join('')}</ul>\n`
252
276
  : ''} </div>
277
+ `
278
+ : '';
279
+ const warningsBanner = suite.warnings?.length
280
+ ? ` <div class="warnings">
281
+ <strong>Warnings</strong>
282
+ <ul>${suite.warnings.map((w) => `<li>${htmlEsc(w)}</li>`).join('')}</ul>
283
+ </div>
253
284
  `
254
285
  : '';
255
286
  const metaBits = [
@@ -274,7 +305,7 @@ ${suite.aborted.notRun.length
274
305
  <div class="summary">
275
306
  ${chips}
276
307
  </div>
277
- ${abortedBanner} <table class="tests">
308
+ ${abortedBanner}${warningsBanner} <table class="tests">
278
309
  <thead><tr><th></th><th>Test</th><th>Steps</th><th>Repairs</th><th>Cost</th><th>Duration</th></tr></thead>
279
310
  <tbody>
280
311
  ${suite.tests.map((x) => suiteTestRow(x, linkBase)).join('\n')}
package/dist/suite.js CHANGED
@@ -13,6 +13,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
13
13
  exports.sortTestFiles = sortTestFiles;
14
14
  exports.listTestFiles = listTestFiles;
15
15
  exports.toSuiteResult = toSuiteResult;
16
+ exports.toSuiteAttempt = toSuiteAttempt;
17
+ exports.mergeSuiteAttempts = mergeSuiteAttempts;
16
18
  exports.cmdSuite = cmdSuite;
17
19
  const node_fs_1 = require("node:fs");
18
20
  const node_path_1 = require("node:path");
@@ -100,6 +102,64 @@ function toSuiteResult(file, r, durationMs) {
100
102
  ...(r.ok ? {} : { failure: failure ?? 'failed' }),
101
103
  };
102
104
  }
105
+ /** Compact one attempt for the `attempts` evidence array (pure). */
106
+ function toSuiteAttempt(r) {
107
+ return {
108
+ id: r.id,
109
+ ok: r.ok,
110
+ durationMs: r.durationMs,
111
+ costUsd: r.costUsd,
112
+ ...(r.failure ? { failure: r.failure } : {}),
113
+ };
114
+ }
115
+ /**
116
+ * Merge a sequence of attempt rows into the final suite row: primary `id` is the last
117
+ * attempt (winning green, or last red), cost/duration/repairs sum across attempts, and
118
+ * prior attempts are retained as flake evidence.
119
+ */
120
+ function mergeSuiteAttempts(attempts) {
121
+ if (attempts.length === 0)
122
+ throw new Error('mergeSuiteAttempts: empty');
123
+ const last = attempts[attempts.length - 1];
124
+ if (attempts.length === 1)
125
+ return last;
126
+ const round = (n) => Number(n.toFixed(4));
127
+ const prior = attempts.slice(0, -1).map(toSuiteAttempt);
128
+ const flaky = last.ok && prior.some((a) => !a.ok);
129
+ return {
130
+ ...last,
131
+ durationMs: attempts.reduce((a, t) => a + t.durationMs, 0),
132
+ costUsd: round(attempts.reduce((a, t) => a + t.costUsd, 0)),
133
+ modelRepairs: attempts.reduce((a, t) => a + t.modelRepairs, 0),
134
+ attempts: prior,
135
+ ...(flaky ? { flaky: true } : {}),
136
+ };
137
+ }
138
+ // What --retries will and won't spend an attempt on. The bias is deliberate and
139
+ // asymmetric: a retry costs one test, while giving up costs the whole suite plus a
140
+ // human rerunning it. So the rule is *retry unless a rerun provably cannot change the
141
+ // outcome* — the two predicates below are the only "provably" cases, everything else
142
+ // (flaky selector, wedged app, a wobbling network to `vk server`) earns another go.
143
+ /** Budget aborts won't heal on retry: each attempt gets its own cost ceiling, so a
144
+ * rerun just re-aborts at the same place having spent the money twice. */
145
+ function isRetryable(r) {
146
+ return !r.ok && !r.abortedForBudget;
147
+ }
148
+ /** A thrown USAGE error (exit 2) is the one throw a rerun cannot change — an unreadable
149
+ * test file, a payload the server refuses, a flag it doesn't understand. Everything
150
+ * else, including every environment error, is retried while attempts remain. */
151
+ function isRetryableThrow(e) {
152
+ return !(e instanceof errors_1.CliError && e.exitCode === 2);
153
+ }
154
+ function parseRetries(flags) {
155
+ const n = (0, args_1.flagNum)(flags, 'retries');
156
+ if (n === undefined)
157
+ return 0;
158
+ if (!Number.isInteger(n) || n < 0) {
159
+ throw new errors_1.CliError(`--retries must be a non-negative integer, got '${n}'`, 2);
160
+ }
161
+ return n;
162
+ }
103
163
  async function cmdSuite(dirArg, flags, deps) {
104
164
  const dir = (0, node_path_1.resolve)(process.cwd(), dirArg);
105
165
  if (!(0, node_fs_1.existsSync)(dir) || !(0, node_fs_1.statSync)(dir).isDirectory()) {
@@ -109,69 +169,133 @@ async function cmdSuite(dirArg, flags, deps) {
109
169
  if (files.length === 0) {
110
170
  throw new errors_1.CliError(`suite: no test files (*.md) in '${dirArg}'`, 2);
111
171
  }
172
+ const retries = parseRetries(flags);
112
173
  const suiteId = (0, run_1.runId)();
113
174
  const name = (0, args_1.flagStr)(flags, 'name') || (0, node_path_1.basename)(dir);
114
175
  const startedAt = new Date().toISOString();
115
- (0, output_1.err)(`[suite] '${name}': ${files.length} test(s) from ${dirArg} (${deps.platform}${deps.device ? ` · ${deps.device}` : ''})`);
176
+ (0, output_1.err)(`[suite] '${name}': ${files.length} test(s) from ${dirArg} (${deps.platform}${deps.device ? ` · ${deps.device}` : ''})${retries > 0 ? ` · up to ${retries} retry(ies) on failure` : ''}`);
116
177
  const results = [];
178
+ const warnings = [];
117
179
  let aborted;
180
+ async function resetApp(label) {
181
+ // Returns the abort reason when the suite should stop (confirmed env break during reset).
182
+ if (!deps.reset)
183
+ return undefined;
184
+ try {
185
+ await deps.reset();
186
+ (0, output_1.err)(`[suite] app state reset${label}`);
187
+ return undefined;
188
+ }
189
+ catch (e) {
190
+ // A reset that failed because the BOX is broken means nothing after it is
191
+ // trustworthy — but only if a re-probe agrees. Otherwise surface and continue:
192
+ // a flaky reset should not zero out the whole suite, and the test itself will
193
+ // fail loudly if the stale state actually matters.
194
+ const broken = (0, errors_1.isEnvError)(e) ? await stillBroken(deps) : undefined;
195
+ if (broken)
196
+ return broken;
197
+ (0, output_1.err)(`[suite] reset failed (${e.message}) — continuing`);
198
+ return undefined;
199
+ }
200
+ }
201
+ /** A confirmed env break with attempts left: say so, pause, and let the loop retry.
202
+ * The pause matters — the failures this rides out (a server restart, a wifi drop, a
203
+ * USB re-enumeration) clear in seconds, and retrying into the same dead socket
204
+ * immediately would burn every attempt inside the outage. */
205
+ async function noteEnvRetry(file, attempt, reason) {
206
+ const warn = `${file}: environment error on attempt ${attempt + 1} (${reason}) — retried`;
207
+ warnings.push(warn);
208
+ (0, output_1.err)(`[suite] WARN ${warn}`);
209
+ await sleep((deps.probeRetryMs ?? PROBE_RETRY_MS) * (attempt + 1));
210
+ }
118
211
  for (let i = 0; i < files.length && !aborted; i++) {
119
212
  const file = files[i];
120
213
  (0, output_1.err)(`[suite] ── (${i + 1}/${files.length}) ${file} ──`);
121
- if (deps.reset) {
214
+ const attemptRows = [];
215
+ for (let attempt = 0; attempt <= retries; attempt++) {
216
+ // The last attempt is where a retryable failure becomes the verdict: a confirmed
217
+ // env break aborts the suite, anything else stands as this test's failed row.
218
+ const lastAttempt = attempt === retries;
219
+ if (attempt > 0)
220
+ (0, output_1.err)(`[suite] retry ${attempt}/${retries} for ${file}`);
221
+ // Re-isolate before EVERY attempt — between tests and between retries alike.
222
+ const resetBreak = await resetApp(attempt > 0 ? ' (retry)' : '');
223
+ if (resetBreak) {
224
+ if (!lastAttempt) {
225
+ await noteEnvRetry(file, attempt, `reset failed: ${resetBreak}`);
226
+ continue;
227
+ }
228
+ // With no attempt row this test never ran, so notRun starts at the CURRENT file.
229
+ aborted = {
230
+ reason: `reset failed: ${resetBreak}`,
231
+ notRun: files.slice(attemptRows.length ? i + 1 : i),
232
+ };
233
+ break;
234
+ }
235
+ const t0 = Date.now();
122
236
  try {
123
- await deps.reset();
124
- (0, output_1.err)('[suite] app state reset');
237
+ const r = await deps.runTest((0, node_path_1.join)(dir, file));
238
+ attemptRows.push(toSuiteResult(file, r, Date.now() - t0));
239
+ if (r.abortedForEnv) {
240
+ const broken = await stillBroken(deps);
241
+ if (broken) {
242
+ if (!lastAttempt) {
243
+ // Even a CONFIRMED break is worth an attempt: the probe window is a couple
244
+ // of seconds, which a server restart outlives — and aborting costs the run.
245
+ await noteEnvRetry(file, attempt, broken);
246
+ continue;
247
+ }
248
+ aborted = { reason: broken, notRun: files.slice(i + 1) };
249
+ break;
250
+ }
251
+ // Transient env blip: retryable like any other failure.
252
+ }
253
+ if (r.ok || !isRetryable(r) || lastAttempt)
254
+ break;
125
255
  }
126
256
  catch (e) {
127
- // A reset that failed because the BOX is broken means nothing after it is
128
- // trustworthybut 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.
257
+ // A test that THREW (device gone, server unreachable, bad file) still becomes a
258
+ // failed row one broken test must not vaporize the suite report for the tests
259
+ // that already ran. Out of attempts, a confirmed env break stops the suite.
260
+ const msg = e instanceof Error ? e.message : String(e);
261
+ (0, output_1.err)(`[suite] ${file} errored: ${msg}`);
262
+ attemptRows.push({
263
+ id: '',
264
+ file,
265
+ name: (0, node_path_1.basename)(file, (0, node_path_1.extname)(file)),
266
+ ok: false,
267
+ durationMs: Date.now() - t0,
268
+ costUsd: 0,
269
+ steps: 0,
270
+ passedSteps: 0,
271
+ failedSteps: 0,
272
+ modelRepairs: 0,
273
+ failure: msg.split('\n')[0],
274
+ });
131
275
  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) };
276
+ if (lastAttempt) {
277
+ if (broken)
278
+ aborted = { reason: broken, notRun: files.slice(i + 1) };
135
279
  break;
136
280
  }
137
- (0, output_1.err)(`[suite] reset failed (${e.message}) — continuing`);
138
- }
139
- }
140
- const t0 = Date.now();
141
- try {
142
- const r = await deps.runTest((0, node_path_1.join)(dir, file));
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);
281
+ if (!isRetryableThrow(e))
282
+ break;
149
283
  if (broken)
150
- aborted = { reason: broken, notRun: files.slice(i + 1) };
284
+ await noteEnvRetry(file, attempt, broken);
151
285
  }
152
286
  }
153
- catch (e) {
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.
157
- const msg = e instanceof Error ? e.message : String(e);
158
- (0, output_1.err)(`[suite] ${file} errored: ${msg}`);
159
- results.push({
160
- id: '',
161
- file,
162
- name: (0, node_path_1.basename)(file, (0, node_path_1.extname)(file)),
163
- ok: false,
164
- durationMs: Date.now() - t0,
165
- costUsd: 0,
166
- steps: 0,
167
- passedSteps: 0,
168
- failedSteps: 0,
169
- modelRepairs: 0,
170
- failure: msg.split('\n')[0],
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) };
287
+ if (attemptRows.length === 0) {
288
+ // Every attempt was blocked by a failing reset, so the test never ran and gets no
289
+ // row — `aborted.notRun` (set above) already names it. Nothing to merge.
290
+ break;
291
+ }
292
+ const merged = mergeSuiteAttempts(attemptRows);
293
+ results.push(merged);
294
+ if (merged.flaky) {
295
+ const n = merged.attempts?.length ?? 0;
296
+ const warn = `${file} passed on retry after ${n} failed attempt${n === 1 ? '' : 's'}`;
297
+ warnings.push(warn);
298
+ (0, output_1.err)(`[suite] WARN ${warn}`);
175
299
  }
176
300
  }
177
301
  if (aborted) {
@@ -189,6 +313,7 @@ async function cmdSuite(dirArg, flags, deps) {
189
313
  totals: (0, report_1.suiteTotals)(results),
190
314
  tests: results,
191
315
  ...(aborted ? { aborted } : {}),
316
+ ...(warnings.length ? { warnings } : {}),
192
317
  };
193
318
  // .verikun/suites/<id>/ sits beside .verikun/runs/<id>/, so index.html reaches a
194
319
  // test report at ../../runs/<id>/report.html — the linkBase below.
@@ -198,8 +323,12 @@ async function cmdSuite(dirArg, flags, deps) {
198
323
  (0, node_fs_1.writeFileSync)((0, node_path_1.join)(outDir, 'index.html'), (0, report_1.toSuiteHtml)(suite, { linkBase: '../../' }));
199
324
  const t = suite.totals;
200
325
  (0, output_1.err)(`[suite] ${t.passed}/${t.tests} passed · ${t.steps} steps · $${t.costUsd.toFixed(4)} · ${(t.durationMs / 1000).toFixed(1)}s`);
201
- for (const r of results)
202
- (0, output_1.err)(` ${r.ok ? 'PASS' : 'FAIL'} ${r.file}${r.failure ? ` — ${r.failure}` : ''}`);
326
+ for (const r of results) {
327
+ const tag = r.flaky ? 'FLAKY' : r.ok ? 'PASS' : 'FAIL';
328
+ (0, output_1.err)(` ${tag} ${r.file}${r.failure ? ` — ${r.failure}` : r.flaky ? ' — passed on retry' : ''}`);
329
+ }
330
+ if (warnings.length)
331
+ (0, output_1.err)(`[suite] ${warnings.length} warning(s)`);
203
332
  (0, output_1.err)(`[suite] overview: ${(0, node_path_1.join)(outDir, 'index.html')}`);
204
333
  if ((0, args_1.flagBool)(flags, 'json'))
205
334
  (0, output_1.json)(suite);
@@ -207,6 +336,7 @@ async function cmdSuite(dirArg, flags, deps) {
207
336
  (0, output_1.out)(outDir); // primary machine result: the suite directory
208
337
  // The CI gate: any failed test fails the invocation (mirrors `vk run archive`). An
209
338
  // environment abort exits 3 instead, so CI can tell "the runner is broken" from "the
210
- // app regressed" — the whole point of stopping early.
339
+ // app regressed" — the whole point of stopping early. A flake that recovered is ok
340
+ // (exit 0) with a warning — that is the whole point of --retries.
211
341
  return aborted ? 3 : t.failed > 0 ? 1 : 0;
212
342
  }
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.16.0';
6
+ exports.VERSION = '0.17.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "verikun",
3
- "version": "0.16.0",
3
+ "version": "0.17.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",