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 +33 -7
- package/dist/agent/claude.js +6 -0
- package/dist/agent/cli-provider.js +6 -0
- package/dist/agent/engine.js +391 -45
- package/dist/agent/grammar.js +73 -6
- package/dist/agent/ir.js +220 -46
- package/dist/agent/lint.js +77 -0
- package/dist/agent/openai.js +6 -0
- package/dist/cli.js +124 -49
- package/dist/drivers/adb.js +41 -0
- package/dist/drivers/index.js +5 -1
- package/dist/drivers/ios.js +75 -0
- package/dist/errors.js +16 -1
- package/dist/report.js +16 -1
- package/dist/run.js +9 -4
- package/dist/server.js +5 -2
- package/dist/suite.js +75 -15
- package/dist/ui/selector.js +17 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
|
package/dist/agent/claude.js
CHANGED
|
@@ -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.
|
package/dist/agent/engine.js
CHANGED
|
@@ -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
|
-
*
|
|
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.
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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 '${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
466
|
+
break;
|
|
187
467
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 } };
|