verikun 0.10.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
@@ -233,6 +233,14 @@ like permission dialogs) and **bounded loops** (`repeat … until`, e.g. scroll
233
233
  row appears) — control flow a flat [`batch`](#batch) script can't express. Loops carry
234
234
  a hard iteration cap and stop early if the screen stops changing.
235
235
 
236
+ An `if-present` guard **waits for its selector to settle** before deciding the optional UI
237
+ isn't there, so a dialog that animates in a beat after the transition is still caught. The
238
+ window guarantees at least two looks at the screen (wall clock alone isn't a usable unit —
239
+ a UI dump ranges from ~200ms on a fast phone to ~2.5s on an emulator), so an absent guard
240
+ costs about one extra dump. `VERIKUN_GUARD_SETTLE_MS` tunes it; `0` restores the old
241
+ single-shot probe. A loop's own exit check never pays this window — it's absent on every
242
+ iteration by construction, which is what makes it a loop.
243
+
236
244
  - **Progress streams to stderr** (so a CI job never goes silent); **stdout is the
237
245
  report path** (or a JSON summary with `--json`). The compiled plan is logged to the
238
246
  run before it executes, for troubleshooting.
@@ -278,16 +286,30 @@ vk suite tests/ --app com.example.app --server "$VERIKUN_SERVER" # remote devi
278
286
  - **Each test is a full `vk ai` run** — plan cache, self-healing, cost budget, and
279
287
  its own archived JUnit + HTML report under `./.verikun/runs/<id>/`. A test that
280
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.
281
295
  - **The suite writes an overview** to `./.verikun/suites/<id>/`:
282
296
  - **`index.json`** — a stable, `schemaVersion`ed manifest: per-test pass/fail,
283
297
  steps, model repairs, cost, duration, and the run id, plus suite totals. This
284
298
  is the **output contract for reporting** — upload/publish steps compose over
285
299
  it (see the [CI recipe](#ci-recipe)) instead of verikun growing upload plugins.
286
- - **`index.html`** a summary page linking every test's `report.html`.
287
- - **Exit code is the CI gate:** `1` if any test failed, `0` all green, `2` bad/empty
288
- directory. All `ai` flags (`--model`, `--max-cost-usd`, `--timeout`, …) apply to
289
- every test; the provider (`ANTHROPIC_API_KEY` / `OPENAI_API_KEY`, or the `codex` /
290
- `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.
291
313
 
292
314
  ## Remote devices — `vk server`
293
315
 
@@ -367,7 +389,11 @@ class:Button simplified type ("Button") or full class ("android.widget.Button
367
389
  ```
368
390
 
369
391
  Modifiers: `--contains` makes text/desc matches substring-based; `--index N`
370
- selects the Nth match (0-based) when a selector intentionally matches several.
392
+ selects the Nth match (0-based) when a selector intentionally matches several;
393
+ `--enabled` matches only a control that is **actionable right now** — use it for a
394
+ Submit/Check button the app disables until a form is valid, since such a button is
395
+ present long before it is usable and tapping presence taps a dead control (with
396
+ auto-wait this reads as "wait until it is pressable").
371
397
  If a selector for an action matches more than one element and no `--index` is
372
398
  given, the command fails with exit code 2 and lists the candidates — it never
373
399
  taps a guess.
@@ -423,7 +449,7 @@ condition as a step in its own right.
423
449
  | `0` | success / found / assertion passed |
424
450
  | `1` | not found / assertion failed / wait timeout |
425
451
  | `2` | usage error or ambiguous selector (caller must refine) |
426
- | `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 |
427
453
 
428
454
  Data goes to stdout; diagnostics/errors go to stderr.
429
455
 
@@ -35,6 +35,12 @@ class ClaudeProvider {
35
35
  JSON.stringify(input.seed, null, 2));
36
36
  }
37
37
  parts.push('NATURAL-LANGUAGE TEST:\n' + input.nl);
38
+ if (input.retryFeedback) {
39
+ // Last, so it is the freshest thing in context: a previous compile of this same
40
+ // test lost something the prose stated. Naming it beats hoping the retry differs.
41
+ parts.push('YOUR PREVIOUS ATTEMPT AT THIS TEST WAS REJECTED. Fix this and emit the whole plan again:\n' +
42
+ input.retryFeedback);
43
+ }
38
44
  const { json, usage } = await this.call(grammar_1.GRAMMAR, parts.join('\n\n'), ir_1.PLAN_JSON_SCHEMA, 8192);
39
45
  return { plan: (0, ir_1.parsePlan)(json), usage };
40
46
  }
@@ -156,6 +156,12 @@ class CliProvider {
156
156
  JSON.stringify(input.seed, null, 2));
157
157
  }
158
158
  parts.push('NATURAL-LANGUAGE TEST:\n' + input.nl);
159
+ if (input.retryFeedback) {
160
+ // Last, so it is the freshest thing in context: a previous compile of this same
161
+ // test lost something the prose stated. Naming it beats hoping the retry differs.
162
+ parts.push('YOUR PREVIOUS ATTEMPT AT THIS TEST WAS REJECTED. Fix this and emit the whole plan again:\n' +
163
+ input.retryFeedback);
164
+ }
159
165
  const json = this.call(grammar_1.GRAMMAR, parts.join('\n\n'), ir_1.PLAN_JSON_SCHEMA);
160
166
  // usage:{} — a CLI is billed to the user's subscription, not per token, so cost is $0
161
167
  // (documented no-op for --max-cost-usd). The run is still bounded by maxRepairs + --timeout.
@@ -1,10 +1,34 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DEFAULT_RUN_TIMEOUT_MS = void 0;
3
+ exports.DEFAULT_GUARD_SETTLE_MS = exports.DEFAULT_RUN_TIMEOUT_MS = void 0;
4
4
  exports.runPlan = runPlan;
5
+ const node_crypto_1 = require("node:crypto");
5
6
  const selector_1 = require("../ui/selector");
6
7
  const errors_1 = require("../errors");
7
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
+ }
8
32
  const describe = (leaf) => [leaf.command, ...leaf.positionals, ...leaf.flags.map((f) => (f.value === 'true' ? `--${f.name}` : `--${f.name} ${f.value}`))]
9
33
  .join(' ')
10
34
  .trim();
@@ -13,23 +37,120 @@ const describe = (leaf) => [leaf.command, ...leaf.positionals, ...leaf.flags.map
13
37
  * fails (a device hiccup on screencap) must never turn a green run red — see
14
38
  * the guard in execLeaf. */
15
39
  const isScreenshotLeaf = (leaf) => leaf.command === 'screenshot' || leaf.command === 'shot';
16
- /** A structural fingerprint of the screen: sorted id+text+type set. Used for the
17
- * loop no-progress check — deliberately NOT the raw hierarchy (its node ordering
18
- * is nondeterministic between identical states, which would false-trip). */
40
+ /** A structural fingerprint of the screen: a sorted id+text+desc+type set. Used for the
41
+ * loop no-progress check — deliberately NOT the raw hierarchy (its node ordering is
42
+ * nondeterministic between identical states, which would false-trip).
43
+ *
44
+ * BOTH content fields are sampled, and that is load-bearing rather than belt-and-braces.
45
+ * Sampling `text` alone made this blind on Flutter apps, which map `Semantics(label:)` to
46
+ * Android's `contentDescription` — i.e. to `desc`, never to `text`. Measured on a live
47
+ * Flutter screen: 14 elements, **0** carrying `text`, 8 carrying `desc`. The fingerprint
48
+ * degenerated to `id||type` for the entire screen, so two completely different questions
49
+ * hashed byte-identically and a loop answering them correctly was declared stalled.
50
+ *
51
+ * Uses the full `id`, not `idShort` (the suffix after the last '/'), so elements from
52
+ * different packages/namespaces cannot collide in what is meant to be a fingerprint.
53
+ *
54
+ * When in doubt, sample MORE: an over-sensitive hash only costs a loop running to its cap;
55
+ * an under-sensitive one fails a passing test, since a stalled loop is now fatal. */
19
56
  function structuralHash(els) {
20
57
  return els
21
- .map((e) => `${e.idShort}|${e.text}|${e.type}`)
58
+ .map((e) => `${e.id}|${e.text}|${e.desc}|${e.type}`)
22
59
  .sort()
23
60
  .join('\n');
24
61
  }
62
+ /** An unresolvable {{...}} placeholder. Terminal and never healed: the model cannot
63
+ * repair a missing value, and substituting one would be inventing test data. */
64
+ class CtxError extends Error {
65
+ }
25
66
  function isHealable(outcome) {
26
67
  return (!!outcome.error &&
27
68
  (outcome.error instanceof errors_1.SelectorNotFoundError || outcome.error instanceof errors_1.AmbiguousSelectorError));
28
69
  }
29
70
  /** Default wall-clock ceiling for a whole `vk ai` run (overridable via --timeout). */
30
71
  exports.DEFAULT_RUN_TIMEOUT_MS = 15 * 60 * 1000;
72
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
73
+ /** How long a CONDITIONAL guard (`if-present`) waits for its selector to show up
74
+ * before concluding "absent". Interstitials animate in: a permission dialog or promo
75
+ * panel typically lands a few hundred ms after the transition that triggers it. Every
76
+ * selector-resolving leaf command already auto-waits ~5s (cli.ts resolveOneWaiting),
77
+ * so before this window existed a guard was strictly LESS patient than a bare `tap` —
78
+ * and an optional dialog could be missed by the very construct meant to catch it.
79
+ * Kept far below the leaf 5s because an ABSENT guard pays this window, every time, and
80
+ * skipping fast is the common case.
81
+ *
82
+ * NOTE the floor in present(): any non-zero window buys at least TWO looks at the screen.
83
+ * Wall clock alone is not a usable unit here — a uiautomator dump measured ~2.4s on
84
+ * emulator-5554 and can be ~10x faster on a physical device, so a pure time box gives a
85
+ * fast phone a dozen looks and a slow emulator none. Set 0 to restore the old
86
+ * single-shot probe. Override per run with VERIKUN_GUARD_SETTLE_MS (see cli.ts). */
87
+ exports.DEFAULT_GUARD_SETTLE_MS = 1500;
88
+ /** Re-dump cadence inside a guard's settle window. */
89
+ const GUARD_POLL_MS = 150;
90
+ /** Consecutive identical screen snapshots before a loop is believed to be stuck.
91
+ *
92
+ * This check is a TIME SAVER and nothing more. A loop already fails when its exit
93
+ * selector never appears, so stopping early buys no safety — it only decides how long we
94
+ * wait before reaching the same verdict. That asymmetry is the whole design: a false
95
+ * positive fails a passing test, while a false negative costs `cap` iterations of runtime.
96
+ *
97
+ * Tuned from a measured false positive, twice over. At 2 strikes the same plan answered
98
+ * 17 questions on one run and bailed after 4 on the next — the loop bodies drive screen
99
+ * transitions, and a single dump lands mid-transition often enough to produce a
100
+ * coincidental pair of matching hashes.
101
+ *
102
+ * 4 discriminates well because the two cases differ in character, not just degree: a loop
103
+ * genuinely at rest (a scrolled-to-the-bottom list — the case this exists for) yields
104
+ * identical hashes indefinitely, whereas an animating screen rarely yields four in a row. */
105
+ const NO_PROGRESS_STRIKES = 4;
31
106
  async function runPlan(plan, deps) {
32
107
  const maxRepairs = deps.maxRepairs ?? 3;
108
+ const guardSettleMs = deps.guardSettleMs ?? exports.DEFAULT_GUARD_SETTLE_MS;
109
+ // The run's mutable state, scoped to THIS runPlan call — never module-level. `vk suite`
110
+ // runs every test in one process, so a shared store would hand two sign-up tests the
111
+ // same {{uuid}} and recreate the collision it exists to prevent. Per-leaf generation
112
+ // would be equally wrong: a sign-up needs the same address in the email field, the
113
+ // confirm field, and a later assert.
114
+ const ctx = new Map(Object.entries(deps.initialCtx ?? {}));
115
+ const generated = new Map();
116
+ const runId = deps.runId ?? 'run';
117
+ /** Resolve {{...}} placeholders. A closed set on purpose — this is a template
118
+ * substitution, not an expression language, so there is nothing to sandbox.
119
+ * Unknown placeholders are left verbatim rather than blanked: a silent empty string
120
+ * is how a typo becomes a false green. */
121
+ function interpolate(s) {
122
+ if (!s.includes('{{'))
123
+ return s;
124
+ return s.replace(/\{\{\s*([A-Za-z_][A-Za-z0-9_.]*)\s*\}\}/g, (whole, name) => {
125
+ if (name.startsWith('ctx.')) {
126
+ const key = name.slice(4);
127
+ const v = ctx.get(key);
128
+ if (v === undefined)
129
+ throw new CtxError(`{{${name}}} is not set — no earlier step stored it`);
130
+ return v;
131
+ }
132
+ if (name.startsWith('env.')) {
133
+ const key = name.slice(4);
134
+ const v = process.env[key];
135
+ // Loud, not empty: a missing CI secret must fail the step, not type "" into a field.
136
+ if (v === undefined || v === '')
137
+ throw new CtxError(`{{${name}}} is not set in the environment`);
138
+ return v;
139
+ }
140
+ if (name === 'run_id')
141
+ return runId;
142
+ // uuid/timestamp are generated ONCE per run and memoized by placeholder name.
143
+ if (name === 'uuid' || name === 'timestamp') {
144
+ const existing = generated.get(name);
145
+ if (existing !== undefined)
146
+ return existing;
147
+ const v = name === 'uuid' ? (0, node_crypto_1.randomUUID)() : String(Date.now());
148
+ generated.set(name, v);
149
+ return v;
150
+ }
151
+ return whole;
152
+ });
153
+ }
33
154
  const overDeadline = () => deps.deadline !== undefined && Date.now() >= deps.deadline;
34
155
  const improvements = [];
35
156
  let modelRepairs = 0;
@@ -44,24 +165,25 @@ async function runPlan(plan, deps) {
44
165
  return [];
45
166
  }
46
167
  };
47
- const present = async (selector) => {
48
- // Re-fetch on a dump FAILURE (uiautomator can throw transiently) so a flaky dump at
49
- // a guard check doesn't silently read as "absent" and skip a body that should run.
50
- // Once a dump SUCCEEDS (even if empty) we trust it — no slow re-poll, so a genuinely
51
- // absent guard still skips fast (the common if-present case).
52
- let els;
53
- for (let i = 0; i < 2 && els === undefined; i++) {
54
- try {
55
- els = await deps.getElements();
56
- }
57
- catch {
58
- /* transient dump failure retry once before concluding "absent" */
59
- }
60
- }
61
- if (els === undefined)
62
- return false;
168
+ /** Is `selector` on screen? `settleMs` is how long to keep re-dumping while it is
169
+ * absent before answering "no" see DEFAULT_GUARD_SETTLE_MS.
170
+ *
171
+ * Two retry behaviours here are deliberately ORTHOGONAL, and conflating them is the
172
+ * bug this signature exists to prevent:
173
+ * - a dump that THROWS is always retried once, even at settleMs=0, so a flaky
174
+ * uiautomator call never silently reads as "absent" and skips a body that
175
+ * should run;
176
+ * - a dump that SUCCEEDS but does not match is re-polled only while settleMs
177
+ * remains. At settleMs=0 that means exactly one pass — the fast, single-shot
178
+ * probe a loop-exit check needs.
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. */
183
+ const present = async (selector, settleMs) => {
184
+ let sel;
63
185
  try {
64
- return (0, selector_1.matchElements)(els, (0, selector_1.parseSelector)(selector)).matches.length > 0;
186
+ sel = (0, selector_1.parseSelector)(selector);
65
187
  }
66
188
  catch (e) {
67
189
  // A guard selector that won't parse is a compiler/plan bug — surface it (then treat
@@ -69,14 +191,80 @@ async function runPlan(plan, deps) {
69
191
  deps.log(`[ai] guard selector '${selector}' did not parse (${e.message}) — treating as not present`);
70
192
  return false;
71
193
  }
194
+ const deadline = Date.now() + Math.max(0, settleMs);
195
+ // A non-zero window must buy at least one SECOND look, independent of the clock.
196
+ // Measured on emulator-5554: one uiautomator dump costs ~2.4s, which already exceeds
197
+ // a 1.5s window — so a purely time-boxed loop returns after a single dump and the
198
+ // window is a silent no-op on exactly the slow devices that need it most. Dump cost
199
+ // swings ~10x across devices, so "how long to wait" cannot be expressed in wall clock
200
+ // alone without making the guard's patience device-dependent.
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;
206
+ const minLooks = settleMs > 0 ? 2 : 1;
207
+ for (;;) {
208
+ let els;
209
+ for (let i = 0; i < 2; i++) {
210
+ try {
211
+ els = await deps.getElements();
212
+ everRead = true;
213
+ }
214
+ catch (e) {
215
+ els = undefined; // transient dump failure — retry once before concluding "absent"
216
+ lastErr = e;
217
+ }
218
+ // An EMPTY tree is not a screen, it is a bad read: a live app always has nodes, and
219
+ // this device routinely returns a partial/blank dump mid-transition (measured: `ui`
220
+ // reported zero elements while `tap`'s auto-wait found its target 3.1s later).
221
+ // Trusting it means "absent" — which silently skips a guard, or sends a loop round
222
+ // again to tap something that already went away. Retry once even at settleMs=0,
223
+ // which is what the loop-exit check runs at.
224
+ if (els !== undefined && els.length > 0)
225
+ break;
226
+ }
227
+ looks++;
228
+ if (els !== undefined && (0, selector_1.matchElements)(els, sel).matches.length > 0)
229
+ return true;
230
+ const remaining = deadline - Date.now();
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);
237
+ return false;
238
+ }
239
+ await sleep(Math.max(0, Math.min(GUARD_POLL_MS, remaining)));
240
+ }
241
+ };
242
+ const runLeaf = (leaf) => {
243
+ const flags = (0, ir_1.leafToFlags)(leaf);
244
+ for (const k of Object.keys(flags))
245
+ flags[k] = interpolate(flags[k]);
246
+ return deps.exec(leaf.command, leaf.positionals.map(interpolate), flags);
72
247
  };
73
- const runLeaf = (leaf) => deps.exec(leaf.command, leaf.positionals, (0, ir_1.leafToFlags)(leaf));
74
248
  /** Execute one leaf, healing a selector miss/ambiguity via the model up to the cap.
75
249
  * `replace` writes a repaired leaf back into the plan so it persists on green. */
76
- async function execLeaf(leaf, where, replace) {
250
+ async function execLeaf(leaf, where, replace, guard) {
77
251
  deps.log(`[ai] ${where}: ${describe(leaf)}`);
78
252
  let current = leaf;
79
253
  let outcome = await runLeaf(current);
254
+ // Guard raced the body: `if-present X { … tap X … }` checked X, X was there, and by
255
+ // the time the tap ran it had gone. That is not drift and there is nothing to repair —
256
+ // it is exactly the transient the guard exists to tolerate, and on a fast-transitioning
257
+ // app it happens routinely. Checked BEFORE the heal loop, because otherwise it costs
258
+ // three model calls to conclude that a thing which was optional is absent.
259
+ // Deliberately narrow: only the leaf that targets the guard's own selector, and only
260
+ // once the guard is confirmed false again.
261
+ if (guard !== undefined &&
262
+ outcome.error instanceof errors_1.SelectorNotFoundError &&
263
+ current.positionals.some((p) => interpolate(p) === interpolate(guard)) &&
264
+ !(await present(interpolate(guard), 0))) {
265
+ deps.log(`[ai] ${where}: '${interpolate(guard)}' disappeared after the guard matched — ending the guarded body`);
266
+ return { status: 'guard-gone' };
267
+ }
80
268
  let attempts = 0;
81
269
  while (isHealable(outcome) && attempts < maxRepairs) {
82
270
  if (deps.cost.exceeded()) {
@@ -150,53 +338,196 @@ async function runPlan(plan, deps) {
150
338
  if (isHealable(outcome)) {
151
339
  return { status: 'fail', where, reason: `unresolved after ${maxRepairs} repair attempt(s): ${outcome.error.message.split('\n')[0]}` };
152
340
  }
153
- // 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.
154
345
  const reason = outcome.error ? outcome.error.message.split('\n')[0] : `exited ${outcome.code}`;
155
- return { status: 'fail', where, reason };
346
+ return { status: isEnvOutcome(outcome) ? 'env' : 'fail', where, reason };
156
347
  }
157
- async function walkBody(body, parentWhere) {
348
+ async function walkBody(body, parentWhere, guard) {
158
349
  for (let j = 0; j < body.length; j++) {
159
- const res = await execLeaf(body[j], `${parentWhere}.body[${j}]`, (l) => (body[j] = l));
350
+ const res = await walkNode(body[j], `${parentWhere}[${j}]`, (l) => (body[j] = l), guard);
351
+ // 'guard-gone' ends this body, not the run: the thing the guard matched went away
352
+ // mid-body, which is precisely the situation the guard exists to tolerate.
353
+ if (res.status === 'guard-gone')
354
+ return { status: 'ok' };
160
355
  if (res.status !== 'ok')
161
356
  return res;
162
357
  }
163
358
  return { status: 'ok' };
164
359
  }
165
- async function walkNode(node, where, replace) {
360
+ /** Capture a value from the live tree into ctx. Not routed through `exec`: the
361
+ * ExecFn contract returns only {code,error}, so a leaf could never hand a value back. */
362
+ async function execRead(node, where) {
363
+ const selector = interpolate(node.selector);
364
+ let sel;
365
+ try {
366
+ sel = (0, selector_1.parseSelector)(selector);
367
+ }
368
+ catch (e) {
369
+ return { status: 'fail', where, reason: `read selector '${selector}' did not parse: ${e.message}` };
370
+ }
371
+ // Same patience as a conditional guard: the value may not have rendered yet.
372
+ const deadline = Date.now() + Math.max(0, guardSettleMs);
373
+ let looks = 0;
374
+ const minLooks = guardSettleMs > 0 ? 2 : 1;
375
+ for (;;) {
376
+ const els = await safeElements();
377
+ const { matches } = (0, selector_1.matchElements)(els, sel);
378
+ looks++;
379
+ if (matches.length > 0) {
380
+ const value = String(matches[0][node.field] ?? '');
381
+ ctx.set(node.into, value);
382
+ deps.log(`[ai] ${where}: read ${node.field} of '${selector}' → ctx.${node.into} = ${JSON.stringify(value)}`);
383
+ return { status: 'ok' };
384
+ }
385
+ const remaining = deadline - Date.now();
386
+ if (looks >= minLooks && remaining <= 0) {
387
+ // Terminal, not healable: a missing source value would silently propagate an
388
+ // empty string into every later {{ctx.*}} use, which is a false green waiting
389
+ // to happen. Fail where the information is.
390
+ return { status: 'fail', where, reason: `read found no element matching '${selector}' (nothing to store in ctx.${node.into})` };
391
+ }
392
+ await sleep(Math.max(0, Math.min(GUARD_POLL_MS, remaining)));
393
+ }
394
+ }
395
+ async function walkNode(node, where, replace, guard) {
396
+ try {
397
+ return await walkNodeInner(node, where, replace, guard);
398
+ }
399
+ catch (e) {
400
+ // An unresolvable placeholder is a plan defect, not a device failure: report it
401
+ // as this step's terminal failure rather than letting it abort the whole run as
402
+ // an "unexpected error" (exit 3).
403
+ if (e instanceof CtxError)
404
+ return { status: 'fail', where, reason: e.message };
405
+ throw e;
406
+ }
407
+ }
408
+ async function walkNodeInner(node, where, replace, guard) {
166
409
  switch (node.type) {
167
410
  case 'command':
168
- return execLeaf(node, where, replace);
411
+ return execLeaf(node, where, replace, guard);
412
+ case 'read':
413
+ return execRead(node, where);
169
414
  case 'if-present': {
170
- if (await present(node.selector)) {
171
- deps.log(`[ai] ${where}: if-present '${node.selector}' → present, running ${node.body.length} step(s)`);
172
- return walkBody(node.body, where);
415
+ const selector = interpolate(node.selector);
416
+ if (await present(selector, guardSettleMs)) {
417
+ deps.log(`[ai] ${where}: if-present '${selector}' → present, running ${node.body.length} step(s)`);
418
+ return walkBody(node.body, `${where}.body`, selector);
173
419
  }
174
- deps.log(`[ai] ${where}: if-present '${node.selector}' → absent, skipping`);
420
+ deps.log(`[ai] ${where}: if-present '${selector}' → absent, skipping`);
175
421
  return { status: 'ok' };
176
422
  }
423
+ case 'when': {
424
+ for (let b = 0; b < node.branches.length; b++) {
425
+ const selector = interpolate(node.branches[b].selector);
426
+ // Ordered: first present branch wins, and only it runs. The settle window is
427
+ // per NODE, spent on the first branch checked — re-polling every branch would
428
+ // cost cap x branches x window inside a loop, and would let a slower-appearing
429
+ // earlier branch beat an already-present later one (making dispatch depend on
430
+ // timing rather than on order).
431
+ if (await present(selector, b === 0 ? guardSettleMs : 0)) {
432
+ deps.log(`[ai] ${where}: when → branch ${b} '${selector}' matched`);
433
+ return walkBody(node.branches[b].body, `${where}.branches[${b}].body`);
434
+ }
435
+ }
436
+ if (node.else) {
437
+ deps.log(`[ai] ${where}: when → no branch matched, running else (${node.else.length} step(s))`);
438
+ return walkBody(node.else, `${where}.else`);
439
+ }
440
+ // No branch, no else => FAIL. Skipping here is the false-green this node exists
441
+ // to prevent: inside a loop it would spin to the cap doing nothing and pass.
442
+ const tried = node.branches.map((b) => `'${interpolate(b.selector)}'`).join(', ');
443
+ return {
444
+ status: 'fail',
445
+ where,
446
+ reason: `when: no branch matched (tried ${tried}) and no else was given — the screen is one this test does not handle`,
447
+ };
448
+ }
177
449
  case 'repeat': {
178
450
  let prevHash = '';
179
- for (let i = 0; i < node.cap; i++) {
451
+ let stalled = 0;
452
+ let satisfied = false;
453
+ let i = 0;
454
+ for (; i < node.cap; i++) {
180
455
  if (overDeadline()) {
181
456
  deps.log(`[ai] ${where}: run timeout reached — stopping repeat after ${i} iteration(s)`);
182
457
  return { status: 'timeout' };
183
458
  }
184
- if (await present(node.selector)) {
459
+ // settleMs=0 on purpose: this guard is absent on EVERY iteration by construction
460
+ // (that is what makes it a loop), so a settle window here would be paid `cap`
461
+ // times — 25 × 1.5s ≈ 37s of dead wall-clock per loop — to discover something
462
+ // we already expect. The interstitial case that needs patience is `if-present`.
463
+ if (await present(interpolate(node.selector), 0)) {
464
+ satisfied = true;
185
465
  deps.log(`[ai] ${where}: repeat reached '${node.selector}' after ${i} iteration(s)`);
186
- return { status: 'ok' };
466
+ break;
187
467
  }
188
- const hash = structuralHash(await safeElements());
189
- if (i > 0 && hash === prevHash) {
190
- deps.log(`[ai] ${where}: repeat made no progress (screen unchanged) stopping after ${i} iteration(s)`);
191
- return { status: 'ok' };
468
+ // No-progress detection over a SINGLE un-settled dump is unreliable on a real
469
+ // app: mid-transition frames read as empty (measured — `vk ui` reported zero
470
+ // elements on a screen where `tap`'s auto-wait found its target 3.1s later).
471
+ // Two such frames hash identically and look exactly like a stuck loop. Since a
472
+ // stalled loop is now a FAILURE, a false positive here fails a passing test, so
473
+ // this needs two independent guards:
474
+ // 1. an empty dump is UNKNOWN, not "unchanged" — never a progress sample;
475
+ // 2. require consecutive identical NON-EMPTY snapshots before believing it.
476
+ const els = await safeElements();
477
+ if (els.length === 0) {
478
+ deps.log(`[ai] ${where}: screen read as empty (mid-transition?) — not counting it as progress either way`);
479
+ }
480
+ else {
481
+ const hash = structuralHash(els);
482
+ stalled = i > 0 && hash === prevHash ? stalled + 1 : 0;
483
+ prevHash = hash;
484
+ if (stalled >= NO_PROGRESS_STRIKES) {
485
+ deps.log(`[ai] ${where}: repeat made no progress across ${stalled + 1} checks — stopping after ${i} iteration(s)`);
486
+ break;
487
+ }
192
488
  }
193
- prevHash = hash;
194
489
  deps.log(`[ai] ${where}: repeat iteration ${i + 1}/${node.cap}`);
195
- const res = await walkBody(node.body, `${where}#${i + 1}`);
490
+ const res = await walkBody(node.body, `${where}#${i + 1}.body`);
196
491
  if (res.status !== 'ok')
197
492
  return res;
198
493
  }
199
- deps.log(`[ai] ${where}: repeat hit cap ${node.cap} without '${node.selector}' (continuing)`);
494
+ // A loop that stopped without ever seeing its target did NOT do its job. Both
495
+ // exits (cap exhausted, and the no-progress bail) used to return ok, which let a
496
+ // loop whose body did nothing report green — the reachable false green.
497
+ if (!satisfied && !(await present(interpolate(node.selector), guardSettleMs))) {
498
+ return {
499
+ status: 'fail',
500
+ where,
501
+ reason: `repeat stopped after ${i} iteration(s) without '${interpolate(node.selector)}' ever appearing`,
502
+ };
503
+ }
504
+ return { status: 'ok' };
505
+ }
506
+ case 'while-present': {
507
+ if (node.bind)
508
+ ctx.set(node.bind, '0');
509
+ let ran = 0;
510
+ for (let i = 0; i < node.cap; i++) {
511
+ if (overDeadline()) {
512
+ deps.log(`[ai] ${where}: run timeout reached — stopping while-present after ${i} iteration(s)`);
513
+ return { status: 'timeout' };
514
+ }
515
+ const selector = interpolate(node.selector);
516
+ // First check gets the settle window (the list may still be rendering); later
517
+ // checks are single-shot, since by then the list is up and we are just walking it.
518
+ if (!(await present(selector, i === 0 ? guardSettleMs : 0))) {
519
+ deps.log(`[ai] ${where}: while-present '${selector}' → absent, loop done after ${ran} iteration(s)`);
520
+ return { status: 'ok' };
521
+ }
522
+ deps.log(`[ai] ${where}: while-present iteration ${i + 1}/${node.cap} ('${selector}')`);
523
+ const res = await walkBody(node.body, `${where}#${i + 1}.body`, selector);
524
+ if (res.status !== 'ok')
525
+ return res;
526
+ ran++;
527
+ if (node.bind)
528
+ ctx.set(node.bind, String(Number(ctx.get(node.bind) ?? '0') + 1));
529
+ }
530
+ deps.log(`[ai] ${where}: while-present hit cap ${node.cap}`);
200
531
  return { status: 'ok' };
201
532
  }
202
533
  }
@@ -206,13 +537,28 @@ async function runPlan(plan, deps) {
206
537
  deps.log(`[ai] run timeout reached before steps[${i}] — aborting`);
207
538
  return { ok: false, plan, modelRepairs, improvements, abortedForTimeout: true };
208
539
  }
209
- 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
+ }
210
550
  if (res.status === 'budget') {
211
551
  return { ok: false, plan, modelRepairs, improvements, abortedForBudget: true };
212
552
  }
213
553
  if (res.status === 'timeout') {
214
554
  return { ok: false, plan, modelRepairs, improvements, abortedForTimeout: true };
215
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
+ }
216
562
  if (res.status === 'fail') {
217
563
  deps.log(`[ai] FAILED at ${res.where}: ${res.reason}`);
218
564
  return { ok: false, plan, modelRepairs, improvements, failure: { where: res.where, reason: res.reason } };