verikun 0.26.0 → 0.26.1

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.
@@ -390,7 +390,9 @@ vk ai onboarding.md --timeout 5m # tighten the run timeout (default 15m)
390
390
  the flow. An `if-present` guard **waits for its selector to settle** (at least two looks
391
391
  at the screen) before deciding the optional UI is absent, so a dialog that animates in a
392
392
  beat after the transition is still caught. An absent guard costs about one extra UI dump;
393
- `VERIKUN_GUARD_SETTLE_MS=0` restores the old single-shot probe.
393
+ `VERIKUN_GUARD_SETTLE_MS=0` restores the old single-shot probe. A guard that cannot read the
394
+ screen **at all** — the app force-stopped, mid-launch, or busy mid-transition — keeps looking
395
+ for up to 10s rather than aborting; still blind after that is exit `3`, never "absent".
394
396
  - **A compile has to cover the test.** Compilation is nondeterministic, and its worst outcome
395
397
  is a plan that stops part-way: it asserts nothing after that point, so it *passes*, caches
396
398
  green, and replays against later builds — a test exercising none of its subject reporting
package/CHANGELOG.md CHANGED
@@ -6,6 +6,24 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.26.1] - 2026-09-07
10
+
11
+ Fixes a `vk ai` run dying when the app is redrawing at the moment a guard checks the screen.
12
+
13
+ ### Added
14
+ - **`errorKind` on every `vk server` error response**: a device error keeps its class over the
15
+ wire, not only on `/v1/exec`. Absent from older servers. ([#80])
16
+
17
+ ### Fixed
18
+ - **`vk ai` guards** now ride out a transient "no window" for up to 10s instead of aborting the
19
+ run. A bare `vk ui` still exits `3`. ([#80])
20
+
21
+ ### Changed
22
+ - **"No window to read"** now names a busy main thread mid-transition as a cause, and no longer
23
+ suggests a command that waits. ([#80])
24
+
25
+ [#80]: https://github.com/ddikman/verikun/issues/80
26
+
9
27
  ## [0.26.0] - 2026-09-05
10
28
 
11
29
  Runs a suite across a pool of devices, and shares prose between tests with `@include`.
@@ -88,6 +88,32 @@ exports.DEFAULT_RUN_TIMEOUT_MS = 15 * 60 * 1000;
88
88
  exports.DEFAULT_GUARD_SETTLE_MS = 1500;
89
89
  /** Re-dump cadence inside a guard's settle window. */
90
90
  const GUARD_POLL_MS = 150;
91
+ /**
92
+ * How long a guard keeps looking at a screen it cannot read at all, when the reason is
93
+ * NoWindowError — the app was force-stopped or is mid-launch and has genuinely not drawn.
94
+ *
95
+ * Separate from `settleMs`, which answers "how long before I believe this selector is
96
+ * absent?". This answers "how long before I believe there is no screen to ask?" — a
97
+ * different question, and the only one whose wrong answer aborts the whole run.
98
+ *
99
+ * MEASURED (issue #80), post-`vk launch`, time until the first hierarchy read succeeds:
100
+ * emulator-5554 (stock reads ~2s): 2027-2379ms over 10 launches
101
+ * SM-A415F, physical (companion ~0.3s): 4746-6219ms over 15 launches
102
+ * So the "clears within a second or two" in NoWindowError's own doc is optimistic by 2.5x on
103
+ * real hardware; the emulator only looks compliant because one slow read already outlasts the
104
+ * gap. 10s clears the worst observed by ~1.6x. Below that the constant sits INSIDE the
105
+ * measured distribution, which is the one place it must not be.
106
+ *
107
+ * Deliberately MORE patient than a leaf command's 5s auto-wait, which is the opposite of
108
+ * DEFAULT_GUARD_SETTLE_MS's reasoning, because the asymmetry is real: a leaf that needs
109
+ * longer takes `--wait`, whereas a guard's patience is internal and a test author cannot
110
+ * reach it. Being generous costs at most this long ONCE per present() call, on a run that is
111
+ * already failing — against a 15-minute default run timeout, and clamped by it.
112
+ *
113
+ * Not configurable on purpose: a dial here is one more thing to explain, and every value a
114
+ * user might pick is worse than the measurement.
115
+ */
116
+ const NO_WINDOW_GRACE_MS = 10_000;
91
117
  /** Consecutive identical screen snapshots before a loop is believed to be stuck.
92
118
  *
93
119
  * This check is a TIME SAVER and nothing more. A loop already fails when its exit
@@ -179,6 +205,11 @@ async function runPlan(plan, deps) {
179
205
  * probe a loop-exit check needs.
180
206
  * So one dump attempt always happens regardless of the window.
181
207
  *
208
+ * A THIRD clock sits beside both: a screen that cannot be read because the app has not
209
+ * drawn (NoWindowError) is retried against NO_WINDOW_GRACE_MS, not against `settleMs`.
210
+ * That is deliberately independent — "is this selector absent?" and "is there a screen to
211
+ * ask at all?" are different questions, and only the second one aborts the run.
212
+ *
182
213
  * Throws GuardBlindError when the window closes having NEVER once read the screen
183
214
  * and the failure was an environment error — see that class for why. */
184
215
  const present = async (selector, settleMs) => {
@@ -198,6 +229,10 @@ async function runPlan(plan, deps) {
198
229
  if (deps.platform)
199
230
  (0, state_support_1.assertStateSupported)(sel, deps.platform);
200
231
  const deadline = Date.now() + Math.max(0, settleMs);
232
+ // A screen that cannot be read AT ALL gets its own, longer clock — see NO_WINDOW_GRACE_MS.
233
+ // Clamped by the run deadline so a guard can never push a run past --timeout: the grace
234
+ // exists to spend budget the caller already has, never to invent more.
235
+ const noWindowDeadline = Math.min(Date.now() + NO_WINDOW_GRACE_MS, deps.deadline ?? Infinity);
201
236
  // A non-zero window must buy at least one SECOND look, independent of the clock.
202
237
  // Measured on emulator-5554: one uiautomator dump costs ~2.4s, which already exceeds
203
238
  // a 1.5s window — so a purely time-boxed loop returns after a single dump and the
@@ -235,9 +270,26 @@ async function runPlan(plan, deps) {
235
270
  return true;
236
271
  const remaining = deadline - Date.now();
237
272
  if (looks >= minLooks && remaining <= 0) {
273
+ // Nothing has been readable yet, and the reason is that the app has not drawn. That is
274
+ // an observation about the SCREEN, not a broken machine, and it clears on its own — so
275
+ // keep looking on the no-window clock instead of killing the run. MEASURED (#80): the
276
+ // two attempts above span ~75ms against a gap of 4.7-6.2s on a physical device, so
277
+ // without this a `repeat` with minutes of budget gives up in under a tenth of a second.
278
+ //
279
+ // Gated on `everRead` so this can only ever extend patience for an UNREADABLE screen.
280
+ // One successful read — even an empty tree — and the ordinary semantics resume exactly:
281
+ // settleMs=0 is still a single-shot probe. It must never make a merely ABSENT selector
282
+ // more patient, or every guard silently costs 10s.
283
+ if (!everRead && lastErr instanceof errors_1.NoWindowError && Date.now() < noWindowDeadline) {
284
+ await (0, wait_1.sleep)(GUARD_POLL_MS);
285
+ continue;
286
+ }
238
287
  // The window closed having NEVER once read the screen, because the environment is
239
288
  // broken. Answering "absent" here is a lie that silently skips the body — and a
240
289
  // guard-heavy plan would then finish fully GREEN having executed nothing.
290
+ //
291
+ // A no-window that outlives its grace lands here too, and still aborts: at that point
292
+ // the app really is gone, and reporting "absent" would be the same false green.
241
293
  if (!everRead && (0, errors_1.isEnvError)(lastErr))
242
294
  throw new GuardBlindError(selector, lastErr);
243
295
  return false;
@@ -9,6 +9,7 @@
9
9
  // into the CALLER's local run — so a remote run archives a report identical to a
10
10
  // local one. Recording stays a caller concern: this module never touches ./.verikun.
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.describeStatus = describeStatus;
12
13
  exports.pingServer = pingServer;
13
14
  exports.remoteDeviceOp = remoteDeviceOp;
14
15
  exports.remoteDeviceList = remoteDeviceList;
@@ -30,6 +31,19 @@ const DEVICE_LIST_TIMEOUT_MS = 30_000;
30
31
  const DEVICE_START_TIMEOUT_MS = 5 * 60_000;
31
32
  const DEVICE_STOP_TIMEOUT_MS = 60_000;
32
33
  const trimUrl = (url) => url.replace(/\/+$/, '');
34
+ /**
35
+ * Turn a non-2xx into the error the caller sees.
36
+ *
37
+ * The 401/409/503 arms come FIRST and stay class-free on purpose: those describe the
38
+ * TRANSPORT (wrong key, device leased, nothing attached), not something a driver threw, so
39
+ * there is no device-error identity to restore and their wording is what a user acts on.
40
+ *
41
+ * Everything else prefers the server's `errorKind`. That field is what stops a `--server` run
42
+ * reading a mid-launch `NoWindowError` as a fatal environment error: the class survives the
43
+ * worker→main hop server-side, and this is where it used to be replaced by an anonymous
44
+ * `CliError` (issue #80). No field — an older server, or a failure with no class worth
45
+ * naming — falls through to exactly the previous behaviour.
46
+ */
33
47
  function describeStatus(status, body, url) {
34
48
  const detail = body?.error ? `: ${body.error}` : '';
35
49
  if (status === 401) {
@@ -44,6 +58,12 @@ function describeStatus(status, body, url) {
44
58
  // The server sends the intended exit code (usage 2 / env 3) in the body; fall
45
59
  // back on the HTTP class when it didn't.
46
60
  const exitCode = body?.exitCode ?? (status === 400 || status === 404 || status === 413 ? 2 : 3);
61
+ if (body?.errorKind) {
62
+ // The server's own message, NOT the `verikun server error 500 at <url>` wrapper: this is
63
+ // a device error that happens to have travelled, and it reads (and matches) the same as
64
+ // the local one. The same shape /v1/exec's 200-with-descriptor path already produces.
65
+ return (0, rpc_1.rebuildError)({ kind: body.errorKind, name: body.errorKind, message: body.error, exitCode });
66
+ }
47
67
  return new errors_1.CliError(`verikun server error ${status} at ${url}${detail}`, exitCode);
48
68
  }
49
69
  async function readBody(res) {
@@ -290,7 +290,7 @@ class Companion {
290
290
  case 'propagate':
291
291
  break;
292
292
  }
293
- throw new errors_1.NoWindowError('No window to read: the app has not drawn yet (force-stopped, or mid-launch).');
293
+ throw new errors_1.NoWindowError();
294
294
  }
295
295
  /** Hand the UiAutomation connection back and take it again. The one thing measured to clear
296
296
  * a stale connection — it is what the stock path's releaseCompanionOn() does by accident. */
@@ -835,8 +835,7 @@ class AdbDriver {
835
835
  // nothing on it yet. Retrying it here just spends someone else's wait budget three
836
836
  // times as fast; hand it up to whoever knows how long they are willing to wait.
837
837
  if (NULL_ROOT.test(lastErr)) {
838
- throw new errors_1.NoWindowError('No window to read: the app has not drawn yet (force-stopped, or mid-launch). ' +
839
- 'Retry, or use a command that waits (`vk wait`, or any selector lookup).');
838
+ throw new errors_1.NoWindowError();
840
839
  }
841
840
  if (attempt === 0) {
842
841
  // A sleeping display is the other documented cause of a failed read. `ensureAwake` ran
package/dist/errors.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // 2 usage error, ambiguous selector, or a device another job is driving (caller must refine)
7
7
  // 3 environment error (adb/simctl missing, no usable device, dump failed)
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.AmbiguousSelectorError = exports.NoWindowError = exports.SelectorNotFoundError = exports.probeFailure = exports.envError = exports.usageError = exports.CliError = void 0;
9
+ exports.AmbiguousSelectorError = exports.NO_WINDOW_MESSAGE = exports.NoWindowError = exports.SelectorNotFoundError = exports.probeFailure = exports.envError = exports.usageError = exports.CliError = void 0;
10
10
  exports.isEnvError = isEnvError;
11
11
  class CliError extends Error {
12
12
  exitCode;
@@ -67,12 +67,25 @@ exports.SelectorNotFoundError = SelectorNotFoundError;
67
67
  * The retry belongs to the caller that knows how long it is willing to wait.
68
68
  */
69
69
  class NoWindowError extends CliError {
70
- constructor(message) {
70
+ constructor(message = exports.NO_WINDOW_MESSAGE) {
71
71
  super(message, 3);
72
72
  this.name = 'NoWindowError';
73
73
  }
74
74
  }
75
75
  exports.NoWindowError = NoWindowError;
76
+ /**
77
+ * The one wording for "there is no window", shared by both Android read paths so they cannot
78
+ * drift — the companion and the stock dumper are reporting the same device state.
79
+ *
80
+ * It names THREE causes, not two. `getRootInActiveWindow()` also returns null while the app's
81
+ * main thread is busy mid-transition, and issue #80 was reported against a build that listed
82
+ * only force-stop and mid-launch: the reporter went looking at app startup for a screen that
83
+ * was drawn, present, and merely busy (device logs showed 32-121 dropped frames in the same
84
+ * window). It also no longer ends in "use a command that waits" — every caller that hit this
85
+ * in the wild was already doing exactly that.
86
+ */
87
+ exports.NO_WINDOW_MESSAGE = 'No window to read: the app has no drawn window right now — force-stopped, mid-launch, or ' +
88
+ 'its main thread is busy mid-transition. This normally clears within a few seconds.';
76
89
  /** Selector matched >1 element. Exit 2. Carries the candidates so the agent runner
77
90
  * can ask the model to disambiguate (a heal trigger) instead of aborting. */
78
91
  class AmbiguousSelectorError extends CliError {
@@ -14,14 +14,21 @@ class HttpError extends Error {
14
14
  status;
15
15
  exitCode;
16
16
  deviceChanged;
17
+ errorKind;
17
18
  constructor(status, message, exitCode = status === 400 || status === 404 || status === 413 ? 2 : 3,
18
19
  /** Set when this request moved the server's device before failing — the client
19
20
  * needs to know the ground shifted even though the answer is an error. */
20
- deviceChanged) {
21
+ deviceChanged,
22
+ /** The CLASS of the error this wraps, when it wraps one. Wrapping a driver error in an
23
+ * HttpError is how the identity used to be lost: only `.message` and `.exitCode` were
24
+ * copied across, so a `NoWindowError` reached the client as a bare `CliError`. Carry it
25
+ * here and the outer catch can put it on the wire. */
26
+ errorKind) {
21
27
  super(message);
22
28
  this.status = status;
23
29
  this.exitCode = exitCode;
24
30
  this.deviceChanged = deviceChanged;
31
+ this.errorKind = errorKind;
25
32
  this.name = 'HttpError';
26
33
  }
27
34
  }
package/dist/server.js CHANGED
@@ -1053,7 +1053,10 @@ function buildServer(config) {
1053
1053
  const deviceChanged = await considerFailover(e, 'read', handle);
1054
1054
  if (!deviceChanged)
1055
1055
  throw e;
1056
- throw new server_http_1.HttpError(500, e.message, e instanceof errors_1.CliError ? e.exitCode : 3, deviceChanged);
1056
+ // describeError, not just .message/.exitCode: this wrap is on the path a mid-launch
1057
+ // NoWindowError takes, and the engine's guard tells "still drawing" from "box broken"
1058
+ // by class alone (issue #80).
1059
+ throw new server_http_1.HttpError(500, e.message, e instanceof errors_1.CliError ? e.exitCode : 3, deviceChanged, (0, rpc_1.describeError)(e).kind);
1057
1060
  }
1058
1061
  }
1059
1062
  async function handleLogs(handle, req, res) {
@@ -1629,9 +1632,16 @@ function buildServer(config) {
1629
1632
  // back why a suite degraded.
1630
1633
  failure = ` — ${(0, server_http_1.firstLine)(mapped.message)}`;
1631
1634
  if (!res.headersSent) {
1635
+ // The class comes from the ORIGINAL throw, never from `mapped`: the HttpError
1636
+ // mapping above keeps only message + exit code, which is precisely how a
1637
+ // NoWindowError used to reach the client as an anonymous CliError (issue #80). An
1638
+ // HttpError raised by the server itself (auth, validation, a lock) has no wrapped
1639
+ // class and simply omits the field, which older clients already tolerate.
1640
+ const errorKind = e instanceof server_http_1.HttpError ? e.errorKind : (0, rpc_1.describeError)(e).kind;
1632
1641
  const body = {
1633
1642
  error: mapped.message,
1634
1643
  exitCode: mapped.exitCode,
1644
+ ...(errorKind ? { errorKind } : {}),
1635
1645
  ...(mapped.deviceChanged ? { deviceChanged: mapped.deviceChanged } : {}),
1636
1646
  };
1637
1647
  (0, server_http_1.sendJson)(res, mapped.status, body);
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.26.0';
6
+ exports.VERSION = '0.26.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "verikun",
3
- "version": "0.26.0",
3
+ "version": "0.26.1",
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",