verikun 0.11.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 +20 -6
- package/dist/agent/engine.js +62 -6
- package/dist/cli.js +54 -42
- 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/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -286,16 +286,30 @@ vk suite tests/ --app com.example.app --server "$VERIKUN_SERVER" # remote devi
|
|
|
286
286
|
- **Each test is a full `vk ai` run** — plan cache, self-healing, cost budget, and
|
|
287
287
|
its own archived JUnit + HTML report under `./.verikun/runs/<id>/`. A test that
|
|
288
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.
|
|
289
295
|
- **The suite writes an overview** to `./.verikun/suites/<id>/`:
|
|
290
296
|
- **`index.json`** — a stable, `schemaVersion`ed manifest: per-test pass/fail,
|
|
291
297
|
steps, model repairs, cost, duration, and the run id, plus suite totals. This
|
|
292
298
|
is the **output contract for reporting** — upload/publish steps compose over
|
|
293
299
|
it (see the [CI recipe](#ci-recipe)) instead of verikun growing upload plugins.
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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.
|
|
299
313
|
|
|
300
314
|
## Remote devices — `vk server`
|
|
301
315
|
|
|
@@ -435,7 +449,7 @@ condition as a step in its own right.
|
|
|
435
449
|
| `0` | success / found / assertion passed |
|
|
436
450
|
| `1` | not found / assertion failed / wait timeout |
|
|
437
451
|
| `2` | usage error or ambiguous selector (caller must refine) |
|
|
438
|
-
| `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 |
|
|
439
453
|
|
|
440
454
|
Data goes to stdout; diagnostics/errors go to stderr.
|
|
441
455
|
|
package/dist/agent/engine.js
CHANGED
|
@@ -6,6 +6,29 @@ const node_crypto_1 = require("node:crypto");
|
|
|
6
6
|
const selector_1 = require("../ui/selector");
|
|
7
7
|
const errors_1 = require("../errors");
|
|
8
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
|
+
}
|
|
9
32
|
const describe = (leaf) => [leaf.command, ...leaf.positionals, ...leaf.flags.map((f) => (f.value === 'true' ? `--${f.name}` : `--${f.name} ${f.value}`))]
|
|
10
33
|
.join(' ')
|
|
11
34
|
.trim();
|
|
@@ -153,7 +176,10 @@ async function runPlan(plan, deps) {
|
|
|
153
176
|
* - a dump that SUCCEEDS but does not match is re-polled only while settleMs
|
|
154
177
|
* remains. At settleMs=0 that means exactly one pass — the fast, single-shot
|
|
155
178
|
* probe a loop-exit check needs.
|
|
156
|
-
* So one dump attempt always happens regardless of the window.
|
|
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. */
|
|
157
183
|
const present = async (selector, settleMs) => {
|
|
158
184
|
let sel;
|
|
159
185
|
try {
|
|
@@ -173,15 +199,21 @@ async function runPlan(plan, deps) {
|
|
|
173
199
|
// swings ~10x across devices, so "how long to wait" cannot be expressed in wall clock
|
|
174
200
|
// alone without making the guard's patience device-dependent.
|
|
175
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;
|
|
176
206
|
const minLooks = settleMs > 0 ? 2 : 1;
|
|
177
207
|
for (;;) {
|
|
178
208
|
let els;
|
|
179
209
|
for (let i = 0; i < 2; i++) {
|
|
180
210
|
try {
|
|
181
211
|
els = await deps.getElements();
|
|
212
|
+
everRead = true;
|
|
182
213
|
}
|
|
183
|
-
catch {
|
|
214
|
+
catch (e) {
|
|
184
215
|
els = undefined; // transient dump failure — retry once before concluding "absent"
|
|
216
|
+
lastErr = e;
|
|
185
217
|
}
|
|
186
218
|
// An EMPTY tree is not a screen, it is a bad read: a live app always has nodes, and
|
|
187
219
|
// this device routinely returns a partial/blank dump mid-transition (measured: `ui`
|
|
@@ -196,8 +228,14 @@ async function runPlan(plan, deps) {
|
|
|
196
228
|
if (els !== undefined && (0, selector_1.matchElements)(els, sel).matches.length > 0)
|
|
197
229
|
return true;
|
|
198
230
|
const remaining = deadline - Date.now();
|
|
199
|
-
if (looks >= minLooks && remaining <= 0)
|
|
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);
|
|
200
237
|
return false;
|
|
238
|
+
}
|
|
201
239
|
await sleep(Math.max(0, Math.min(GUARD_POLL_MS, remaining)));
|
|
202
240
|
}
|
|
203
241
|
};
|
|
@@ -300,9 +338,12 @@ async function runPlan(plan, deps) {
|
|
|
300
338
|
if (isHealable(outcome)) {
|
|
301
339
|
return { status: 'fail', where, reason: `unresolved after ${maxRepairs} repair attempt(s): ${outcome.error.message.split('\n')[0]}` };
|
|
302
340
|
}
|
|
303
|
-
// 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.
|
|
304
345
|
const reason = outcome.error ? outcome.error.message.split('\n')[0] : `exited ${outcome.code}`;
|
|
305
|
-
return { status: 'fail', where, reason };
|
|
346
|
+
return { status: isEnvOutcome(outcome) ? 'env' : 'fail', where, reason };
|
|
306
347
|
}
|
|
307
348
|
async function walkBody(body, parentWhere, guard) {
|
|
308
349
|
for (let j = 0; j < body.length; j++) {
|
|
@@ -496,13 +537,28 @@ async function runPlan(plan, deps) {
|
|
|
496
537
|
deps.log(`[ai] run timeout reached before steps[${i}] — aborting`);
|
|
497
538
|
return { ok: false, plan, modelRepairs, improvements, abortedForTimeout: true };
|
|
498
539
|
}
|
|
499
|
-
|
|
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
|
+
}
|
|
500
550
|
if (res.status === 'budget') {
|
|
501
551
|
return { ok: false, plan, modelRepairs, improvements, abortedForBudget: true };
|
|
502
552
|
}
|
|
503
553
|
if (res.status === 'timeout') {
|
|
504
554
|
return { ok: false, plan, modelRepairs, improvements, abortedForTimeout: true };
|
|
505
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
|
+
}
|
|
506
562
|
if (res.status === 'fail') {
|
|
507
563
|
deps.log(`[ai] FAILED at ${res.where}: ${res.reason}`);
|
|
508
564
|
return { ok: false, plan, modelRepairs, improvements, failure: { where: res.where, reason: res.reason } };
|
package/dist/cli.js
CHANGED
|
@@ -238,54 +238,41 @@ function formatDeviceTable(devices) {
|
|
|
238
238
|
// Pad every cell except the last shown column (no trailing whitespace); join with 2 spaces.
|
|
239
239
|
return rows.map((r) => r.map((cell, i) => (i === r.length - 1 ? cell : cell.padEnd(widths[i]))).join(' ').trimEnd());
|
|
240
240
|
}
|
|
241
|
+
/** Render one shared ToolProbe the way doctor always has: present -> stdout, failure +
|
|
242
|
+
* hint -> stderr. Unlike `Driver.preflight()` (which throws on the first failure),
|
|
243
|
+
* doctor reports every probe so one run lists everything that needs fixing. */
|
|
244
|
+
function reportProbe(p) {
|
|
245
|
+
if (p.ok) {
|
|
246
|
+
// A multi-line detail (simctl's booted-device listing) reads as its own block
|
|
247
|
+
// rather than smashed onto the `name:` line.
|
|
248
|
+
if (p.detail.includes('\n')) {
|
|
249
|
+
(0, output_1.out)(`${p.name}: present`);
|
|
250
|
+
(0, output_1.out)(p.detail);
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
(0, output_1.out)(`${p.name}: ${p.detail}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
(0, output_1.err)(`${p.name}: ${p.detail}`);
|
|
258
|
+
if (p.hint)
|
|
259
|
+
(0, output_1.err)(` ${p.hint}`);
|
|
260
|
+
}
|
|
261
|
+
return p.ok;
|
|
262
|
+
}
|
|
241
263
|
function cmdDoctor(ctx) {
|
|
242
264
|
if (ctx.platform === 'ios') {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
(0, output_1.out)('xcrun: present');
|
|
246
|
-
(0, output_1.out)(r.stdout.trim() || '(no booted simulators)');
|
|
247
|
-
}
|
|
248
|
-
catch (e) {
|
|
249
|
-
// Not necessarily missing: runText also throws on a spawn timeout or other exec
|
|
250
|
-
// failure, so surface the real reason rather than always claiming "NOT FOUND".
|
|
251
|
-
(0, output_1.err)(`xcrun: ${e.message}`);
|
|
252
|
-
(0, output_1.err)(' (if the Xcode command-line tools are not installed: `xcode-select --install`)');
|
|
265
|
+
// xcrun is the floor: without it there is no device list to reason about.
|
|
266
|
+
if (!reportProbe((0, drivers_1.probeXcrun)()))
|
|
253
267
|
return 3;
|
|
254
|
-
}
|
|
255
268
|
// idb (+ its companion) powers everything interactive: ui/tap/text/swipe/key/logs.
|
|
256
|
-
const
|
|
257
|
-
let idbOk = true;
|
|
258
|
-
try {
|
|
259
|
-
(0, exec_1.runText)(idb, ['--help']); // idb has no --version; --help confirms the binary runs
|
|
260
|
-
(0, output_1.out)('idb: present');
|
|
261
|
-
}
|
|
262
|
-
catch (e) {
|
|
263
|
-
(0, output_1.err)(`idb: ${e.message}`);
|
|
264
|
-
(0, output_1.err)(' needed for ui/tap/text/swipe/key/logs — install: `brew install idb-companion` then `pip install fb-idb`');
|
|
265
|
-
idbOk = false;
|
|
266
|
-
}
|
|
267
|
-
try {
|
|
268
|
-
(0, exec_1.runText)('idb_companion', ['--help']);
|
|
269
|
-
(0, output_1.out)('idb_companion: present');
|
|
270
|
-
}
|
|
271
|
-
catch (e) {
|
|
272
|
-
(0, output_1.err)(`idb_companion: ${e.message}`);
|
|
273
|
-
(0, output_1.err)(' install: `brew install idb-companion`');
|
|
274
|
-
idbOk = false;
|
|
275
|
-
}
|
|
269
|
+
const idbOk = [(0, drivers_1.probeIdb)(), (0, drivers_1.probeIdbCompanion)()].map(reportProbe).every(Boolean);
|
|
276
270
|
(0, output_1.out)('note: simulator screenshots + launch/stop work via simctl; ui/tap/text/swipe/key/logs use idb.');
|
|
277
271
|
return idbOk ? 0 : 3;
|
|
278
272
|
}
|
|
279
273
|
const adb = process.env.ADB || 'adb';
|
|
280
|
-
|
|
281
|
-
(0, output_1.out)('adb: ' + (0, exec_1.runText)(adb, ['version']).stdout.split('\n')[0]);
|
|
282
|
-
}
|
|
283
|
-
catch (e) {
|
|
284
|
-
// Not necessarily missing: runText also throws on a spawn timeout or other exec
|
|
285
|
-
// failure — surface the real reason rather than always claiming "NOT FOUND".
|
|
286
|
-
(0, output_1.err)(`adb: ${e.message}`);
|
|
274
|
+
if (!reportProbe((0, drivers_1.probeAdb)()))
|
|
287
275
|
return 3;
|
|
288
|
-
}
|
|
289
276
|
const devices = ctx.driver.listDevices();
|
|
290
277
|
const usable = devices.filter((d) => d.state === 'device');
|
|
291
278
|
(0, output_1.out)(`devices: ${devices.length} attached, ${usable.length} usable`);
|
|
@@ -1113,6 +1100,11 @@ async function resolveBackend(platform, device, flags) {
|
|
|
1113
1100
|
const server = (0, args_1.flagStr)(flags, 'server') || process.env.VERIKUN_SERVER || undefined;
|
|
1114
1101
|
if (!server) {
|
|
1115
1102
|
const driver = (0, drivers_1.getDriver)(platform, device);
|
|
1103
|
+
// Fail fast on a broken toolchain BEFORE any model spend — the mirror of the
|
|
1104
|
+
// remote branch's pingServer below. Without this, a missing `idb` isn't noticed
|
|
1105
|
+
// until the first step that reads the hierarchy (launch/screenshot go through
|
|
1106
|
+
// simctl on a simulator), by which point every test in a suite has been compiled.
|
|
1107
|
+
driver.preflight();
|
|
1116
1108
|
return {
|
|
1117
1109
|
backend: {
|
|
1118
1110
|
exec: (command, positionals, f) => executeOutcome(command, positionals, f, driver),
|
|
@@ -1126,6 +1118,7 @@ async function resolveBackend(platform, device, flags) {
|
|
|
1126
1118
|
else
|
|
1127
1119
|
driver.clearApp(appId);
|
|
1128
1120
|
},
|
|
1121
|
+
preflight: () => driver.preflight(),
|
|
1129
1122
|
},
|
|
1130
1123
|
platform,
|
|
1131
1124
|
device,
|
|
@@ -1142,8 +1135,20 @@ async function resolveBackend(platform, device, flags) {
|
|
|
1142
1135
|
const health = await (0, remote_1.pingServer)(opts); // fails fast (exit 3) on a bad URL or key
|
|
1143
1136
|
runCtx = { platform: health.platform, device: health.serial };
|
|
1144
1137
|
(0, output_1.err)(`[verikun] server ${server}: ${health.platform} · device ${health.serial} · verikun ${health.version}`);
|
|
1138
|
+
const remote = (0, remote_1.createRemoteBackend)(opts, health);
|
|
1145
1139
|
return {
|
|
1146
|
-
backend:
|
|
1140
|
+
backend: {
|
|
1141
|
+
...remote,
|
|
1142
|
+
// Ping first (URL, key, version), then fetch the hierarchy once. The ping alone
|
|
1143
|
+
// is NOT enough as a health probe: /v1/health answers from config captured at
|
|
1144
|
+
// server startup and never touches the device, so it cannot see a phone that was
|
|
1145
|
+
// unplugged next to the server — the suite's mid-run re-probe would call it
|
|
1146
|
+
// healthy and keep grinding. One dump is the cheap call that actually proves it.
|
|
1147
|
+
preflight: async () => {
|
|
1148
|
+
await (0, remote_1.pingServer)(opts);
|
|
1149
|
+
await remote.getElements();
|
|
1150
|
+
},
|
|
1151
|
+
},
|
|
1147
1152
|
platform: health.platform,
|
|
1148
1153
|
device: health.serial,
|
|
1149
1154
|
remote: { url: server, version: health.version },
|
|
@@ -1249,7 +1254,9 @@ async function runAiTest(file, opts, backend, platform, device) {
|
|
|
1249
1254
|
? `ABORTED — cost ceiling $${opts.maxCostUsd} reached`
|
|
1250
1255
|
: result.abortedForTimeout
|
|
1251
1256
|
? `ABORTED — run timeout (${Math.round(opts.timeoutMs / 1000)}s) reached`
|
|
1252
|
-
:
|
|
1257
|
+
: result.abortedForEnv
|
|
1258
|
+
? `ABORTED — environment: ${result.failure?.reason}`
|
|
1259
|
+
: `FAIL at ${result.failure?.where}: ${result.failure?.reason}`;
|
|
1253
1260
|
(0, output_1.err)(`[ai] ${status} · ${costLine}`);
|
|
1254
1261
|
(0, output_1.err)(`[ai] report: ${htmlPath}`);
|
|
1255
1262
|
if (result.improvements.length) {
|
|
@@ -1271,6 +1278,7 @@ async function runAiTest(file, opts, backend, platform, device) {
|
|
|
1271
1278
|
...(result.failure ? { failure: result.failure } : {}),
|
|
1272
1279
|
...(result.abortedForBudget ? { abortedForBudget: true } : {}),
|
|
1273
1280
|
...(result.abortedForTimeout ? { abortedForTimeout: true } : {}),
|
|
1281
|
+
...(result.abortedForEnv ? { abortedForEnv: true } : {}),
|
|
1274
1282
|
};
|
|
1275
1283
|
}
|
|
1276
1284
|
async function cmdAi(positionals, flags) {
|
|
@@ -1311,12 +1319,15 @@ async function cmdAi(positionals, flags) {
|
|
|
1311
1319
|
...(result.failure ? { failure: result.failure } : {}),
|
|
1312
1320
|
...(result.abortedForBudget ? { abortedForBudget: true } : {}),
|
|
1313
1321
|
...(result.abortedForTimeout ? { abortedForTimeout: true } : {}),
|
|
1322
|
+
...(result.abortedForEnv ? { abortedForEnv: true } : {}),
|
|
1314
1323
|
});
|
|
1315
1324
|
}
|
|
1316
1325
|
else if (result.reportHtml) {
|
|
1317
1326
|
(0, output_1.out)(result.reportHtml); // primary machine result: the report path
|
|
1318
1327
|
}
|
|
1319
|
-
|
|
1328
|
+
// An environment failure is exit 3, not 1: the box is broken, not the app. Exit 1
|
|
1329
|
+
// here would be indistinguishable from a real regression and page the wrong person.
|
|
1330
|
+
return result.abortedForEnv ? 3 : result.ok ? 0 : 1;
|
|
1320
1331
|
}
|
|
1321
1332
|
// ---------------------------------------------------------------------------
|
|
1322
1333
|
// install — put an app build on the device (local driver or remote vk server)
|
|
@@ -1369,6 +1380,7 @@ async function cmdSuiteEntry(positionals, flags) {
|
|
|
1369
1380
|
// Reset app state between tests only when the app id is known; without --app,
|
|
1370
1381
|
// each test is responsible for its own isolation (e.g. `launch --clear`).
|
|
1371
1382
|
reset: app ? () => backend.reset(app) : undefined,
|
|
1383
|
+
preflight: () => backend.preflight?.(),
|
|
1372
1384
|
});
|
|
1373
1385
|
}
|
|
1374
1386
|
finally {
|
package/dist/drivers/adb.js
CHANGED
|
@@ -1,11 +1,31 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.AdbDriver = void 0;
|
|
4
|
+
exports.probeAdb = probeAdb;
|
|
4
5
|
exports.escapeText = escapeText;
|
|
5
6
|
const errors_1 = require("../errors");
|
|
6
7
|
const exec_1 = require("../exec");
|
|
7
8
|
const android_parse_1 = require("../ui/android-parse");
|
|
8
9
|
const ADB = process.env.ADB || 'adb';
|
|
10
|
+
const ADB_HINT = 'install the Android platform-tools (`brew install --cask android-platform-tools`), or point ADB at the binary';
|
|
11
|
+
/** Is `adb` present and runnable? Shared by `vk doctor` and AdbDriver.preflight() so
|
|
12
|
+
* the two can't drift on what "the Android toolchain works" means. */
|
|
13
|
+
function probeAdb() {
|
|
14
|
+
try {
|
|
15
|
+
const r = (0, exec_1.runText)(ADB, ['version']);
|
|
16
|
+
// runText only throws when the binary can't be SPAWNED, so a broken-but-present adb
|
|
17
|
+
// needs its exit code checked too — otherwise it reports as healthy.
|
|
18
|
+
if (r.code !== 0) {
|
|
19
|
+
return { name: 'adb', ok: false, detail: `adb version exited ${r.code}: ${r.stderr.trim()}`, hint: ADB_HINT };
|
|
20
|
+
}
|
|
21
|
+
return { name: 'adb', ok: true, detail: r.stdout.split('\n')[0] };
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
// Not necessarily missing: runText also throws on a spawn timeout or other exec
|
|
25
|
+
// failure — surface the real reason rather than always claiming "NOT FOUND".
|
|
26
|
+
return { name: 'adb', ok: false, detail: e.message, hint: ADB_HINT };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
9
29
|
// Named keys -> Android keycodes. Numeric codes are also accepted directly.
|
|
10
30
|
const KEYCODES = {
|
|
11
31
|
enter: 66,
|
|
@@ -79,6 +99,27 @@ class AdbDriver {
|
|
|
79
99
|
constructor(serial) {
|
|
80
100
|
this.requested = serial;
|
|
81
101
|
}
|
|
102
|
+
preflight() {
|
|
103
|
+
const adb = probeAdb();
|
|
104
|
+
if (!adb.ok)
|
|
105
|
+
throw (0, errors_1.probeFailure)(adb);
|
|
106
|
+
// Resolving the serial is the other half of "can I drive anything?": it throws
|
|
107
|
+
// exit 3 with no device attached and exit 2 when several are and none was chosen.
|
|
108
|
+
const serial = this.resolvedSerial();
|
|
109
|
+
// …but that answer is cached, so ask the device itself. That is what lets a suite's
|
|
110
|
+
// mid-run re-probe notice a phone that was unplugged or an emulator that died,
|
|
111
|
+
// rather than replaying the resolution it made before anything went wrong.
|
|
112
|
+
const state = (0, exec_1.runText)(ADB, ['-s', serial, 'get-state']);
|
|
113
|
+
const got = state.stdout.trim();
|
|
114
|
+
if (state.code !== 0 || got !== 'device') {
|
|
115
|
+
throw (0, errors_1.probeFailure)({
|
|
116
|
+
name: 'adb',
|
|
117
|
+
ok: false,
|
|
118
|
+
detail: `device ${serial} is not ready (${got || state.stderr.trim().split('\n')[0] || `adb get-state exited ${state.code}`})`,
|
|
119
|
+
hint: 'reconnect it (check `verikun devices`); an unauthorized device needs the USB-debugging prompt accepted',
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
82
123
|
listDevices() {
|
|
83
124
|
const { stdout } = (0, exec_1.runText)(ADB, ['devices', '-l']);
|
|
84
125
|
const devices = [];
|
package/dist/drivers/index.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.IdbDriver = exports.AdbDriver = void 0;
|
|
3
|
+
exports.probeIdbCompanion = exports.probeIdb = exports.probeXcrun = exports.IdbDriver = exports.probeAdb = exports.AdbDriver = void 0;
|
|
4
4
|
exports.getDriver = getDriver;
|
|
5
5
|
const adb_1 = require("./adb");
|
|
6
6
|
const ios_1 = require("./ios");
|
|
7
7
|
var adb_2 = require("./adb");
|
|
8
8
|
Object.defineProperty(exports, "AdbDriver", { enumerable: true, get: function () { return adb_2.AdbDriver; } });
|
|
9
|
+
Object.defineProperty(exports, "probeAdb", { enumerable: true, get: function () { return adb_2.probeAdb; } });
|
|
9
10
|
var ios_2 = require("./ios");
|
|
10
11
|
Object.defineProperty(exports, "IdbDriver", { enumerable: true, get: function () { return ios_2.IdbDriver; } });
|
|
12
|
+
Object.defineProperty(exports, "probeXcrun", { enumerable: true, get: function () { return ios_2.probeXcrun; } });
|
|
13
|
+
Object.defineProperty(exports, "probeIdb", { enumerable: true, get: function () { return ios_2.probeIdb; } });
|
|
14
|
+
Object.defineProperty(exports, "probeIdbCompanion", { enumerable: true, get: function () { return ios_2.probeIdbCompanion; } });
|
|
11
15
|
function getDriver(platform, device) {
|
|
12
16
|
return platform === 'ios' ? new ios_1.IdbDriver(device) : new adb_1.AdbDriver(device);
|
|
13
17
|
}
|
package/dist/drivers/ios.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.IdbDriver = void 0;
|
|
4
|
+
exports.probeXcrun = probeXcrun;
|
|
5
|
+
exports.probeIdb = probeIdb;
|
|
6
|
+
exports.probeIdbCompanion = probeIdbCompanion;
|
|
4
7
|
const node_os_1 = require("node:os");
|
|
5
8
|
const node_path_1 = require("node:path");
|
|
6
9
|
const node_fs_1 = require("node:fs");
|
|
@@ -21,6 +24,50 @@ const ios_parse_1 = require("../ui/ios-parse");
|
|
|
21
24
|
// never feed back into a tap.)
|
|
22
25
|
const XCRUN = 'xcrun';
|
|
23
26
|
const IDB = process.env.IDB || 'idb';
|
|
27
|
+
// Tool probes, shared by `vk doctor --ios` (which renders every one and keeps going)
|
|
28
|
+
// and IdbDriver.preflight() (which throws on the first failure) so the two can't drift
|
|
29
|
+
// on what "the iOS toolchain works" means — or on the install hints.
|
|
30
|
+
const IDB_HINT = 'needed for ui/tap/text/swipe/key/logs — install: `brew install idb-companion` then `pip install fb-idb`';
|
|
31
|
+
const XCRUN_HINT = 'if the Xcode command-line tools are not installed: `xcode-select --install`';
|
|
32
|
+
function probeXcrun() {
|
|
33
|
+
try {
|
|
34
|
+
const r = (0, exec_1.runText)(XCRUN, ['simctl', 'list', 'devices', 'booted']);
|
|
35
|
+
// runText only throws when the binary can't be SPAWNED, so a tool that exists but
|
|
36
|
+
// fails (broken install, missing Xcode selection) needs the exit code checked too.
|
|
37
|
+
if (r.code !== 0) {
|
|
38
|
+
return { name: 'xcrun', ok: false, detail: `xcrun simctl exited ${r.code}: ${r.stderr.trim()}`, hint: XCRUN_HINT };
|
|
39
|
+
}
|
|
40
|
+
return { name: 'xcrun', ok: true, detail: r.stdout.trim() || '(no booted simulators)' };
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
// Not necessarily missing: runText also throws on a spawn timeout or other exec
|
|
44
|
+
// failure, so surface the real reason rather than always claiming "NOT FOUND".
|
|
45
|
+
return { name: 'xcrun', ok: false, detail: e.message, hint: XCRUN_HINT };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function probeIdb() {
|
|
49
|
+
try {
|
|
50
|
+
const r = (0, exec_1.runText)(IDB, ['--help']); // idb has no --version; --help confirms the binary runs
|
|
51
|
+
if (r.code !== 0)
|
|
52
|
+
return { name: 'idb', ok: false, detail: `idb --help exited ${r.code}: ${r.stderr.trim()}`, hint: IDB_HINT };
|
|
53
|
+
return { name: 'idb', ok: true, detail: 'present' };
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
return { name: 'idb', ok: false, detail: e.message, hint: IDB_HINT };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function probeIdbCompanion() {
|
|
60
|
+
try {
|
|
61
|
+
// Spawn-only on purpose: idb_companion prints its usage to stderr and exits 1 for
|
|
62
|
+
// --help, so its exit code says nothing about health. Presence is all we can cheaply
|
|
63
|
+
// assert here — idb itself is the probe that has to actually work.
|
|
64
|
+
(0, exec_1.runText)('idb_companion', ['--help']);
|
|
65
|
+
return { name: 'idb_companion', ok: true, detail: 'present' };
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
return { name: 'idb_companion', ok: false, detail: e.message, hint: 'install: `brew install idb-companion`' };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
24
71
|
const DEFAULT_LOG_LINES = 200;
|
|
25
72
|
const DEFAULT_LOG_WINDOW = '5m';
|
|
26
73
|
// Named keys -> USB-HID keyboard usage IDs, handed to `idb ui key <code>`. Numeric
|
|
@@ -104,6 +151,34 @@ class IdbDriver {
|
|
|
104
151
|
// 'booted' is a simctl-only alias idb can't address, so treat it as "auto-resolve".
|
|
105
152
|
this.requested = device && device !== 'booted' ? device : undefined;
|
|
106
153
|
}
|
|
154
|
+
preflight() {
|
|
155
|
+
// resolvedSerial() shells to simctl, so it already covers a missing xcrun, no
|
|
156
|
+
// booted simulator / connected device, and an ambiguous target (exit 2).
|
|
157
|
+
this.resolvedSerial();
|
|
158
|
+
// idb is required to drive iOS AT ALL — simulator or not. simctl covers launch,
|
|
159
|
+
// stop and screenshots, so without this a missing idb goes unnoticed until the
|
|
160
|
+
// first step that reads the hierarchy, long after a compile has been paid for.
|
|
161
|
+
//
|
|
162
|
+
// `describe` rather than doctor's `idb --help`: one round-trip that proves idb runs
|
|
163
|
+
// AND that the target is still reachable through its companion. That second half is
|
|
164
|
+
// what lets `vk suite`'s mid-run re-probe notice a simulator that died — `--help`
|
|
165
|
+
// would keep answering happily with the device long gone. (doctor keeps `--help`
|
|
166
|
+
// because it must report on idb with no device booted at all.)
|
|
167
|
+
let r;
|
|
168
|
+
try {
|
|
169
|
+
// Default 30s timeout, same as screenSize()'s identical call: preflight is the
|
|
170
|
+
// FIRST idb call of the process, so it is the one that pays idb_companion's
|
|
171
|
+
// cold start — the last place to shave the budget.
|
|
172
|
+
r = (0, exec_1.runText)(IDB, ['describe', '--udid', this.udid()]);
|
|
173
|
+
}
|
|
174
|
+
catch (e) {
|
|
175
|
+
throw (0, errors_1.probeFailure)({ name: 'idb', ok: false, detail: e.message, hint: IDB_HINT });
|
|
176
|
+
}
|
|
177
|
+
if (r.code !== 0) {
|
|
178
|
+
const why = r.stderr.trim().split('\n')[0] || `exit code ${r.code}`;
|
|
179
|
+
throw (0, errors_1.probeFailure)({ name: 'idb', ok: false, detail: `idb cannot reach ${this.udid()}: ${why}`, hint: IDB_HINT });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
107
182
|
/** All available simulators (booted or shutdown) via simctl. Tolerates odd output. */
|
|
108
183
|
simulators() {
|
|
109
184
|
const devices = [];
|
package/dist/errors.js
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// 2 usage error or ambiguous selector (caller must refine)
|
|
7
7
|
// 3 environment error (adb/simctl missing, no/multiple devices, dump failed)
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
-
exports.AmbiguousSelectorError = exports.SelectorNotFoundError = exports.envError = exports.notFound = exports.usageError = exports.CliError = void 0;
|
|
9
|
+
exports.AmbiguousSelectorError = exports.SelectorNotFoundError = exports.probeFailure = exports.envError = exports.notFound = exports.usageError = exports.CliError = void 0;
|
|
10
|
+
exports.isEnvError = isEnvError;
|
|
10
11
|
class CliError extends Error {
|
|
11
12
|
exitCode;
|
|
12
13
|
constructor(message, exitCode) {
|
|
@@ -22,6 +23,20 @@ const notFound = (m) => new CliError(m, 1);
|
|
|
22
23
|
exports.notFound = notFound;
|
|
23
24
|
const envError = (m) => new CliError(m, 3);
|
|
24
25
|
exports.envError = envError;
|
|
26
|
+
/**
|
|
27
|
+
* An environment failure (exit 3): a tool missing from PATH, no/ambiguous device, a
|
|
28
|
+
* hierarchy dump or capture that failed. The one predicate every layer shares to tell
|
|
29
|
+
* "the box is broken" from "the app is broken" — the agent runner aborts on it instead
|
|
30
|
+
* of recording a regression, and `vk suite` stops rather than reporting N phantom
|
|
31
|
+
* failures. Accepts `unknown` so `catch (e)` blocks can pass their binding directly.
|
|
32
|
+
*/
|
|
33
|
+
function isEnvError(e) {
|
|
34
|
+
return e instanceof CliError && e.exitCode === 3;
|
|
35
|
+
}
|
|
36
|
+
/** Turn a failed tool probe into the environment error both drivers' preflight throws,
|
|
37
|
+
* so the install hint reads the same whether it came from `vk doctor` or a preflight. */
|
|
38
|
+
const probeFailure = (p) => (0, exports.envError)(`${p.detail}${p.hint ? `\n ${p.hint}` : ''}`);
|
|
39
|
+
exports.probeFailure = probeFailure;
|
|
25
40
|
// --- Selector-resolution errors (heal triggers for the agent runner) --------
|
|
26
41
|
//
|
|
27
42
|
// A selector miss (zero matches) and an ambiguous match (>1) are still ordinary
|
package/dist/report.js
CHANGED
|
@@ -206,6 +206,9 @@ const SUITE_STYLE = `
|
|
|
206
206
|
table.tests td.num { text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; }
|
|
207
207
|
table.tests a { color:inherit; }
|
|
208
208
|
.fail-reason { color:var(--fail); font-size:12px; margin-top:2px; }
|
|
209
|
+
.aborted { background:#fff4e5; border:1px solid #f0b429; border-radius:8px; padding:12px 14px; margin:0 0 14px; font-size:13px; }
|
|
210
|
+
.aborted strong { color:#8a5300; }
|
|
211
|
+
.aborted ul { margin:6px 0 0; padding-left:20px; color:var(--muted); }
|
|
209
212
|
`;
|
|
210
213
|
function suiteTestRow(t, linkBase) {
|
|
211
214
|
// A test that errored before its run started (id '') has no report to link.
|
|
@@ -233,10 +236,22 @@ function toSuiteHtml(suite, opts = {}) {
|
|
|
233
236
|
const chips = [
|
|
234
237
|
`<span class="chip pass">${t.passed} passed</span>`,
|
|
235
238
|
t.failed ? `<span class="chip fail">${t.failed} failed</span>` : '',
|
|
239
|
+
suite.aborted ? `<span class="chip fail">ABORTED</span>` : '',
|
|
236
240
|
`<span class="chip muted">${t.tests} tests · ${t.steps} steps · ${fmtDuration(t.durationMs)} · $${t.costUsd.toFixed(4)}</span>`,
|
|
237
241
|
]
|
|
238
242
|
.filter(Boolean)
|
|
239
243
|
.join('\n ');
|
|
244
|
+
// The banner, not a table row per skipped file: a not-run test is not a result, and
|
|
245
|
+
// faking a FAIL row for one would be the very "phantom regression" this prevents.
|
|
246
|
+
const abortedBanner = suite.aborted
|
|
247
|
+
? ` <div class="aborted">
|
|
248
|
+
<strong>Suite aborted — the device environment broke mid-run.</strong>
|
|
249
|
+
<div>${htmlEsc(suite.aborted.reason)}</div>
|
|
250
|
+
${suite.aborted.notRun.length
|
|
251
|
+
? ` <ul>${suite.aborted.notRun.map((f) => `<li>${htmlEsc(f)} — not run</li>`).join('')}</ul>\n`
|
|
252
|
+
: ''} </div>
|
|
253
|
+
`
|
|
254
|
+
: '';
|
|
240
255
|
const metaBits = [
|
|
241
256
|
`<code>${htmlEsc(suite.id)}</code>`,
|
|
242
257
|
htmlEsc(suite.platform) + (suite.device ? ` · ${htmlEsc(suite.device)}` : ''),
|
|
@@ -259,7 +274,7 @@ function toSuiteHtml(suite, opts = {}) {
|
|
|
259
274
|
<div class="summary">
|
|
260
275
|
${chips}
|
|
261
276
|
</div>
|
|
262
|
-
<table class="tests">
|
|
277
|
+
${abortedBanner} <table class="tests">
|
|
263
278
|
<thead><tr><th></th><th>Test</th><th>Steps</th><th>Repairs</th><th>Cost</th><th>Duration</th></tr></thead>
|
|
264
279
|
<tbody>
|
|
265
280
|
${suite.tests.map((x) => suiteTestRow(x, linkBase)).join('\n')}
|
package/dist/run.js
CHANGED
|
@@ -340,12 +340,15 @@ class Recorder {
|
|
|
340
340
|
this.step.status = exitCode === 1 ? 'failed' : 'error';
|
|
341
341
|
if (!this.step.message)
|
|
342
342
|
this.step.message = e.message;
|
|
343
|
-
|
|
343
|
+
// The step failed BECAUSE the environment is broken, so evidence capture is
|
|
344
|
+
// near-certain to fail the same way. Still attempt it (a screencap can succeed
|
|
345
|
+
// where a dump doesn't), but don't narrate two more copies of the same error.
|
|
346
|
+
this.capture(driver, (0, errors_1.isEnvError)(e));
|
|
344
347
|
this.commit();
|
|
345
348
|
}
|
|
346
349
|
// Best-effort: grab a screenshot and the UI hierarchy of the failing page.
|
|
347
350
|
// The device may be unreachable (that may be why we failed) — swallow errors.
|
|
348
|
-
capture(driver) {
|
|
351
|
+
capture(driver, quiet = false) {
|
|
349
352
|
if (!driver)
|
|
350
353
|
return;
|
|
351
354
|
try {
|
|
@@ -355,14 +358,16 @@ class Recorder {
|
|
|
355
358
|
catch (e) {
|
|
356
359
|
// Best-effort evidence: the device may be gone (often why the step failed). Surface
|
|
357
360
|
// it so a screenshot bug isn't hidden, but never let it derail failure recording.
|
|
358
|
-
|
|
361
|
+
if (!quiet)
|
|
362
|
+
(0, output_1.err)(`[verikun] could not capture failure screenshot (${e.message})`);
|
|
359
363
|
}
|
|
360
364
|
try {
|
|
361
365
|
const text = (0, format_1.formatCompact)(driver.getElements({ all: false }));
|
|
362
366
|
this.step.failHierarchy = text.length > HIERARCHY_CAP ? text.slice(0, HIERARCHY_CAP) + '\n…(truncated)' : text;
|
|
363
367
|
}
|
|
364
368
|
catch (e) {
|
|
365
|
-
|
|
369
|
+
if (!quiet)
|
|
370
|
+
(0, output_1.err)(`[verikun] could not capture failure hierarchy (${e.message})`);
|
|
366
371
|
}
|
|
367
372
|
}
|
|
368
373
|
writeArtifact(rel, buf) {
|
package/dist/server.js
CHANGED
|
@@ -310,9 +310,12 @@ async function cmdServer(positionals, flags) {
|
|
|
310
310
|
authKey = (0, node_crypto_1.randomBytes)(32).toString('base64url');
|
|
311
311
|
generated = true;
|
|
312
312
|
}
|
|
313
|
-
// Build the ONE driver the server will ever use, and fail fast (exit 2/3) if
|
|
314
|
-
//
|
|
313
|
+
// Build the ONE driver the server will ever use, and fail fast (exit 2/3) if the
|
|
314
|
+
// toolchain can't drive it — before binding a port. preflight() covers resolving the
|
|
315
|
+
// device AND the tools; without it the server happily listens on a box with no idb
|
|
316
|
+
// and then 500s every /v1/exec.
|
|
315
317
|
const driver = (0, drivers_1.getDriver)(platform, device);
|
|
318
|
+
driver.preflight();
|
|
316
319
|
const serial = driver.resolvedSerial();
|
|
317
320
|
// Handlers print "tapped …" confirmations via out(); a server's stdout is not a
|
|
318
321
|
// data channel, so silence them — request logging goes to stderr instead.
|
package/dist/suite.js
CHANGED
|
@@ -22,6 +22,38 @@ const output_1 = require("./output");
|
|
|
22
22
|
const run_1 = require("./run");
|
|
23
23
|
const report_1 = require("./report");
|
|
24
24
|
const version_1 = require("./version");
|
|
25
|
+
/** Gap between the two health probes below. Long enough to outlast a USB
|
|
26
|
+
* re-enumeration or a simulator relaunch, short enough not to pad a real abort. */
|
|
27
|
+
const PROBE_RETRY_MS = 1000;
|
|
28
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
29
|
+
/**
|
|
30
|
+
* An environment-flavoured failure is only FATAL if the toolchain is STILL broken when
|
|
31
|
+
* we re-probe. This distinction is load-bearing: a transient uiautomator dump failure
|
|
32
|
+
* also surfaces as exit 3 (matchWaiting/resolveOneWaiting don't catch a thrown
|
|
33
|
+
* getElements), so aborting on the exit code alone would let one flaky dump vaporize a
|
|
34
|
+
* 20-test suite. Returns the reason when broken, undefined when it was transient.
|
|
35
|
+
*/
|
|
36
|
+
async function stillBroken(deps) {
|
|
37
|
+
if (!deps.preflight)
|
|
38
|
+
return undefined; // not wired -> preserve continue-on-failure
|
|
39
|
+
// Two attempts a second apart, because the probe is the ONLY thing separating a
|
|
40
|
+
// momentary blip from a dead box, and killing a 20-test suite is the expensive
|
|
41
|
+
// mistake. A USB re-enumeration or a simulator mid-relaunch can fail one probe and
|
|
42
|
+
// pass the next; a genuinely missing tool fails both in a few milliseconds.
|
|
43
|
+
let last = '';
|
|
44
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
45
|
+
if (attempt > 0)
|
|
46
|
+
await sleep(deps.probeRetryMs ?? PROBE_RETRY_MS);
|
|
47
|
+
try {
|
|
48
|
+
await deps.preflight();
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
last = e.message.split('\n')[0];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return last;
|
|
56
|
+
}
|
|
25
57
|
/** Lexicographic order, so authors sequence flows with 01-…, 02-… prefixes. */
|
|
26
58
|
function sortTestFiles(files) {
|
|
27
59
|
return [...files].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
@@ -45,13 +77,15 @@ function listTestFiles(dir) {
|
|
|
45
77
|
function toSuiteResult(file, r, durationMs) {
|
|
46
78
|
const steps = r.state?.steps ?? [];
|
|
47
79
|
const passedSteps = steps.filter((s) => s.status === 'passed').length;
|
|
48
|
-
const failure = r.
|
|
49
|
-
? `
|
|
50
|
-
: r.
|
|
51
|
-
?
|
|
52
|
-
: r.
|
|
53
|
-
? 'aborted:
|
|
54
|
-
:
|
|
80
|
+
const failure = r.abortedForEnv
|
|
81
|
+
? `aborted: environment — ${r.failure?.reason ?? 'device unavailable'}`
|
|
82
|
+
: r.failure
|
|
83
|
+
? `FAIL at ${r.failure.where}: ${r.failure.reason}`
|
|
84
|
+
: r.abortedForBudget
|
|
85
|
+
? 'aborted: cost ceiling reached'
|
|
86
|
+
: r.abortedForTimeout
|
|
87
|
+
? 'aborted: run timeout reached'
|
|
88
|
+
: undefined;
|
|
55
89
|
return {
|
|
56
90
|
id: r.runDir ? (0, node_path_1.basename)(r.runDir) : '',
|
|
57
91
|
file,
|
|
@@ -80,7 +114,8 @@ async function cmdSuite(dirArg, flags, deps) {
|
|
|
80
114
|
const startedAt = new Date().toISOString();
|
|
81
115
|
(0, output_1.err)(`[suite] '${name}': ${files.length} test(s) from ${dirArg} (${deps.platform}${deps.device ? ` · ${deps.device}` : ''})`);
|
|
82
116
|
const results = [];
|
|
83
|
-
|
|
117
|
+
let aborted;
|
|
118
|
+
for (let i = 0; i < files.length && !aborted; i++) {
|
|
84
119
|
const file = files[i];
|
|
85
120
|
(0, output_1.err)(`[suite] ── (${i + 1}/${files.length}) ${file} ──`);
|
|
86
121
|
if (deps.reset) {
|
|
@@ -89,8 +124,16 @@ async function cmdSuite(dirArg, flags, deps) {
|
|
|
89
124
|
(0, output_1.err)('[suite] app state reset');
|
|
90
125
|
}
|
|
91
126
|
catch (e) {
|
|
92
|
-
//
|
|
93
|
-
//
|
|
127
|
+
// A reset that failed because the BOX is broken means nothing after it is
|
|
128
|
+
// trustworthy — but only if a re-probe agrees. Otherwise surface and continue:
|
|
129
|
+
// a flaky reset should not zero out the whole suite, and the test itself will
|
|
130
|
+
// fail loudly if the stale state actually matters.
|
|
131
|
+
const broken = (0, errors_1.isEnvError)(e) ? await stillBroken(deps) : undefined;
|
|
132
|
+
if (broken) {
|
|
133
|
+
// This test never ran, so it gets no row — notRun starts at the CURRENT file.
|
|
134
|
+
aborted = { reason: `reset failed: ${broken}`, notRun: files.slice(i) };
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
94
137
|
(0, output_1.err)(`[suite] reset failed (${e.message}) — continuing`);
|
|
95
138
|
}
|
|
96
139
|
}
|
|
@@ -98,11 +141,19 @@ async function cmdSuite(dirArg, flags, deps) {
|
|
|
98
141
|
try {
|
|
99
142
|
const r = await deps.runTest((0, node_path_1.join)(dir, file));
|
|
100
143
|
results.push(toSuiteResult(file, r, Date.now() - t0));
|
|
144
|
+
// The test itself reported an environment abort (exit 3 mid-plan). Same rule:
|
|
145
|
+
// fatal only if the box is still broken. This test HAS a row and a real report,
|
|
146
|
+
// so notRun starts after it.
|
|
147
|
+
if (r.abortedForEnv) {
|
|
148
|
+
const broken = await stillBroken(deps);
|
|
149
|
+
if (broken)
|
|
150
|
+
aborted = { reason: broken, notRun: files.slice(i + 1) };
|
|
151
|
+
}
|
|
101
152
|
}
|
|
102
153
|
catch (e) {
|
|
103
|
-
// A test that THREW (
|
|
104
|
-
//
|
|
105
|
-
//
|
|
154
|
+
// A test that THREW (device gone, server unreachable, bad file) still becomes a
|
|
155
|
+
// failed row — one broken test must not vaporize the suite report for the tests
|
|
156
|
+
// that already ran. But if it threw because the environment is gone, stop.
|
|
106
157
|
const msg = e instanceof Error ? e.message : String(e);
|
|
107
158
|
(0, output_1.err)(`[suite] ${file} errored: ${msg}`);
|
|
108
159
|
results.push({
|
|
@@ -118,8 +169,14 @@ async function cmdSuite(dirArg, flags, deps) {
|
|
|
118
169
|
modelRepairs: 0,
|
|
119
170
|
failure: msg.split('\n')[0],
|
|
120
171
|
});
|
|
172
|
+
const broken = (0, errors_1.isEnvError)(e) ? await stillBroken(deps) : undefined;
|
|
173
|
+
if (broken)
|
|
174
|
+
aborted = { reason: broken, notRun: files.slice(i + 1) };
|
|
121
175
|
}
|
|
122
176
|
}
|
|
177
|
+
if (aborted) {
|
|
178
|
+
(0, output_1.err)(`[suite] ABORTED — environment: ${aborted.reason} (${aborted.notRun.length} test(s) not run)`);
|
|
179
|
+
}
|
|
123
180
|
const suite = {
|
|
124
181
|
schemaVersion: 1,
|
|
125
182
|
id: suiteId,
|
|
@@ -131,6 +188,7 @@ async function cmdSuite(dirArg, flags, deps) {
|
|
|
131
188
|
verikun: version_1.VERSION,
|
|
132
189
|
totals: (0, report_1.suiteTotals)(results),
|
|
133
190
|
tests: results,
|
|
191
|
+
...(aborted ? { aborted } : {}),
|
|
134
192
|
};
|
|
135
193
|
// .verikun/suites/<id>/ sits beside .verikun/runs/<id>/, so index.html reaches a
|
|
136
194
|
// test report at ../../runs/<id>/report.html — the linkBase below.
|
|
@@ -147,6 +205,8 @@ async function cmdSuite(dirArg, flags, deps) {
|
|
|
147
205
|
(0, output_1.json)(suite);
|
|
148
206
|
else
|
|
149
207
|
(0, output_1.out)(outDir); // primary machine result: the suite directory
|
|
150
|
-
// The CI gate: any failed test fails the invocation (mirrors `vk run archive`).
|
|
151
|
-
|
|
208
|
+
// The CI gate: any failed test fails the invocation (mirrors `vk run archive`). An
|
|
209
|
+
// environment abort exits 3 instead, so CI can tell "the runner is broken" from "the
|
|
210
|
+
// app regressed" — the whole point of stopping early.
|
|
211
|
+
return aborted ? 3 : t.failed > 0 ? 1 : 0;
|
|
152
212
|
}
|
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.12.0';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "verikun",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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",
|