verikun 0.10.0 → 0.11.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 +13 -1
- package/dist/agent/claude.js +6 -0
- package/dist/agent/cli-provider.js +6 -0
- package/dist/agent/engine.js +332 -42
- 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 +70 -7
- 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.
|
|
@@ -367,7 +375,11 @@ class:Button simplified type ("Button") or full class ("android.widget.Button
|
|
|
367
375
|
```
|
|
368
376
|
|
|
369
377
|
Modifiers: `--contains` makes text/desc matches substring-based; `--index N`
|
|
370
|
-
selects the Nth match (0-based) when a selector intentionally matches several
|
|
378
|
+
selects the Nth match (0-based) when a selector intentionally matches several;
|
|
379
|
+
`--enabled` matches only a control that is **actionable right now** — use it for a
|
|
380
|
+
Submit/Check button the app disables until a form is valid, since such a button is
|
|
381
|
+
present long before it is usable and tapping presence taps a dead control (with
|
|
382
|
+
auto-wait this reads as "wait until it is pressable").
|
|
371
383
|
If a selector for an action matches more than one element and no `--index` is
|
|
372
384
|
given, the command fails with exit code 2 and lists the candidates — it never
|
|
373
385
|
taps a guess.
|
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,7 +1,8 @@
|
|
|
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");
|
|
@@ -13,23 +14,120 @@ const describe = (leaf) => [leaf.command, ...leaf.positionals, ...leaf.flags.map
|
|
|
13
14
|
* fails (a device hiccup on screencap) must never turn a green run red — see
|
|
14
15
|
* the guard in execLeaf. */
|
|
15
16
|
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
|
-
*
|
|
17
|
+
/** A structural fingerprint of the screen: a sorted id+text+desc+type set. Used for the
|
|
18
|
+
* loop no-progress check — deliberately NOT the raw hierarchy (its node ordering is
|
|
19
|
+
* nondeterministic between identical states, which would false-trip).
|
|
20
|
+
*
|
|
21
|
+
* BOTH content fields are sampled, and that is load-bearing rather than belt-and-braces.
|
|
22
|
+
* Sampling `text` alone made this blind on Flutter apps, which map `Semantics(label:)` to
|
|
23
|
+
* Android's `contentDescription` — i.e. to `desc`, never to `text`. Measured on a live
|
|
24
|
+
* Flutter screen: 14 elements, **0** carrying `text`, 8 carrying `desc`. The fingerprint
|
|
25
|
+
* degenerated to `id||type` for the entire screen, so two completely different questions
|
|
26
|
+
* hashed byte-identically and a loop answering them correctly was declared stalled.
|
|
27
|
+
*
|
|
28
|
+
* Uses the full `id`, not `idShort` (the suffix after the last '/'), so elements from
|
|
29
|
+
* different packages/namespaces cannot collide in what is meant to be a fingerprint.
|
|
30
|
+
*
|
|
31
|
+
* When in doubt, sample MORE: an over-sensitive hash only costs a loop running to its cap;
|
|
32
|
+
* an under-sensitive one fails a passing test, since a stalled loop is now fatal. */
|
|
19
33
|
function structuralHash(els) {
|
|
20
34
|
return els
|
|
21
|
-
.map((e) => `${e.
|
|
35
|
+
.map((e) => `${e.id}|${e.text}|${e.desc}|${e.type}`)
|
|
22
36
|
.sort()
|
|
23
37
|
.join('\n');
|
|
24
38
|
}
|
|
39
|
+
/** An unresolvable {{...}} placeholder. Terminal and never healed: the model cannot
|
|
40
|
+
* repair a missing value, and substituting one would be inventing test data. */
|
|
41
|
+
class CtxError extends Error {
|
|
42
|
+
}
|
|
25
43
|
function isHealable(outcome) {
|
|
26
44
|
return (!!outcome.error &&
|
|
27
45
|
(outcome.error instanceof errors_1.SelectorNotFoundError || outcome.error instanceof errors_1.AmbiguousSelectorError));
|
|
28
46
|
}
|
|
29
47
|
/** Default wall-clock ceiling for a whole `vk ai` run (overridable via --timeout). */
|
|
30
48
|
exports.DEFAULT_RUN_TIMEOUT_MS = 15 * 60 * 1000;
|
|
49
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
50
|
+
/** How long a CONDITIONAL guard (`if-present`) waits for its selector to show up
|
|
51
|
+
* before concluding "absent". Interstitials animate in: a permission dialog or promo
|
|
52
|
+
* panel typically lands a few hundred ms after the transition that triggers it. Every
|
|
53
|
+
* selector-resolving leaf command already auto-waits ~5s (cli.ts resolveOneWaiting),
|
|
54
|
+
* so before this window existed a guard was strictly LESS patient than a bare `tap` —
|
|
55
|
+
* and an optional dialog could be missed by the very construct meant to catch it.
|
|
56
|
+
* Kept far below the leaf 5s because an ABSENT guard pays this window, every time, and
|
|
57
|
+
* skipping fast is the common case.
|
|
58
|
+
*
|
|
59
|
+
* NOTE the floor in present(): any non-zero window buys at least TWO looks at the screen.
|
|
60
|
+
* Wall clock alone is not a usable unit here — a uiautomator dump measured ~2.4s on
|
|
61
|
+
* emulator-5554 and can be ~10x faster on a physical device, so a pure time box gives a
|
|
62
|
+
* fast phone a dozen looks and a slow emulator none. Set 0 to restore the old
|
|
63
|
+
* single-shot probe. Override per run with VERIKUN_GUARD_SETTLE_MS (see cli.ts). */
|
|
64
|
+
exports.DEFAULT_GUARD_SETTLE_MS = 1500;
|
|
65
|
+
/** Re-dump cadence inside a guard's settle window. */
|
|
66
|
+
const GUARD_POLL_MS = 150;
|
|
67
|
+
/** Consecutive identical screen snapshots before a loop is believed to be stuck.
|
|
68
|
+
*
|
|
69
|
+
* This check is a TIME SAVER and nothing more. A loop already fails when its exit
|
|
70
|
+
* selector never appears, so stopping early buys no safety — it only decides how long we
|
|
71
|
+
* wait before reaching the same verdict. That asymmetry is the whole design: a false
|
|
72
|
+
* positive fails a passing test, while a false negative costs `cap` iterations of runtime.
|
|
73
|
+
*
|
|
74
|
+
* Tuned from a measured false positive, twice over. At 2 strikes the same plan answered
|
|
75
|
+
* 17 questions on one run and bailed after 4 on the next — the loop bodies drive screen
|
|
76
|
+
* transitions, and a single dump lands mid-transition often enough to produce a
|
|
77
|
+
* coincidental pair of matching hashes.
|
|
78
|
+
*
|
|
79
|
+
* 4 discriminates well because the two cases differ in character, not just degree: a loop
|
|
80
|
+
* genuinely at rest (a scrolled-to-the-bottom list — the case this exists for) yields
|
|
81
|
+
* identical hashes indefinitely, whereas an animating screen rarely yields four in a row. */
|
|
82
|
+
const NO_PROGRESS_STRIKES = 4;
|
|
31
83
|
async function runPlan(plan, deps) {
|
|
32
84
|
const maxRepairs = deps.maxRepairs ?? 3;
|
|
85
|
+
const guardSettleMs = deps.guardSettleMs ?? exports.DEFAULT_GUARD_SETTLE_MS;
|
|
86
|
+
// The run's mutable state, scoped to THIS runPlan call — never module-level. `vk suite`
|
|
87
|
+
// runs every test in one process, so a shared store would hand two sign-up tests the
|
|
88
|
+
// same {{uuid}} and recreate the collision it exists to prevent. Per-leaf generation
|
|
89
|
+
// would be equally wrong: a sign-up needs the same address in the email field, the
|
|
90
|
+
// confirm field, and a later assert.
|
|
91
|
+
const ctx = new Map(Object.entries(deps.initialCtx ?? {}));
|
|
92
|
+
const generated = new Map();
|
|
93
|
+
const runId = deps.runId ?? 'run';
|
|
94
|
+
/** Resolve {{...}} placeholders. A closed set on purpose — this is a template
|
|
95
|
+
* substitution, not an expression language, so there is nothing to sandbox.
|
|
96
|
+
* Unknown placeholders are left verbatim rather than blanked: a silent empty string
|
|
97
|
+
* is how a typo becomes a false green. */
|
|
98
|
+
function interpolate(s) {
|
|
99
|
+
if (!s.includes('{{'))
|
|
100
|
+
return s;
|
|
101
|
+
return s.replace(/\{\{\s*([A-Za-z_][A-Za-z0-9_.]*)\s*\}\}/g, (whole, name) => {
|
|
102
|
+
if (name.startsWith('ctx.')) {
|
|
103
|
+
const key = name.slice(4);
|
|
104
|
+
const v = ctx.get(key);
|
|
105
|
+
if (v === undefined)
|
|
106
|
+
throw new CtxError(`{{${name}}} is not set — no earlier step stored it`);
|
|
107
|
+
return v;
|
|
108
|
+
}
|
|
109
|
+
if (name.startsWith('env.')) {
|
|
110
|
+
const key = name.slice(4);
|
|
111
|
+
const v = process.env[key];
|
|
112
|
+
// Loud, not empty: a missing CI secret must fail the step, not type "" into a field.
|
|
113
|
+
if (v === undefined || v === '')
|
|
114
|
+
throw new CtxError(`{{${name}}} is not set in the environment`);
|
|
115
|
+
return v;
|
|
116
|
+
}
|
|
117
|
+
if (name === 'run_id')
|
|
118
|
+
return runId;
|
|
119
|
+
// uuid/timestamp are generated ONCE per run and memoized by placeholder name.
|
|
120
|
+
if (name === 'uuid' || name === 'timestamp') {
|
|
121
|
+
const existing = generated.get(name);
|
|
122
|
+
if (existing !== undefined)
|
|
123
|
+
return existing;
|
|
124
|
+
const v = name === 'uuid' ? (0, node_crypto_1.randomUUID)() : String(Date.now());
|
|
125
|
+
generated.set(name, v);
|
|
126
|
+
return v;
|
|
127
|
+
}
|
|
128
|
+
return whole;
|
|
129
|
+
});
|
|
130
|
+
}
|
|
33
131
|
const overDeadline = () => deps.deadline !== undefined && Date.now() >= deps.deadline;
|
|
34
132
|
const improvements = [];
|
|
35
133
|
let modelRepairs = 0;
|
|
@@ -44,24 +142,22 @@ async function runPlan(plan, deps) {
|
|
|
44
142
|
return [];
|
|
45
143
|
}
|
|
46
144
|
};
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (els === undefined)
|
|
62
|
-
return false;
|
|
145
|
+
/** Is `selector` on screen? `settleMs` is how long to keep re-dumping while it is
|
|
146
|
+
* absent before answering "no" — see DEFAULT_GUARD_SETTLE_MS.
|
|
147
|
+
*
|
|
148
|
+
* Two retry behaviours here are deliberately ORTHOGONAL, and conflating them is the
|
|
149
|
+
* bug this signature exists to prevent:
|
|
150
|
+
* - a dump that THROWS is always retried once, even at settleMs=0, so a flaky
|
|
151
|
+
* uiautomator call never silently reads as "absent" and skips a body that
|
|
152
|
+
* should run;
|
|
153
|
+
* - a dump that SUCCEEDS but does not match is re-polled only while settleMs
|
|
154
|
+
* remains. At settleMs=0 that means exactly one pass — the fast, single-shot
|
|
155
|
+
* probe a loop-exit check needs.
|
|
156
|
+
* So one dump attempt always happens regardless of the window. */
|
|
157
|
+
const present = async (selector, settleMs) => {
|
|
158
|
+
let sel;
|
|
63
159
|
try {
|
|
64
|
-
|
|
160
|
+
sel = (0, selector_1.parseSelector)(selector);
|
|
65
161
|
}
|
|
66
162
|
catch (e) {
|
|
67
163
|
// A guard selector that won't parse is a compiler/plan bug — surface it (then treat
|
|
@@ -69,14 +165,68 @@ async function runPlan(plan, deps) {
|
|
|
69
165
|
deps.log(`[ai] guard selector '${selector}' did not parse (${e.message}) — treating as not present`);
|
|
70
166
|
return false;
|
|
71
167
|
}
|
|
168
|
+
const deadline = Date.now() + Math.max(0, settleMs);
|
|
169
|
+
// A non-zero window must buy at least one SECOND look, independent of the clock.
|
|
170
|
+
// Measured on emulator-5554: one uiautomator dump costs ~2.4s, which already exceeds
|
|
171
|
+
// a 1.5s window — so a purely time-boxed loop returns after a single dump and the
|
|
172
|
+
// window is a silent no-op on exactly the slow devices that need it most. Dump cost
|
|
173
|
+
// swings ~10x across devices, so "how long to wait" cannot be expressed in wall clock
|
|
174
|
+
// alone without making the guard's patience device-dependent.
|
|
175
|
+
let looks = 0;
|
|
176
|
+
const minLooks = settleMs > 0 ? 2 : 1;
|
|
177
|
+
for (;;) {
|
|
178
|
+
let els;
|
|
179
|
+
for (let i = 0; i < 2; i++) {
|
|
180
|
+
try {
|
|
181
|
+
els = await deps.getElements();
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
els = undefined; // transient dump failure — retry once before concluding "absent"
|
|
185
|
+
}
|
|
186
|
+
// An EMPTY tree is not a screen, it is a bad read: a live app always has nodes, and
|
|
187
|
+
// this device routinely returns a partial/blank dump mid-transition (measured: `ui`
|
|
188
|
+
// reported zero elements while `tap`'s auto-wait found its target 3.1s later).
|
|
189
|
+
// Trusting it means "absent" — which silently skips a guard, or sends a loop round
|
|
190
|
+
// again to tap something that already went away. Retry once even at settleMs=0,
|
|
191
|
+
// which is what the loop-exit check runs at.
|
|
192
|
+
if (els !== undefined && els.length > 0)
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
looks++;
|
|
196
|
+
if (els !== undefined && (0, selector_1.matchElements)(els, sel).matches.length > 0)
|
|
197
|
+
return true;
|
|
198
|
+
const remaining = deadline - Date.now();
|
|
199
|
+
if (looks >= minLooks && remaining <= 0)
|
|
200
|
+
return false;
|
|
201
|
+
await sleep(Math.max(0, Math.min(GUARD_POLL_MS, remaining)));
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
const runLeaf = (leaf) => {
|
|
205
|
+
const flags = (0, ir_1.leafToFlags)(leaf);
|
|
206
|
+
for (const k of Object.keys(flags))
|
|
207
|
+
flags[k] = interpolate(flags[k]);
|
|
208
|
+
return deps.exec(leaf.command, leaf.positionals.map(interpolate), flags);
|
|
72
209
|
};
|
|
73
|
-
const runLeaf = (leaf) => deps.exec(leaf.command, leaf.positionals, (0, ir_1.leafToFlags)(leaf));
|
|
74
210
|
/** Execute one leaf, healing a selector miss/ambiguity via the model up to the cap.
|
|
75
211
|
* `replace` writes a repaired leaf back into the plan so it persists on green. */
|
|
76
|
-
async function execLeaf(leaf, where, replace) {
|
|
212
|
+
async function execLeaf(leaf, where, replace, guard) {
|
|
77
213
|
deps.log(`[ai] ${where}: ${describe(leaf)}`);
|
|
78
214
|
let current = leaf;
|
|
79
215
|
let outcome = await runLeaf(current);
|
|
216
|
+
// Guard raced the body: `if-present X { … tap X … }` checked X, X was there, and by
|
|
217
|
+
// the time the tap ran it had gone. That is not drift and there is nothing to repair —
|
|
218
|
+
// it is exactly the transient the guard exists to tolerate, and on a fast-transitioning
|
|
219
|
+
// app it happens routinely. Checked BEFORE the heal loop, because otherwise it costs
|
|
220
|
+
// three model calls to conclude that a thing which was optional is absent.
|
|
221
|
+
// Deliberately narrow: only the leaf that targets the guard's own selector, and only
|
|
222
|
+
// once the guard is confirmed false again.
|
|
223
|
+
if (guard !== undefined &&
|
|
224
|
+
outcome.error instanceof errors_1.SelectorNotFoundError &&
|
|
225
|
+
current.positionals.some((p) => interpolate(p) === interpolate(guard)) &&
|
|
226
|
+
!(await present(interpolate(guard), 0))) {
|
|
227
|
+
deps.log(`[ai] ${where}: '${interpolate(guard)}' disappeared after the guard matched — ending the guarded body`);
|
|
228
|
+
return { status: 'guard-gone' };
|
|
229
|
+
}
|
|
80
230
|
let attempts = 0;
|
|
81
231
|
while (isHealable(outcome) && attempts < maxRepairs) {
|
|
82
232
|
if (deps.cost.exceeded()) {
|
|
@@ -154,49 +304,189 @@ async function runPlan(plan, deps) {
|
|
|
154
304
|
const reason = outcome.error ? outcome.error.message.split('\n')[0] : `exited ${outcome.code}`;
|
|
155
305
|
return { status: 'fail', where, reason };
|
|
156
306
|
}
|
|
157
|
-
async function walkBody(body, parentWhere) {
|
|
307
|
+
async function walkBody(body, parentWhere, guard) {
|
|
158
308
|
for (let j = 0; j < body.length; j++) {
|
|
159
|
-
const res = await
|
|
309
|
+
const res = await walkNode(body[j], `${parentWhere}[${j}]`, (l) => (body[j] = l), guard);
|
|
310
|
+
// 'guard-gone' ends this body, not the run: the thing the guard matched went away
|
|
311
|
+
// mid-body, which is precisely the situation the guard exists to tolerate.
|
|
312
|
+
if (res.status === 'guard-gone')
|
|
313
|
+
return { status: 'ok' };
|
|
160
314
|
if (res.status !== 'ok')
|
|
161
315
|
return res;
|
|
162
316
|
}
|
|
163
317
|
return { status: 'ok' };
|
|
164
318
|
}
|
|
165
|
-
|
|
319
|
+
/** Capture a value from the live tree into ctx. Not routed through `exec`: the
|
|
320
|
+
* ExecFn contract returns only {code,error}, so a leaf could never hand a value back. */
|
|
321
|
+
async function execRead(node, where) {
|
|
322
|
+
const selector = interpolate(node.selector);
|
|
323
|
+
let sel;
|
|
324
|
+
try {
|
|
325
|
+
sel = (0, selector_1.parseSelector)(selector);
|
|
326
|
+
}
|
|
327
|
+
catch (e) {
|
|
328
|
+
return { status: 'fail', where, reason: `read selector '${selector}' did not parse: ${e.message}` };
|
|
329
|
+
}
|
|
330
|
+
// Same patience as a conditional guard: the value may not have rendered yet.
|
|
331
|
+
const deadline = Date.now() + Math.max(0, guardSettleMs);
|
|
332
|
+
let looks = 0;
|
|
333
|
+
const minLooks = guardSettleMs > 0 ? 2 : 1;
|
|
334
|
+
for (;;) {
|
|
335
|
+
const els = await safeElements();
|
|
336
|
+
const { matches } = (0, selector_1.matchElements)(els, sel);
|
|
337
|
+
looks++;
|
|
338
|
+
if (matches.length > 0) {
|
|
339
|
+
const value = String(matches[0][node.field] ?? '');
|
|
340
|
+
ctx.set(node.into, value);
|
|
341
|
+
deps.log(`[ai] ${where}: read ${node.field} of '${selector}' → ctx.${node.into} = ${JSON.stringify(value)}`);
|
|
342
|
+
return { status: 'ok' };
|
|
343
|
+
}
|
|
344
|
+
const remaining = deadline - Date.now();
|
|
345
|
+
if (looks >= minLooks && remaining <= 0) {
|
|
346
|
+
// Terminal, not healable: a missing source value would silently propagate an
|
|
347
|
+
// empty string into every later {{ctx.*}} use, which is a false green waiting
|
|
348
|
+
// to happen. Fail where the information is.
|
|
349
|
+
return { status: 'fail', where, reason: `read found no element matching '${selector}' (nothing to store in ctx.${node.into})` };
|
|
350
|
+
}
|
|
351
|
+
await sleep(Math.max(0, Math.min(GUARD_POLL_MS, remaining)));
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
async function walkNode(node, where, replace, guard) {
|
|
355
|
+
try {
|
|
356
|
+
return await walkNodeInner(node, where, replace, guard);
|
|
357
|
+
}
|
|
358
|
+
catch (e) {
|
|
359
|
+
// An unresolvable placeholder is a plan defect, not a device failure: report it
|
|
360
|
+
// as this step's terminal failure rather than letting it abort the whole run as
|
|
361
|
+
// an "unexpected error" (exit 3).
|
|
362
|
+
if (e instanceof CtxError)
|
|
363
|
+
return { status: 'fail', where, reason: e.message };
|
|
364
|
+
throw e;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
async function walkNodeInner(node, where, replace, guard) {
|
|
166
368
|
switch (node.type) {
|
|
167
369
|
case 'command':
|
|
168
|
-
return execLeaf(node, where, replace);
|
|
370
|
+
return execLeaf(node, where, replace, guard);
|
|
371
|
+
case 'read':
|
|
372
|
+
return execRead(node, where);
|
|
169
373
|
case 'if-present': {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
374
|
+
const selector = interpolate(node.selector);
|
|
375
|
+
if (await present(selector, guardSettleMs)) {
|
|
376
|
+
deps.log(`[ai] ${where}: if-present '${selector}' → present, running ${node.body.length} step(s)`);
|
|
377
|
+
return walkBody(node.body, `${where}.body`, selector);
|
|
173
378
|
}
|
|
174
|
-
deps.log(`[ai] ${where}: if-present '${
|
|
379
|
+
deps.log(`[ai] ${where}: if-present '${selector}' → absent, skipping`);
|
|
175
380
|
return { status: 'ok' };
|
|
176
381
|
}
|
|
382
|
+
case 'when': {
|
|
383
|
+
for (let b = 0; b < node.branches.length; b++) {
|
|
384
|
+
const selector = interpolate(node.branches[b].selector);
|
|
385
|
+
// Ordered: first present branch wins, and only it runs. The settle window is
|
|
386
|
+
// per NODE, spent on the first branch checked — re-polling every branch would
|
|
387
|
+
// cost cap x branches x window inside a loop, and would let a slower-appearing
|
|
388
|
+
// earlier branch beat an already-present later one (making dispatch depend on
|
|
389
|
+
// timing rather than on order).
|
|
390
|
+
if (await present(selector, b === 0 ? guardSettleMs : 0)) {
|
|
391
|
+
deps.log(`[ai] ${where}: when → branch ${b} '${selector}' matched`);
|
|
392
|
+
return walkBody(node.branches[b].body, `${where}.branches[${b}].body`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (node.else) {
|
|
396
|
+
deps.log(`[ai] ${where}: when → no branch matched, running else (${node.else.length} step(s))`);
|
|
397
|
+
return walkBody(node.else, `${where}.else`);
|
|
398
|
+
}
|
|
399
|
+
// No branch, no else => FAIL. Skipping here is the false-green this node exists
|
|
400
|
+
// to prevent: inside a loop it would spin to the cap doing nothing and pass.
|
|
401
|
+
const tried = node.branches.map((b) => `'${interpolate(b.selector)}'`).join(', ');
|
|
402
|
+
return {
|
|
403
|
+
status: 'fail',
|
|
404
|
+
where,
|
|
405
|
+
reason: `when: no branch matched (tried ${tried}) and no else was given — the screen is one this test does not handle`,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
177
408
|
case 'repeat': {
|
|
178
409
|
let prevHash = '';
|
|
179
|
-
|
|
410
|
+
let stalled = 0;
|
|
411
|
+
let satisfied = false;
|
|
412
|
+
let i = 0;
|
|
413
|
+
for (; i < node.cap; i++) {
|
|
180
414
|
if (overDeadline()) {
|
|
181
415
|
deps.log(`[ai] ${where}: run timeout reached — stopping repeat after ${i} iteration(s)`);
|
|
182
416
|
return { status: 'timeout' };
|
|
183
417
|
}
|
|
184
|
-
|
|
418
|
+
// settleMs=0 on purpose: this guard is absent on EVERY iteration by construction
|
|
419
|
+
// (that is what makes it a loop), so a settle window here would be paid `cap`
|
|
420
|
+
// times — 25 × 1.5s ≈ 37s of dead wall-clock per loop — to discover something
|
|
421
|
+
// we already expect. The interstitial case that needs patience is `if-present`.
|
|
422
|
+
if (await present(interpolate(node.selector), 0)) {
|
|
423
|
+
satisfied = true;
|
|
185
424
|
deps.log(`[ai] ${where}: repeat reached '${node.selector}' after ${i} iteration(s)`);
|
|
186
|
-
|
|
425
|
+
break;
|
|
187
426
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
427
|
+
// No-progress detection over a SINGLE un-settled dump is unreliable on a real
|
|
428
|
+
// app: mid-transition frames read as empty (measured — `vk ui` reported zero
|
|
429
|
+
// elements on a screen where `tap`'s auto-wait found its target 3.1s later).
|
|
430
|
+
// Two such frames hash identically and look exactly like a stuck loop. Since a
|
|
431
|
+
// stalled loop is now a FAILURE, a false positive here fails a passing test, so
|
|
432
|
+
// this needs two independent guards:
|
|
433
|
+
// 1. an empty dump is UNKNOWN, not "unchanged" — never a progress sample;
|
|
434
|
+
// 2. require consecutive identical NON-EMPTY snapshots before believing it.
|
|
435
|
+
const els = await safeElements();
|
|
436
|
+
if (els.length === 0) {
|
|
437
|
+
deps.log(`[ai] ${where}: screen read as empty (mid-transition?) — not counting it as progress either way`);
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
const hash = structuralHash(els);
|
|
441
|
+
stalled = i > 0 && hash === prevHash ? stalled + 1 : 0;
|
|
442
|
+
prevHash = hash;
|
|
443
|
+
if (stalled >= NO_PROGRESS_STRIKES) {
|
|
444
|
+
deps.log(`[ai] ${where}: repeat made no progress across ${stalled + 1} checks — stopping after ${i} iteration(s)`);
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
192
447
|
}
|
|
193
|
-
prevHash = hash;
|
|
194
448
|
deps.log(`[ai] ${where}: repeat iteration ${i + 1}/${node.cap}`);
|
|
195
|
-
const res = await walkBody(node.body, `${where}#${i + 1}`);
|
|
449
|
+
const res = await walkBody(node.body, `${where}#${i + 1}.body`);
|
|
450
|
+
if (res.status !== 'ok')
|
|
451
|
+
return res;
|
|
452
|
+
}
|
|
453
|
+
// A loop that stopped without ever seeing its target did NOT do its job. Both
|
|
454
|
+
// exits (cap exhausted, and the no-progress bail) used to return ok, which let a
|
|
455
|
+
// loop whose body did nothing report green — the reachable false green.
|
|
456
|
+
if (!satisfied && !(await present(interpolate(node.selector), guardSettleMs))) {
|
|
457
|
+
return {
|
|
458
|
+
status: 'fail',
|
|
459
|
+
where,
|
|
460
|
+
reason: `repeat stopped after ${i} iteration(s) without '${interpolate(node.selector)}' ever appearing`,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
return { status: 'ok' };
|
|
464
|
+
}
|
|
465
|
+
case 'while-present': {
|
|
466
|
+
if (node.bind)
|
|
467
|
+
ctx.set(node.bind, '0');
|
|
468
|
+
let ran = 0;
|
|
469
|
+
for (let i = 0; i < node.cap; i++) {
|
|
470
|
+
if (overDeadline()) {
|
|
471
|
+
deps.log(`[ai] ${where}: run timeout reached — stopping while-present after ${i} iteration(s)`);
|
|
472
|
+
return { status: 'timeout' };
|
|
473
|
+
}
|
|
474
|
+
const selector = interpolate(node.selector);
|
|
475
|
+
// First check gets the settle window (the list may still be rendering); later
|
|
476
|
+
// checks are single-shot, since by then the list is up and we are just walking it.
|
|
477
|
+
if (!(await present(selector, i === 0 ? guardSettleMs : 0))) {
|
|
478
|
+
deps.log(`[ai] ${where}: while-present '${selector}' → absent, loop done after ${ran} iteration(s)`);
|
|
479
|
+
return { status: 'ok' };
|
|
480
|
+
}
|
|
481
|
+
deps.log(`[ai] ${where}: while-present iteration ${i + 1}/${node.cap} ('${selector}')`);
|
|
482
|
+
const res = await walkBody(node.body, `${where}#${i + 1}.body`, selector);
|
|
196
483
|
if (res.status !== 'ok')
|
|
197
484
|
return res;
|
|
485
|
+
ran++;
|
|
486
|
+
if (node.bind)
|
|
487
|
+
ctx.set(node.bind, String(Number(ctx.get(node.bind) ?? '0') + 1));
|
|
198
488
|
}
|
|
199
|
-
deps.log(`[ai] ${where}:
|
|
489
|
+
deps.log(`[ai] ${where}: while-present hit cap ${node.cap}`);
|
|
200
490
|
return { status: 'ok' };
|
|
201
491
|
}
|
|
202
492
|
}
|
package/dist/agent/grammar.js
CHANGED
|
@@ -34,13 +34,75 @@ Each step is one of three node types:
|
|
|
34
34
|
permission dialogs, "rate us" popups, cookie banners, A/B variants. This is how you
|
|
35
35
|
keep a flow from breaking when an extra screen sometimes appears.
|
|
36
36
|
|
|
37
|
-
3. REPEAT — { "type":"repeat", "selector":<sel>, "cap":<n>, "body":[<
|
|
38
|
-
Repeat body
|
|
39
|
-
is visible". Always set a sane cap (e.g.
|
|
40
|
-
stops changing.
|
|
37
|
+
3. REPEAT — { "type":"repeat", "selector":<sel>, "cap":<n>, "body":[<nodes>] }
|
|
38
|
+
Repeat body UNTIL the selector appears, up to cap iterations. Use for "scroll until X
|
|
39
|
+
is visible", or "keep answering until the results screen". Always set a sane cap (e.g.
|
|
40
|
+
10). The engine also stops early if the screen stops changing. A repeat that finishes
|
|
41
|
+
without its selector ever appearing FAILS the test — it did not do its job.
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
4. WHEN — { "type":"when", "branches":[{ "selector":<sel>, "body":[<nodes>] }, ...],
|
|
44
|
+
"else":[<nodes>] (optional) }
|
|
45
|
+
Ordered n-way dispatch: the FIRST branch whose selector is on screen runs, and only it.
|
|
46
|
+
Use when a screen is one of several KINDS that each need different handling —
|
|
47
|
+
"the question is multiple-choice, or match-the-pairs, or arrange-the-words".
|
|
48
|
+
If no branch matches and there is no "else", the test FAILS (the app showed something
|
|
49
|
+
this test does not handle — that is a real result, not something to skip past).
|
|
50
|
+
Use "else": [] to say explicitly "if none match, do nothing".
|
|
51
|
+
WHEN vs IF-PRESENT: if-present = "this may or may not be there, carry on either way".
|
|
52
|
+
when = "it is one of these; if it is none of them, that is a failure".
|
|
53
|
+
|
|
54
|
+
5. WHILE-PRESENT — { "type":"while-present", "selector":<sel>, "bind":<name>,
|
|
55
|
+
"cap":<n>, "body":[<nodes>] }
|
|
56
|
+
Repeat body WHILE the selector is present. With "bind", the named counter starts at 0
|
|
57
|
+
and increments after each iteration, and you reference it as {{ctx.<name>}} inside the
|
|
58
|
+
selector and the body. This is how you walk an index-addressed list whose LENGTH you
|
|
59
|
+
cannot know when compiling:
|
|
60
|
+
{ "type":"while-present", "selector":"id:word_bubble_container_id_{{ctx.i}}",
|
|
61
|
+
"bind":"i", "cap":20,
|
|
62
|
+
"body":[ { "type":"command","command":"tap",
|
|
63
|
+
"positionals":["id:word_bubble_container_id_{{ctx.i}}"],"flags":[] } ] }
|
|
64
|
+
|
|
65
|
+
6. READ — { "type":"read", "selector":<sel>, "field":"text"|"desc"|"id"|"idShort",
|
|
66
|
+
"into":<name> }
|
|
67
|
+
Capture a value off the live screen into {{ctx.<name>}} for a later step to use. Use it
|
|
68
|
+
when the test must ACT ON a value it cannot know in advance — e.g. read the correct
|
|
69
|
+
answer's text, then type that text into a field.
|
|
70
|
+
|
|
71
|
+
PLACEHOLDERS — any positional or flag value, and any control-node selector, may contain:
|
|
72
|
+
{{ctx.NAME}} a value stored by read, or a while-present counter
|
|
73
|
+
{{env.NAME}} an environment variable (use for credentials; never inline a secret)
|
|
74
|
+
{{uuid}} a fresh id, generated once per run (same value everywhere in the run)
|
|
75
|
+
{{timestamp}} epoch ms, once per run {{run_id}} this run's id
|
|
76
|
+
Use {{uuid}} when the test needs data that must be unique per run, e.g. a signup email
|
|
77
|
+
like "user-{{uuid}}@example.com" — never a hard-coded literal, which collides on rerun.
|
|
78
|
+
|
|
79
|
+
NESTING: control nodes may nest ONE level — a control node inside a control node, whose
|
|
80
|
+
body is command leaves. A repeat containing a when is the shape for "until the flow ends,
|
|
81
|
+
handle whichever screen is showing", and it is legal. Three levels is not.
|
|
82
|
+
EXCEPT: if-present and while-present may go one level deeper (their bodies are leaves), so
|
|
83
|
+
repeat { when { while-present { tap ... } } } — walk an index-addressed list
|
|
84
|
+
repeat { when { if-present { tap ... } } } — an optional step inside a branch
|
|
85
|
+
are both legal. Use them rather than approximating.
|
|
86
|
+
In particular: "if X appears, tap it" inside a branch is an if-present. Do NOT turn it
|
|
87
|
+
into a bare wait + tap — wait FAILS the test when X never appears, and "if" means it
|
|
88
|
+
might not. That mistake reads as a passing plan and fails on the first run where the app
|
|
89
|
+
skips that step.
|
|
90
|
+
|
|
91
|
+
Inside a "repeat until X" loop, GUARD a tap whose target is the thing that brings X about:
|
|
92
|
+
repeat until <form> { if-present <button> { tap <button> } ; screenshot }
|
|
93
|
+
Once the transition starts, <button> is gone — so on the final iteration an unguarded tap
|
|
94
|
+
misses and fails the whole run, even though the loop did its job. The guard makes that
|
|
95
|
+
last lap a no-op instead of an error. Apply this whenever the prose says "tap ... until"
|
|
96
|
+
or "repeatedly until".
|
|
97
|
+
|
|
98
|
+
Do NOT flatten a branch into an unconditional sequence: emitting the taps for ALL the
|
|
99
|
+
kinds of screen one after another is wrong — on any given iteration most of them are not
|
|
100
|
+
there, and the test will fail on the first one that is missing. Use when.
|
|
101
|
+
|
|
102
|
+
Do NOT hard-code a run of indices you were not told the length of. If the prose says
|
|
103
|
+
"tap each pair", "until every pair is matched", or "tap the bubbles in order", the COUNT
|
|
104
|
+
varies per run — emit a while-present over {{ctx.i}} rather than tap _0, _1, _2, _3.
|
|
105
|
+
A hard-coded list is right only when the prose states the exact count.
|
|
44
106
|
|
|
45
107
|
SELECTORS (the engine auto-heals case/whitespace/partial, so prefer stable identifiers):
|
|
46
108
|
@login resource-id 'login' (shorthand for id:login)
|
|
@@ -51,6 +113,11 @@ SELECTORS (the engine auto-heals case/whitespace/partial, so prefer stable ident
|
|
|
51
113
|
"Sign in" bare string == text:Sign in
|
|
52
114
|
|
|
53
115
|
RULES:
|
|
116
|
+
- --enabled on a tap makes it match only a control that is ACTIONABLE right now, and (with
|
|
117
|
+
auto-wait) wait until it becomes so. Use it for any button that the app disables until
|
|
118
|
+
something else is done — a Check/Submit/Continue that only lights up once an answer is
|
|
119
|
+
selected or a form is valid. Without it the step taps a dead control, does nothing, and
|
|
120
|
+
the failure surfaces later as a confusing timeout on the NEXT step.
|
|
54
121
|
- assert is for VERIFICATION only and is terminal — never use it as a step you expect to
|
|
55
122
|
fail. Put genuinely-optional UI behind if-present.
|
|
56
123
|
- Prefer resource-id / accessibility selectors over visible text where possible.
|
package/dist/agent/ir.js
CHANGED
|
@@ -18,10 +18,25 @@
|
|
|
18
18
|
// structured-output schemas can't express an arbitrary-key object. A pure boolean
|
|
19
19
|
// flag is value:"true" (flagBool reads 'true' as true; flagStr reads the string).
|
|
20
20
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
-
exports.InvalidPlanError = exports.REPAIR_DECISION_JSON_SCHEMA = exports.PLAN_JSON_SCHEMA = exports.DEFAULT_LOOP_CAP = exports.KNOWN_COMMANDS = void 0;
|
|
21
|
+
exports.InvalidPlanError = exports.REPAIR_DECISION_JSON_SCHEMA = exports.PLAN_JSON_SCHEMA = exports.DEFAULT_LOOP_CAP = exports.KNOWN_COMMANDS = exports.isControlNode = exports.MAX_CONTROL_DEPTH = void 0;
|
|
22
|
+
exports.bodiesOf = bodiesOf;
|
|
22
23
|
exports.validateNode = validateNode;
|
|
23
24
|
exports.parsePlan = parsePlan;
|
|
24
25
|
exports.leafToFlags = leafToFlags;
|
|
26
|
+
/** Max control-node nesting. 1 = a control node at top level whose body is leaves
|
|
27
|
+
* (the v1 rule). 2 = a control node inside that body, whose own body is leaves —
|
|
28
|
+
* i.e. `repeat { when { … } }`, the loop-that-branches shape a real dynamic flow
|
|
29
|
+
* needs. The schema is hand-unrolled to exactly this depth, so raising it means
|
|
30
|
+
* editing controlSchema()'s nesting as well as this constant. */
|
|
31
|
+
exports.MAX_CONTROL_DEPTH = 2;
|
|
32
|
+
const isControlNode = (n) => n.type === 'if-present' || n.type === 'when' || n.type === 'repeat' || n.type === 'while-present';
|
|
33
|
+
exports.isControlNode = isControlNode;
|
|
34
|
+
/** Every body a control node owns (a `when` owns one per branch, plus `else`). */
|
|
35
|
+
function bodiesOf(n) {
|
|
36
|
+
if (n.type === 'when')
|
|
37
|
+
return [...n.branches.map((b) => b.body), ...(n.else ? [n.else] : [])];
|
|
38
|
+
return [n.body];
|
|
39
|
+
}
|
|
25
40
|
/** Commands a leaf step is allowed to carry — the agent-emittable ACTION/assertion verbs
|
|
26
41
|
* the grammar offers, a SUBSET of cli.ts's dispatch (inspection/diagnostic commands are
|
|
27
42
|
* excluded; see the note in the set). The engine validates every step — including a
|
|
@@ -47,10 +62,115 @@ exports.KNOWN_COMMANDS = new Set([
|
|
|
47
62
|
exports.DEFAULT_LOOP_CAP = 25;
|
|
48
63
|
/**
|
|
49
64
|
* JSON Schema for `output_config.format` so the model returns a guaranteed-valid
|
|
50
|
-
* Plan. Deliberately NON-RECURSIVE:
|
|
51
|
-
*
|
|
52
|
-
*
|
|
65
|
+
* Plan. Deliberately NON-RECURSIVE: instead of a `Plan -> node -> Plan` cycle
|
|
66
|
+
* (structured output rejects recursive schemas) the control levels are HAND-UNROLLED
|
|
67
|
+
* to MAX_CONTROL_DEPTH. Raising the depth means adding another unrolled level here.
|
|
53
68
|
*/
|
|
69
|
+
const stepItems = (bodyItems) => ({
|
|
70
|
+
anyOf: [
|
|
71
|
+
leafSchema(),
|
|
72
|
+
readSchema(),
|
|
73
|
+
{
|
|
74
|
+
type: 'object',
|
|
75
|
+
additionalProperties: false,
|
|
76
|
+
required: ['type', 'selector', 'body'],
|
|
77
|
+
properties: {
|
|
78
|
+
type: { type: 'string', enum: ['if-present'] },
|
|
79
|
+
selector: { type: 'string' },
|
|
80
|
+
body: { type: 'array', items: bodyItems },
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
type: 'object',
|
|
85
|
+
additionalProperties: false,
|
|
86
|
+
required: ['type', 'branches'],
|
|
87
|
+
properties: {
|
|
88
|
+
type: { type: 'string', enum: ['when'] },
|
|
89
|
+
branches: {
|
|
90
|
+
type: 'array',
|
|
91
|
+
items: {
|
|
92
|
+
type: 'object',
|
|
93
|
+
additionalProperties: false,
|
|
94
|
+
required: ['selector', 'body'],
|
|
95
|
+
properties: { selector: { type: 'string' }, body: { type: 'array', items: bodyItems } },
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
else: { type: 'array', items: bodyItems },
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
type: 'object',
|
|
103
|
+
additionalProperties: false,
|
|
104
|
+
required: ['type', 'selector', 'cap', 'body'],
|
|
105
|
+
properties: {
|
|
106
|
+
type: { type: 'string', enum: ['repeat'] },
|
|
107
|
+
selector: { type: 'string' },
|
|
108
|
+
cap: { type: 'integer' },
|
|
109
|
+
body: { type: 'array', items: bodyItems },
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
type: 'object',
|
|
114
|
+
additionalProperties: false,
|
|
115
|
+
required: ['type', 'selector', 'cap', 'body'],
|
|
116
|
+
properties: {
|
|
117
|
+
type: { type: 'string', enum: ['while-present'] },
|
|
118
|
+
selector: { type: 'string' },
|
|
119
|
+
bind: { type: 'string' },
|
|
120
|
+
cap: { type: 'integer' },
|
|
121
|
+
body: { type: 'array', items: bodyItems },
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
});
|
|
126
|
+
/** The innermost level: leaves, reads, and the two SIMPLE control nodes — `if-present`
|
|
127
|
+
* and `while-present` — restricted to leaf-only bodies so they cannot open a further
|
|
128
|
+
* level.
|
|
129
|
+
*
|
|
130
|
+
* Both were added from measured failures, and both are the same failure: when the model
|
|
131
|
+
* needs a construct one level deeper than the grammar allows, it does not give up — it
|
|
132
|
+
* DEGRADES to an unconditional approximation, which is exactly the bug #33 is about.
|
|
133
|
+
* - Without `while-present` here, a compile of the dynamic-lesson repro produced
|
|
134
|
+
* `repeat { when { … } }` correctly but HARD-CODED the index lists inside each branch
|
|
135
|
+
* (pairs 0..3, bubbles 0..5) — broken the moment a question has a different count.
|
|
136
|
+
* - Without `if-present` here, the same compile turned the prose "IF a Continue button
|
|
137
|
+
* appears, tap it" into an unconditional `wait id:continue_button_id` inside every
|
|
138
|
+
* branch. The app auto-advances and shows no Continue, so all three runs failed on
|
|
139
|
+
* that wait — a conditional flattened into an assertion, one level down.
|
|
140
|
+
*
|
|
141
|
+
* A full third level for every node would ~3x a 28KB schema (bad for OpenAI strict
|
|
142
|
+
* limits, worse for cursor, which injects the schema as prompt text). Granting it to the
|
|
143
|
+
* two leaf-bodied nodes is the cheap, bounded version. `when` and `repeat` stay at
|
|
144
|
+
* MAX_CONTROL_DEPTH: they are the nodes whose bodies want to be deep, and allowing them
|
|
145
|
+
* here is what would actually cost the schema. */
|
|
146
|
+
const innerControls = () => {
|
|
147
|
+
const leafBody = { type: 'array', items: { anyOf: [leafSchema(), readSchema()] } };
|
|
148
|
+
return [
|
|
149
|
+
{
|
|
150
|
+
type: 'object',
|
|
151
|
+
additionalProperties: false,
|
|
152
|
+
required: ['type', 'selector', 'body'],
|
|
153
|
+
properties: {
|
|
154
|
+
type: { type: 'string', enum: ['if-present'] },
|
|
155
|
+
selector: { type: 'string' },
|
|
156
|
+
body: leafBody,
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
type: 'object',
|
|
161
|
+
additionalProperties: false,
|
|
162
|
+
required: ['type', 'selector', 'cap', 'body'],
|
|
163
|
+
properties: {
|
|
164
|
+
type: { type: 'string', enum: ['while-present'] },
|
|
165
|
+
selector: { type: 'string' },
|
|
166
|
+
bind: { type: 'string' },
|
|
167
|
+
cap: { type: 'integer' },
|
|
168
|
+
body: leafBody,
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
];
|
|
172
|
+
};
|
|
173
|
+
const LEAF_ONLY_ITEMS = { anyOf: [leafSchema(), readSchema(), ...innerControls()] };
|
|
54
174
|
exports.PLAN_JSON_SCHEMA = {
|
|
55
175
|
type: 'object',
|
|
56
176
|
additionalProperties: false,
|
|
@@ -61,32 +181,7 @@ exports.PLAN_JSON_SCHEMA = {
|
|
|
61
181
|
platform: { type: 'string', enum: ['android', 'ios'] },
|
|
62
182
|
steps: {
|
|
63
183
|
type: 'array',
|
|
64
|
-
items:
|
|
65
|
-
anyOf: [
|
|
66
|
-
leafSchema(),
|
|
67
|
-
{
|
|
68
|
-
type: 'object',
|
|
69
|
-
additionalProperties: false,
|
|
70
|
-
required: ['type', 'selector', 'body'],
|
|
71
|
-
properties: {
|
|
72
|
-
type: { type: 'string', enum: ['if-present'] },
|
|
73
|
-
selector: { type: 'string' },
|
|
74
|
-
body: { type: 'array', items: leafSchema() },
|
|
75
|
-
},
|
|
76
|
-
},
|
|
77
|
-
{
|
|
78
|
-
type: 'object',
|
|
79
|
-
additionalProperties: false,
|
|
80
|
-
required: ['type', 'selector', 'cap', 'body'],
|
|
81
|
-
properties: {
|
|
82
|
-
type: { type: 'string', enum: ['repeat'] },
|
|
83
|
-
selector: { type: 'string' },
|
|
84
|
-
cap: { type: 'integer' },
|
|
85
|
-
body: { type: 'array', items: leafSchema() },
|
|
86
|
-
},
|
|
87
|
-
},
|
|
88
|
-
],
|
|
89
|
-
},
|
|
184
|
+
items: stepItems(stepItems(LEAF_ONLY_ITEMS)),
|
|
90
185
|
},
|
|
91
186
|
},
|
|
92
187
|
};
|
|
@@ -107,6 +202,19 @@ exports.REPAIR_DECISION_JSON_SCHEMA = {
|
|
|
107
202
|
reason: { type: 'string' },
|
|
108
203
|
},
|
|
109
204
|
};
|
|
205
|
+
function readSchema() {
|
|
206
|
+
return {
|
|
207
|
+
type: 'object',
|
|
208
|
+
additionalProperties: false,
|
|
209
|
+
required: ['type', 'selector', 'field', 'into'],
|
|
210
|
+
properties: {
|
|
211
|
+
type: { type: 'string', enum: ['read'] },
|
|
212
|
+
selector: { type: 'string' },
|
|
213
|
+
field: { type: 'string', enum: ['text', 'desc', 'id', 'idShort'] },
|
|
214
|
+
into: { type: 'string' },
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
110
218
|
function leafSchema() {
|
|
111
219
|
return {
|
|
112
220
|
type: 'object',
|
|
@@ -141,11 +249,49 @@ function isFlagSpecArray(v) {
|
|
|
141
249
|
return (Array.isArray(v) &&
|
|
142
250
|
v.every((f) => f && typeof f === 'object' && typeof f.name === 'string' && typeof f.value === 'string'));
|
|
143
251
|
}
|
|
144
|
-
|
|
145
|
-
|
|
252
|
+
const READ_FIELDS = new Set(['text', 'desc', 'id', 'idShort']);
|
|
253
|
+
/** ctx keys are interpolated into selectors as {{ctx.NAME}}; keep them boring so a
|
|
254
|
+
* name can never smuggle regex/template metacharacters into a selector. */
|
|
255
|
+
const CTX_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
256
|
+
/** Validate a single node (used for both compile output and a spliced repair).
|
|
257
|
+
*
|
|
258
|
+
* `depth` is how many control nodes enclose this one. It is the ONLY thing bounding
|
|
259
|
+
* nesting at runtime, and it must agree with PLAN_JSON_SCHEMA's hand-unrolled levels
|
|
260
|
+
* — the schema stops a compliant model, this stops everything else (a repair, a
|
|
261
|
+
* hand-edited plan, a poisoned cache entry). */
|
|
262
|
+
function validateNode(node, where, depth = 0) {
|
|
146
263
|
if (!node || typeof node !== 'object')
|
|
147
264
|
throw new InvalidPlanError(`${where}: not an object`);
|
|
148
265
|
const n = node;
|
|
266
|
+
/** Validate one control body. `allowEmpty` is true only for `when`'s `else`. */
|
|
267
|
+
const validateBody = (raw, label, allowEmpty = false) => {
|
|
268
|
+
if (!Array.isArray(raw))
|
|
269
|
+
throw new InvalidPlanError(`${label}: body must be an array`);
|
|
270
|
+
if (raw.length === 0 && !allowEmpty) {
|
|
271
|
+
// Silence must never read as success — same rule parsePlan applies to the plan.
|
|
272
|
+
throw new InvalidPlanError(`${label}: body is empty (a control body must do something; use when's "else": [] to mean "do nothing")`);
|
|
273
|
+
}
|
|
274
|
+
return raw.map((b, i) => validateNode(b, `${label}[${i}]`, depth + 1));
|
|
275
|
+
};
|
|
276
|
+
/** `if-present` and `while-present` are allowed one level deeper than the branching
|
|
277
|
+
* nodes: they are the conditional and the index-walk an innermost branch body needs,
|
|
278
|
+
* and because their own bodies are leaves they cannot open a further level. Blocking
|
|
279
|
+
* them here does not make the model simplify — it makes it emit an unconditional
|
|
280
|
+
* approximation instead. See LEAF_ONLY_ITEMS. */
|
|
281
|
+
const guardDepth = (extra = 0) => {
|
|
282
|
+
const limit = exports.MAX_CONTROL_DEPTH + extra;
|
|
283
|
+
if (depth >= limit) {
|
|
284
|
+
throw new InvalidPlanError(`${where}: control nodes may nest ${limit} deep at most (got ${depth + 1}) — flatten this or lift it to an outer step`);
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
const selectorOf = () => {
|
|
288
|
+
if (typeof n.selector !== 'string' || !n.selector)
|
|
289
|
+
throw new InvalidPlanError(`${where}: ${n.type} needs a selector`);
|
|
290
|
+
return n.selector;
|
|
291
|
+
};
|
|
292
|
+
// OpenAI/codex strict mode forces every property into `required` and nullifies the
|
|
293
|
+
// optional ones, so an absent `else`/`bind` arrives as null rather than missing.
|
|
294
|
+
const optional = (v, pick) => v === undefined || v === null ? undefined : pick(v);
|
|
149
295
|
switch (n.type) {
|
|
150
296
|
case 'command': {
|
|
151
297
|
if (typeof n.command !== 'string' || !exports.KNOWN_COMMANDS.has(n.command)) {
|
|
@@ -158,22 +304,50 @@ function validateNode(node, where) {
|
|
|
158
304
|
throw new InvalidPlanError(`${where}: flags must be {name,value}[]`);
|
|
159
305
|
return { type: 'command', command: n.command, positionals: n.positionals, flags: n.flags };
|
|
160
306
|
}
|
|
161
|
-
case '
|
|
162
|
-
|
|
163
|
-
if (typeof n.
|
|
164
|
-
throw new InvalidPlanError(`${where}: ${
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
307
|
+
case 'read': {
|
|
308
|
+
const selector = selectorOf();
|
|
309
|
+
if (typeof n.field !== 'string' || !READ_FIELDS.has(n.field)) {
|
|
310
|
+
throw new InvalidPlanError(`${where}: read needs field one of ${[...READ_FIELDS].join('|')} (got ${JSON.stringify(n.field)})`);
|
|
311
|
+
}
|
|
312
|
+
if (typeof n.into !== 'string' || !CTX_NAME_RE.test(n.into)) {
|
|
313
|
+
throw new InvalidPlanError(`${where}: read "into" must be a plain identifier (got ${JSON.stringify(n.into)})`);
|
|
314
|
+
}
|
|
315
|
+
return { type: 'read', selector, field: n.field, into: n.into };
|
|
316
|
+
}
|
|
317
|
+
case 'if-present': {
|
|
318
|
+
guardDepth(1);
|
|
319
|
+
return { type: 'if-present', selector: selectorOf(), body: validateBody(n.body, `${where}.body`) };
|
|
320
|
+
}
|
|
321
|
+
case 'when': {
|
|
322
|
+
guardDepth();
|
|
323
|
+
if (!Array.isArray(n.branches) || n.branches.length === 0) {
|
|
324
|
+
throw new InvalidPlanError(`${where}: when needs at least one branch`);
|
|
325
|
+
}
|
|
326
|
+
const branches = n.branches.map((b, i) => {
|
|
327
|
+
const br = b;
|
|
328
|
+
if (!br || typeof br !== 'object' || typeof br.selector !== 'string' || !br.selector) {
|
|
329
|
+
throw new InvalidPlanError(`${where}.branches[${i}]: needs a selector`);
|
|
330
|
+
}
|
|
331
|
+
return { selector: br.selector, body: validateBody(br.body, `${where}.branches[${i}].body`) };
|
|
172
332
|
});
|
|
173
|
-
|
|
174
|
-
|
|
333
|
+
const els = optional(n.else, (v) => validateBody(v, `${where}.else`, true));
|
|
334
|
+
return { type: 'when', branches, ...(els ? { else: els } : {}) };
|
|
335
|
+
}
|
|
336
|
+
case 'repeat':
|
|
337
|
+
case 'while-present': {
|
|
338
|
+
guardDepth(n.type === 'while-present' ? 1 : 0);
|
|
339
|
+
const selector = selectorOf();
|
|
175
340
|
const cap = typeof n.cap === 'number' && n.cap > 0 ? Math.floor(n.cap) : exports.DEFAULT_LOOP_CAP;
|
|
176
|
-
|
|
341
|
+
const body = validateBody(n.body, `${where}.body`);
|
|
342
|
+
if (n.type === 'repeat')
|
|
343
|
+
return { type: 'repeat', selector, cap, body };
|
|
344
|
+
const bind = optional(n.bind, (v) => {
|
|
345
|
+
if (typeof v !== 'string' || !CTX_NAME_RE.test(v)) {
|
|
346
|
+
throw new InvalidPlanError(`${where}: while-present "bind" must be a plain identifier (got ${JSON.stringify(v)})`);
|
|
347
|
+
}
|
|
348
|
+
return v;
|
|
349
|
+
});
|
|
350
|
+
return { type: 'while-present', selector, cap, body, ...(bind ? { bind } : {}) };
|
|
177
351
|
}
|
|
178
352
|
default:
|
|
179
353
|
throw new InvalidPlanError(`${where}: unknown node type ${JSON.stringify(n.type)}`);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Compile-fidelity lint: does the plan the model produced still say what the prose said?
|
|
3
|
+
//
|
|
4
|
+
// The model is a compiler, and this is the compiler's own sanity check. It exists because
|
|
5
|
+
// compilation is NONDETERMINISTIC in a way that is invisible until a run fails, and two
|
|
6
|
+
// failure modes showed up repeatedly against a real suite:
|
|
7
|
+
//
|
|
8
|
+
// 1. An explicit directive silently vanishes. The same prose ("Launch the app WITH ITS
|
|
9
|
+
// DATA CLEARED so it starts logged-out") compiled to `launch <pkg> --clear` on one
|
|
10
|
+
// run and plain `launch <pkg>` on the next. The plan looked fine and the test failed
|
|
11
|
+
// several steps later, on a screen that only appears when you are already logged in.
|
|
12
|
+
// 2. Conditional prose compiles to an unconditional step. "IF a Continue button appears,
|
|
13
|
+
// tap it" becomes `wait` + `tap`, which FAILS on every run where the app skips it.
|
|
14
|
+
//
|
|
15
|
+
// Both are cheap to detect and cheap to fix: hand the finding back to the model and let it
|
|
16
|
+
// compile once more. That is far better than the alternative, which is a plan that is
|
|
17
|
+
// quietly wrong and burns a device run to say so.
|
|
18
|
+
//
|
|
19
|
+
// Deliberately CONSERVATIVE — a false positive costs a wasted recompile, so every rule
|
|
20
|
+
// requires a fairly unambiguous phrase and checks for a specific structural counterpart.
|
|
21
|
+
// It never edits the plan; the model gets the feedback and stays the author.
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.lintPlan = lintPlan;
|
|
24
|
+
const ir_1 = require("./ir");
|
|
25
|
+
/** Walk every node in the plan, including control-node bodies. */
|
|
26
|
+
function* walk(nodes) {
|
|
27
|
+
for (const n of nodes) {
|
|
28
|
+
yield n;
|
|
29
|
+
if ((0, ir_1.isControlNode)(n))
|
|
30
|
+
for (const body of (0, ir_1.bodiesOf)(n))
|
|
31
|
+
yield* walk(body);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const hasControlNode = (plan) => {
|
|
35
|
+
for (const n of walk(plan.steps))
|
|
36
|
+
if ((0, ir_1.isControlNode)(n))
|
|
37
|
+
return true;
|
|
38
|
+
return false;
|
|
39
|
+
};
|
|
40
|
+
const hasLeafWithFlag = (plan, command, flag) => {
|
|
41
|
+
for (const n of walk(plan.steps)) {
|
|
42
|
+
if (n.type === 'command' && n.command === command && n.flags.some((f) => f.name === flag))
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
};
|
|
47
|
+
/** Phrases that state the app must start from a clean slate. Kept tight on purpose:
|
|
48
|
+
* "clear" alone is far too common (clearing a text field, a clear button). */
|
|
49
|
+
const FRESH_START_RE = /\b(data cleared|cleared data|clear its data|with its data cleared|freshly installed|logged[- ]out|logged out|clean state|from scratch)\b/i;
|
|
50
|
+
/** Phrases that make a step conditional. These are the ones that produced an
|
|
51
|
+
* unconditional step in practice; vaguer hedges ("should", "normally") are excluded. */
|
|
52
|
+
const CONDITIONAL_RE = /\b(if (?:a|an|the|any)\b|may appear|might appear|sometimes|optionally|if present|if shown|if it appears|dismiss any)\b/i;
|
|
53
|
+
/**
|
|
54
|
+
* Check a compiled plan against the prose it came from.
|
|
55
|
+
*
|
|
56
|
+
* @param nl the natural-language test, verbatim
|
|
57
|
+
* @param plan the plan the model just produced
|
|
58
|
+
* @returns findings; empty means the plan is consistent with the prose
|
|
59
|
+
*/
|
|
60
|
+
function lintPlan(nl, plan) {
|
|
61
|
+
const findings = [];
|
|
62
|
+
if (FRESH_START_RE.test(nl) && !hasLeafWithFlag(plan, 'launch', 'clear')) {
|
|
63
|
+
findings.push({
|
|
64
|
+
message: 'The test says the app must start from cleared data / logged out, but no step launches it with --clear. ' +
|
|
65
|
+
'Emit `launch <package> --clear` so the run does not inherit the previous run\'s session.',
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (CONDITIONAL_RE.test(nl) && !hasControlNode(plan)) {
|
|
69
|
+
findings.push({
|
|
70
|
+
message: 'The test describes something that may or may not appear ("if ...", "may appear", "dismiss any ..."), ' +
|
|
71
|
+
'but the plan has no if-present/when node — every step is unconditional. An unconditional step for ' +
|
|
72
|
+
'optional UI fails on every run where that UI does not show. Put the optional part behind if-present ' +
|
|
73
|
+
'(skip when absent), or behind when (when the screen is one of several known kinds).',
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return findings;
|
|
77
|
+
}
|
package/dist/agent/openai.js
CHANGED
|
@@ -116,6 +116,12 @@ class OpenAiProvider {
|
|
|
116
116
|
JSON.stringify(input.seed, null, 2));
|
|
117
117
|
}
|
|
118
118
|
parts.push('NATURAL-LANGUAGE TEST:\n' + input.nl);
|
|
119
|
+
if (input.retryFeedback) {
|
|
120
|
+
// Last, so it is the freshest thing in context: a previous compile of this same
|
|
121
|
+
// test lost something the prose stated. Naming it beats hoping the retry differs.
|
|
122
|
+
parts.push('YOUR PREVIOUS ATTEMPT AT THIS TEST WAS REJECTED. Fix this and emit the whole plan again:\n' +
|
|
123
|
+
input.retryFeedback);
|
|
124
|
+
}
|
|
119
125
|
// Generous completion budget: on reasoning models the plan JSON shares this ceiling
|
|
120
126
|
// with reasoning tokens, so leave headroom (a 'length' finish is surfaced as an error).
|
|
121
127
|
const { json, usage } = await this.call(grammar_1.GRAMMAR, parts.join('\n\n'), ir_1.PLAN_JSON_SCHEMA, 16384);
|
package/dist/cli.js
CHANGED
|
@@ -41,6 +41,7 @@ exports.parseDuration = parseDuration;
|
|
|
41
41
|
exports.waitWindowMs = waitWindowMs;
|
|
42
42
|
exports.waitNote = waitNote;
|
|
43
43
|
exports.formatDeviceTable = formatDeviceTable;
|
|
44
|
+
exports.guardSettleMs = guardSettleMs;
|
|
44
45
|
exports.confineToCwd = confineToCwd;
|
|
45
46
|
exports.assertSafeAppId = assertSafeAppId;
|
|
46
47
|
exports.chooseLogOpts = chooseLogOpts;
|
|
@@ -61,6 +62,7 @@ const output_1 = require("./output");
|
|
|
61
62
|
const run_1 = require("./run");
|
|
62
63
|
const image_1 = require("./image");
|
|
63
64
|
const engine_1 = require("./agent/engine");
|
|
65
|
+
const lint_1 = require("./agent/lint");
|
|
64
66
|
const claude_1 = require("./agent/claude");
|
|
65
67
|
const openai_1 = require("./agent/openai");
|
|
66
68
|
const cli_provider_1 = require("./agent/cli-provider");
|
|
@@ -93,7 +95,11 @@ function buildSelector(raw, flags) {
|
|
|
93
95
|
if (!raw) {
|
|
94
96
|
throw new errors_1.CliError('Missing selector. e.g. `@login_button`, `text:Login`, `desc:Submit`.', 2);
|
|
95
97
|
}
|
|
96
|
-
return (0, selector_1.parseSelector)(raw, {
|
|
98
|
+
return (0, selector_1.parseSelector)(raw, {
|
|
99
|
+
contains: (0, args_1.flagBool)(flags, 'contains'),
|
|
100
|
+
index: (0, args_1.flagNum)(flags, 'index'),
|
|
101
|
+
enabled: (0, args_1.flagBool)(flags, 'enabled'),
|
|
102
|
+
});
|
|
97
103
|
}
|
|
98
104
|
function parsePoint(s) {
|
|
99
105
|
const m = /^(-?\d+)\s*,\s*(-?\d+)$/.exec(s.trim());
|
|
@@ -510,6 +516,19 @@ function shotMaxEdge() {
|
|
|
510
516
|
}
|
|
511
517
|
return DEFAULT_SHOT_MAX_EDGE;
|
|
512
518
|
}
|
|
519
|
+
/** How long a `vk ai` `if-present` guard waits for its selector before deciding the
|
|
520
|
+
* optional UI is absent. `VERIKUN_GUARD_SETTLE_MS` overrides the engine default so the
|
|
521
|
+
* window can be tuned against a real app without a rebuild (0 restores the old
|
|
522
|
+
* single-shot probe). Exported for unit tests. */
|
|
523
|
+
function guardSettleMs() {
|
|
524
|
+
const env = process.env.VERIKUN_GUARD_SETTLE_MS;
|
|
525
|
+
if (env !== undefined && env !== '') {
|
|
526
|
+
const n = Number(env);
|
|
527
|
+
if (Number.isFinite(n) && n >= 0)
|
|
528
|
+
return n;
|
|
529
|
+
}
|
|
530
|
+
return engine_1.DEFAULT_GUARD_SETTLE_MS;
|
|
531
|
+
}
|
|
513
532
|
/** Resolve an `--out` path and confine it to the working directory. A host-side write
|
|
514
533
|
* (a screenshot PNG, captured device logs) must never land outside cwd via a `..`
|
|
515
534
|
* traversal or an absolute path — including when driven by `vk ai` model output, whose
|
|
@@ -654,10 +673,20 @@ function evalAssert(els, sel, flags) {
|
|
|
654
673
|
}
|
|
655
674
|
else if (wantText !== undefined) {
|
|
656
675
|
const contains = (0, args_1.flagBool)(flags, 'contains');
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
676
|
+
// Compare against text OR content-desc, mirroring what the selector layer already
|
|
677
|
+
// does (a `text:` selector falls back to desc when nothing matches on text). Without
|
|
678
|
+
// the fallback this could never pass on a Flutter app, which maps Semantics(label:)
|
|
679
|
+
// to contentDescription and leaves `text` empty on every node — while `text:Foo` as a
|
|
680
|
+
// SELECTOR resolved fine, so the contradiction was silent and read as a real failure.
|
|
681
|
+
// Strictly widening: it only turns false negatives into passes.
|
|
682
|
+
const content = (m) => [m.text, m.desc].filter((v) => v !== '');
|
|
683
|
+
const hit = (v) => contains
|
|
684
|
+
? v.toLowerCase().includes(wantText.toLowerCase())
|
|
685
|
+
: v.trim().toLowerCase() === wantText.trim().toLowerCase();
|
|
686
|
+
pass = matches.some((m) => content(m).some(hit));
|
|
687
|
+
reason = pass
|
|
688
|
+
? 'text matched'
|
|
689
|
+
: `found, but text != ${JSON.stringify(wantText)} (got ${JSON.stringify(matches.flatMap(content))})`;
|
|
661
690
|
}
|
|
662
691
|
else {
|
|
663
692
|
pass = true;
|
|
@@ -1039,8 +1068,38 @@ async function obtainPlan(key, file, opts, cost, provider) {
|
|
|
1039
1068
|
if (seed)
|
|
1040
1069
|
(0, output_1.err)(`[ai] no exact cache; seeding from a prior plan (build ${seed.build ?? 'unknown'})`);
|
|
1041
1070
|
(0, output_1.err)(`[ai] compiling '${file}' with ${opts.model} (effort ${opts.effort ?? 'default'})…`);
|
|
1042
|
-
|
|
1071
|
+
let compiled = await provider.compile({ nl: key.nl, pkg: key.pkg, platform: key.platform, seed: seed?.plan });
|
|
1043
1072
|
cost.add(compiled.usage, 'compile');
|
|
1073
|
+
// Compilation is nondeterministic: the same prose has produced `launch --clear` on one
|
|
1074
|
+
// run and plain `launch` on the next, silently dropping something the test stated. One
|
|
1075
|
+
// guided retry is much cheaper than discovering it as a device-run failure several steps
|
|
1076
|
+
// later. Budget is re-checked HERE because the first attempt has already been billed.
|
|
1077
|
+
const findings = (0, lint_1.lintPlan)(key.nl, compiled.plan);
|
|
1078
|
+
if (findings.length > 0) {
|
|
1079
|
+
const feedback = findings.map((f) => `- ${f.message}`).join('\n');
|
|
1080
|
+
(0, output_1.err)(`[ai] compiled plan does not match the test — recompiling once:\n${feedback}`);
|
|
1081
|
+
if (cost.exceeded()) {
|
|
1082
|
+
(0, output_1.err)('[ai] cost ceiling reached — keeping the first plan rather than paying for a retry');
|
|
1083
|
+
}
|
|
1084
|
+
else {
|
|
1085
|
+
const retry = await provider.compile({
|
|
1086
|
+
nl: key.nl,
|
|
1087
|
+
pkg: key.pkg,
|
|
1088
|
+
platform: key.platform,
|
|
1089
|
+
seed: seed?.plan,
|
|
1090
|
+
retryFeedback: feedback,
|
|
1091
|
+
});
|
|
1092
|
+
cost.add(retry.usage, 'compile');
|
|
1093
|
+
const still = (0, lint_1.lintPlan)(key.nl, retry.plan);
|
|
1094
|
+
// Keep the retry either way: it was compiled with strictly more information. If it
|
|
1095
|
+
// still trips the lint, say so rather than pretending the plan is clean.
|
|
1096
|
+
if (still.length > 0)
|
|
1097
|
+
(0, output_1.err)(`[ai] the retry still does not match the test — running it anyway:\n${still.map((f) => `- ${f.message}`).join('\n')}`);
|
|
1098
|
+
else
|
|
1099
|
+
(0, output_1.err)('[ai] recompile matches the test');
|
|
1100
|
+
compiled = retry;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1044
1103
|
(0, output_1.err)(`[ai] compiled ${compiled.plan.steps.length} top-level step(s) · ${cost.summaryLine()}`);
|
|
1045
1104
|
try {
|
|
1046
1105
|
(0, cache_1.writePlan)(key, compiled.plan);
|
|
@@ -1132,7 +1191,7 @@ async function runAiTest(file, opts, backend, platform, device) {
|
|
|
1132
1191
|
const sealed = run_1.Recorder.archive();
|
|
1133
1192
|
(0, output_1.err)(`[ai] archived the active run ('${existing.name}', ${existing.steps.length} step(s)) → ${sealed.dir}`);
|
|
1134
1193
|
}
|
|
1135
|
-
run_1.Recorder.start(`ai: ${(0, node_path_1.basename)(file)}`, platform, device, true);
|
|
1194
|
+
const started = run_1.Recorder.start(`ai: ${(0, node_path_1.basename)(file)}`, platform, device, true);
|
|
1136
1195
|
// Suppress per-step `out()` so stdout stays the one final result; progress -> stderr.
|
|
1137
1196
|
const prevQuiet = (0, output_1.setOutputQuiet)(true);
|
|
1138
1197
|
let result;
|
|
@@ -1145,6 +1204,8 @@ async function runAiTest(file, opts, backend, platform, device) {
|
|
|
1145
1204
|
log: (m) => (0, output_1.err)(m),
|
|
1146
1205
|
markHealed: (m) => run_1.Recorder.markLastStepHealed(m),
|
|
1147
1206
|
maxRepairs: 3,
|
|
1207
|
+
guardSettleMs: guardSettleMs(),
|
|
1208
|
+
runId: started.id,
|
|
1148
1209
|
deadline,
|
|
1149
1210
|
});
|
|
1150
1211
|
}
|
|
@@ -1649,6 +1710,8 @@ AUTO-WAIT (selector lookups retry until they resolve)
|
|
|
1649
1710
|
up to 5s when a lookup misses, so a settling UI needs no explicit \`wait\`.
|
|
1650
1711
|
--wait <dur> override the window: 8s, 800ms, or bare ms (3000); 0 disables
|
|
1651
1712
|
--no-wait fail fast on the first miss (same as --wait 0)
|
|
1713
|
+
A \`vk ai\` plan's if-present guard has its own smaller settle window (>=2 looks at
|
|
1714
|
+
the screen); VERIKUN_GUARD_SETTLE_MS tunes it, 0 = old single-shot probe.
|
|
1652
1715
|
Ambiguity is never waited on (the elements are already there). The \`wait\`
|
|
1653
1716
|
command stays for explicit polling, including --gone, with --timeout/--interval.
|
|
1654
1717
|
|
package/dist/ui/selector.js
CHANGED
|
@@ -21,8 +21,20 @@ function parseSelector(raw, opts = {}) {
|
|
|
21
21
|
}
|
|
22
22
|
if (!value)
|
|
23
23
|
throw new errors_1.CliError(`Empty selector value in '${raw}'`, 2);
|
|
24
|
-
return { kind, value, contains: !!opts.contains, index: opts.index, raw };
|
|
24
|
+
return { kind, value, contains: !!opts.contains, index: opts.index, enabled: opts.enabled, raw };
|
|
25
25
|
}
|
|
26
|
+
/** Is this element actionable right now?
|
|
27
|
+
*
|
|
28
|
+
* Just `enabled` — the a11y attribute, matching what Maestro's `enabled: true` means.
|
|
29
|
+
* An earlier version also required `clickable || longClickable`, reasoning that a
|
|
30
|
+
* disabled Button might report clickable=false. That was speculation and it was wrong in
|
|
31
|
+
* the direction that hurts: plenty of legitimate tap targets are CONTAINERS whose own
|
|
32
|
+
* clickable flag is false (the tappable child is inside), so the extra conjunct filtered
|
|
33
|
+
* out real elements and turned `--enabled` into a source of phantom "not found" misses —
|
|
34
|
+
* which then burned model repairs. Prefer under-filtering here: a tap on a present-but-
|
|
35
|
+
* odd element fails loudly, whereas a selector that silently matches nothing looks like
|
|
36
|
+
* app drift and sends the heal loop chasing it. */
|
|
37
|
+
const isActionable = (e) => e.enabled;
|
|
26
38
|
const norm = (s) => s.trim().toLowerCase();
|
|
27
39
|
const strip = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '');
|
|
28
40
|
/** The ordered match tiers for a selector. First tier with a hit wins. */
|
|
@@ -77,6 +89,10 @@ function tiers(sel) {
|
|
|
77
89
|
return sel.contains ? list.filter((t) => t.tier !== 'exact') : list;
|
|
78
90
|
}
|
|
79
91
|
function matchElements(elements, sel) {
|
|
92
|
+
// Applied BEFORE the tier ladder, not after: filtering the candidate pool keeps a
|
|
93
|
+
// disabled exact match from shadowing an enabled partial one.
|
|
94
|
+
if (sel.enabled)
|
|
95
|
+
elements = elements.filter(isActionable);
|
|
80
96
|
for (const { tier, test } of tiers(sel)) {
|
|
81
97
|
const found = elements.filter(test);
|
|
82
98
|
if (found.length === 0)
|
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.
|
|
6
|
+
exports.VERSION = '0.11.0';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "verikun",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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",
|