zeno-mobile-runner 0.2.7 → 0.2.8

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/CHANGELOG.md CHANGED
@@ -4,6 +4,15 @@ All notable changes to Zeno Mobile Runner are tracked here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.2.8 (2026-06-17)
8
+
9
+ ### Fixed
10
+
11
+ - iOS native selector waits now cap each XCTest query to the remaining scenario
12
+ step timeout and retry transient query command timeouts. Missing selectors can
13
+ no longer outlive the scenario `timeoutMs` by waiting on the shim's longer
14
+ cold-start command timeout.
15
+
7
16
  ## 0.2.7 (2026-06-15)
8
17
 
9
18
  ### Fixed
@@ -39,7 +48,7 @@ All notable changes to Zeno Mobile Runner are tracked here.
39
48
  - Extended the iOS simulator `openLink` interruption sweep to cover Expo
40
49
  dev-client deep-link chooser sheets that appear more than six seconds after
41
50
  `simctl openurl` returns. The sweep remains bounded, but now covers the
42
- delayed chooser timing observed in the Rently auth smoke.
51
+ delayed chooser timing observed in app auth smoke runs.
43
52
 
44
53
  ## 0.2.3 (2026-06-15)
45
54
 
package/FEATURES.md CHANGED
@@ -142,7 +142,7 @@ state, and writes deterministic traces. It does not embed an LLM.
142
142
 
143
143
  ## Current Limitations
144
144
 
145
- - Current release status is `0.2.7`, a public developer preview rather than
145
+ - Current release status is `0.2.8`, a public developer preview rather than
146
146
  a production-stable `1.0.0`.
147
147
  - Physical iOS log capture is still simulator-first. Physical iOS screenshots
148
148
  are available when the XCTest/XCUIAutomation shim is configured.
package/README.md CHANGED
@@ -197,7 +197,7 @@ comparisons against your current E2E tool, and multi-device matrices, see
197
197
  | Cloud device farms | Not included | ZMR focuses on local and self-managed device targets in this preview |
198
198
 
199
199
  Slow CI hardware can extend the iOS shim cold-build timeout with
200
- `ZMR_IOS_SHIM_TIMEOUT_MS`. Current release: `0.2.7` developer preview.
200
+ `ZMR_IOS_SHIM_TIMEOUT_MS`. Current release: `0.2.8` developer preview.
201
201
  Protocol version: `2026-04-28`.
202
202
 
203
203
  ## Optional protocol clients
@@ -27,7 +27,7 @@ gradle -p clients/kotlin runFakeSession \
27
27
  ```
28
28
 
29
29
  ```kotlin
30
- implementation(files("path/to/zeno-mobile-runner/clients/kotlin/build/libs/zmr-client-0.2.7.jar"))
30
+ implementation(files("path/to/zeno-mobile-runner/clients/kotlin/build/libs/zmr-client-0.2.8.jar"))
31
31
  ```
32
32
 
33
33
  ```kotlin
@@ -4,7 +4,7 @@ plugins {
4
4
  }
5
5
 
6
6
  group = "dev.zmr"
7
- version = "0.2.7"
7
+ version = "0.2.8"
8
8
 
9
9
  kotlin {
10
10
  jvmToolchain(17)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "zmr-client"
7
- version = "0.2.7.dev1"
7
+ version = "0.2.8.dev1"
8
8
  description = "Python JSON-RPC client for Zeno Mobile Runner."
9
9
  requires-python = ">=3.9"
10
10
  license = { text = "MIT" }
@@ -100,7 +100,7 @@ checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
100
100
 
101
101
  [[package]]
102
102
  name = "zmr-client"
103
- version = "0.2.7"
103
+ version = "0.2.8"
104
104
  dependencies = [
105
105
  "serde",
106
106
  "serde_json",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "zmr-client"
3
- version = "0.2.7"
3
+ version = "0.2.8"
4
4
  edition = "2021"
5
5
  license = "MIT"
6
6
  description = "Rust JSON-RPC client for Zeno Mobile Runner."
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zmr/client",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "type": "module",
5
5
  "main": "index.mjs",
6
6
  "types": "index.d.ts",
package/docs/npm.md CHANGED
@@ -431,6 +431,14 @@ Publish the generated tarball from `dist/`:
431
431
  npm publish ./dist/zeno-mobile-runner-<version>.tgz --access public
432
432
  ```
433
433
 
434
+ If npm returns `EOTP`, the account or organization requires a TOTP-style
435
+ one-time password for this publish command. The browser/passkey login flow can
436
+ authenticate the local CLI session, but `npm publish` itself only accepts the
437
+ publish-time second factor through `--otp`. Prefer the tagged trusted-publishing
438
+ workflow for normal releases; otherwise enter the TOTP locally or use a granular
439
+ automation token configured to bypass 2FA. Do not send OTPs or tokens through
440
+ issue comments, chat, or commit history.
441
+
434
442
  If npm returns `E403` with a two-factor authentication message, the account or
435
443
  organization requires either a current interactive 2FA challenge or a granular
436
444
  automation token configured to bypass 2FA. For local passkey accounts, rerun
@@ -1,4 +1,4 @@
1
- {"jsonrpc":"2.0","id":1,"result":{"name":"zmr","version":"0.2.7","protocolVersion":"2026-04-28","protocol":{"version":"2026-04-28","minimumCompatibleVersion":"2026-04-28","stability":"dev-preview","breakingChangePolicy":"version-and-changelog"},"platforms":["android","ios"],"platformSupport":{"android":{"status":"supported","deviceTypes":["emulator","physical"],"automation":["adb","uiautomator","android-shim"]},"ios":{"status":"supported","deviceTypes":["simulator","physical"],"automation":["simctl","devicectl","xctest-shim"],"physicalDevices":true}},"iosPreview":false,"transports":["stdio","tcp"],"methods":["runner.capabilities","device.list","session.create","session.close","app.install","app.launch","app.stop","app.openLink","app.clearState","observe.snapshot","observe.semanticSnapshot","ui.tap","ui.type","ui.eraseText","ui.hideKeyboard","ui.swipe","ui.pressBack","ui.scrollUntilVisible","wait.until","wait.any","wait.gone","assert.visible","assert.notVisible","assert.healthy","scenario.validate","trace.events","trace.explore","trace.discover","trace.explain","trace.export"]}}
1
+ {"jsonrpc":"2.0","id":1,"result":{"name":"zmr","version":"0.2.8","protocolVersion":"2026-04-28","protocol":{"version":"2026-04-28","minimumCompatibleVersion":"2026-04-28","stability":"dev-preview","breakingChangePolicy":"version-and-changelog"},"platforms":["android","ios"],"platformSupport":{"android":{"status":"supported","deviceTypes":["emulator","physical"],"automation":["adb","uiautomator","android-shim"]},"ios":{"status":"supported","deviceTypes":["simulator","physical"],"automation":["simctl","devicectl","xctest-shim"],"physicalDevices":true}},"iosPreview":false,"transports":["stdio","tcp"],"methods":["runner.capabilities","device.list","session.create","session.close","app.install","app.launch","app.stop","app.openLink","app.clearState","observe.snapshot","observe.semanticSnapshot","ui.tap","ui.type","ui.eraseText","ui.hideKeyboard","ui.swipe","ui.pressBack","ui.scrollUntilVisible","wait.until","wait.any","wait.gone","assert.visible","assert.notVisible","assert.healthy","scenario.validate","trace.events","trace.explore","trace.discover","trace.explain","trace.export"]}}
2
2
  {"jsonrpc":"2.0","id":2,"result":[{"serial":"fake-device-1","state":"device","ready":true}]}
3
3
  {"jsonrpc":"2.0","id":3,"result":{"sessionId":"default"}}
4
4
  {"jsonrpc":"2.0","id":4,"result":true}
package/docs/protocol.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ZMR exposes newline-delimited JSON-RPC 2.0 over stdio or localhost TCP in v1. Each request is one JSON object followed by `\n`. Each response is one JSON object followed by `\n`.
4
4
 
5
- Current runner version: `0.2.7`.
5
+ Current runner version: `0.2.8`.
6
6
 
7
7
  Current protocol version: `2026-04-28`.
8
8
 
@@ -47,7 +47,7 @@ and protocol versions. The response is covered by
47
47
  `schemas/inspect-output.schema.json`:
48
48
 
49
49
  ```json
50
- {"ok":true,"status":"ready","schemaVersion":1,"runnerVersion":"0.2.7","protocolVersion":"2026-04-28","dir":".","configPath":".zmr/config.json","configExists":true,"agentInstructionsPath":".zmr/AGENTS.md","agentInstructionsExists":true,"platforms":[{"name":"android","enabled":true,"defaultDevice":"emulator-5554","smokeScenario":".zmr/android-smoke.json","smokeScenarioExists":true,"traceDir":"traces/zmr-android"},{"name":"ios","enabled":true,"defaultDevice":"booted","smokeScenario":".zmr/ios-smoke.json","smokeScenarioExists":true,"traceDir":"traces/zmr-ios"}],"recommendedCommands":["zmr doctor --strict --json --config .zmr/config.json","zmr schemas --json","zmr validate --json .zmr/android-smoke.json","zmr validate --json .zmr/ios-smoke.json","zmr serve --transport stdio --config .zmr/config.json --trace-dir traces/zmr-agent","zmr mcp --config .zmr/config.json --trace-dir traces/zmr-agent"],"claimsPolicy":["verify runs with trace evidence before making readiness claims","do not claim Flutter widget-tree inspection"],"limitations":["inspect is read-only and does not launch devices","autonomous crawling is not shipped; generate or edit scenarios for human review"]}
50
+ {"ok":true,"status":"ready","schemaVersion":1,"runnerVersion":"0.2.8","protocolVersion":"2026-04-28","dir":".","configPath":".zmr/config.json","configExists":true,"agentInstructionsPath":".zmr/AGENTS.md","agentInstructionsExists":true,"platforms":[{"name":"android","enabled":true,"defaultDevice":"emulator-5554","smokeScenario":".zmr/android-smoke.json","smokeScenarioExists":true,"traceDir":"traces/zmr-android"},{"name":"ios","enabled":true,"defaultDevice":"booted","smokeScenario":".zmr/ios-smoke.json","smokeScenarioExists":true,"traceDir":"traces/zmr-ios"}],"recommendedCommands":["zmr doctor --strict --json --config .zmr/config.json","zmr schemas --json","zmr validate --json .zmr/android-smoke.json","zmr validate --json .zmr/ios-smoke.json","zmr serve --transport stdio --config .zmr/config.json --trace-dir traces/zmr-agent","zmr mcp --config .zmr/config.json --trace-dir traces/zmr-agent"],"claimsPolicy":["verify runs with trace evidence before making readiness claims","do not claim Flutter widget-tree inspection"],"limitations":["inspect is read-only and does not launch devices","autonomous crawling is not shipped; generate or edit scenarios for human review"]}
51
51
  ```
52
52
 
53
53
  `zmr discover --from-trace <trace-dir> --out <scenario.json> --validate --json`
@@ -60,7 +60,7 @@ invent credentials, or commit files. The response is covered by
60
60
  `schemas/discover-output.schema.json`:
61
61
 
62
62
  ```json
63
- {"ok":true,"mode":"discover","schemaVersion":1,"runnerVersion":"0.2.7","protocolVersion":"2026-04-28","out":".zmr/discovered/replay-smoke.json","traceDir":"traces/zmr-agent","sourceSnapshot":"traces/zmr-agent/artifacts/snapshot-2.json","name":"draft from login smoke","appId":"com.example.mobiletest","selectorCount":2,"stepCount":6,"replay":{"enabled":true,"eventCount":4,"stepCount":3,"skippedEventCount":1},"warnings":["draft requires human review before commit"],"validated":true,"validation":{"ok":true,"path":".zmr/discovered/replay-smoke.json","name":"draft from login smoke","appId":"com.example.mobiletest","stepCount":6},"nextCommands":["zmr validate --json .zmr/discovered/replay-smoke.json","zmr run .zmr/discovered/replay-smoke.json --json --trace-dir traces/zmr-agent"]}
63
+ {"ok":true,"mode":"discover","schemaVersion":1,"runnerVersion":"0.2.8","protocolVersion":"2026-04-28","out":".zmr/discovered/replay-smoke.json","traceDir":"traces/zmr-agent","sourceSnapshot":"traces/zmr-agent/artifacts/snapshot-2.json","name":"draft from login smoke","appId":"com.example.mobiletest","selectorCount":2,"stepCount":6,"replay":{"enabled":true,"eventCount":4,"stepCount":3,"skippedEventCount":1},"warnings":["draft requires human review before commit"],"validated":true,"validation":{"ok":true,"path":".zmr/discovered/replay-smoke.json","name":"draft from login smoke","appId":"com.example.mobiletest","stepCount":6},"nextCommands":["zmr validate --json .zmr/discovered/replay-smoke.json","zmr run .zmr/discovered/replay-smoke.json --json --trace-dir traces/zmr-agent"]}
64
64
  ```
65
65
 
66
66
  `zmr explore --from-trace <trace-dir> --out <scenario.json> --goal <goal>
@@ -71,7 +71,7 @@ launch devices, crawl the app, invent missing actions, discover credentials, or
71
71
  commit files. The response is covered by `schemas/explore-output.schema.json`:
72
72
 
73
73
  ```json
74
- {"ok":true,"mode":"explore","schemaVersion":1,"runnerVersion":"0.2.7","protocolVersion":"2026-04-28","goal":"find a stable login smoke","autonomous":false,"reviewRequired":true,"guardrails":["writes from existing trace evidence only","does not crawl the app","does not discover credentials or secrets","requires human review before commit"],"out":".zmr/discovered/login-smoke.json","traceDir":"traces/zmr-agent","sourceSnapshot":"traces/zmr-agent/artifacts/snapshot-2.json","name":"draft from login smoke","appId":"com.example.mobiletest","selectorCount":2,"stepCount":6,"replay":{"enabled":true,"eventCount":4,"stepCount":3,"skippedEventCount":1},"warnings":["draft requires human review before commit"],"validated":true,"validation":{"ok":true,"path":".zmr/discovered/login-smoke.json","name":"draft from login smoke","appId":"com.example.mobiletest","stepCount":6},"nextCommands":["zmr validate --json .zmr/discovered/login-smoke.json","zmr run .zmr/discovered/login-smoke.json --json --trace-dir traces/zmr-agent"]}
74
+ {"ok":true,"mode":"explore","schemaVersion":1,"runnerVersion":"0.2.8","protocolVersion":"2026-04-28","goal":"find a stable login smoke","autonomous":false,"reviewRequired":true,"guardrails":["writes from existing trace evidence only","does not crawl the app","does not discover credentials or secrets","requires human review before commit"],"out":".zmr/discovered/login-smoke.json","traceDir":"traces/zmr-agent","sourceSnapshot":"traces/zmr-agent/artifacts/snapshot-2.json","name":"draft from login smoke","appId":"com.example.mobiletest","selectorCount":2,"stepCount":6,"replay":{"enabled":true,"eventCount":4,"stepCount":3,"skippedEventCount":1},"warnings":["draft requires human review before commit"],"validated":true,"validation":{"ok":true,"path":".zmr/discovered/login-smoke.json","name":"draft from login smoke","appId":"com.example.mobiletest","stepCount":6},"nextCommands":["zmr validate --json .zmr/discovered/login-smoke.json","zmr run .zmr/discovered/login-smoke.json --json --trace-dir traces/zmr-agent"]}
75
75
  ```
76
76
 
77
77
  `zmr draft --from-trace <trace-dir> --out <scenario.json> --json` is the lower
@@ -84,7 +84,7 @@ into fields, or commit files. The response is covered by
84
84
  `schemas/draft-output.schema.json`:
85
85
 
86
86
  ```json
87
- {"ok":true,"mode":"draft","schemaVersion":1,"runnerVersion":"0.2.7","protocolVersion":"2026-04-28","out":".zmr/discovered/surface-smoke.json","traceDir":"traces/zmr-agent","sourceSnapshot":"traces/zmr-agent/artifacts/snapshot-2.json","name":"draft from login smoke","appId":"com.example.mobiletest","selectorCount":2,"stepCount":4,"replay":{"enabled":false,"eventCount":0,"stepCount":0,"skippedEventCount":0},"warnings":["draft requires human review before commit"],"nextCommands":["zmr validate --json .zmr/discovered/surface-smoke.json","zmr run .zmr/discovered/surface-smoke.json --json --trace-dir traces/zmr-agent"]}
87
+ {"ok":true,"mode":"draft","schemaVersion":1,"runnerVersion":"0.2.8","protocolVersion":"2026-04-28","out":".zmr/discovered/surface-smoke.json","traceDir":"traces/zmr-agent","sourceSnapshot":"traces/zmr-agent/artifacts/snapshot-2.json","name":"draft from login smoke","appId":"com.example.mobiletest","selectorCount":2,"stepCount":4,"replay":{"enabled":false,"eventCount":0,"stepCount":0,"skippedEventCount":0},"warnings":["draft requires human review before commit"],"nextCommands":["zmr validate --json .zmr/discovered/surface-smoke.json","zmr run .zmr/discovered/surface-smoke.json --json --trace-dir traces/zmr-agent"]}
88
88
  ```
89
89
 
90
90
  `zmr draft --include-actions` additionally parses `events.jsonl` and prepends
@@ -214,7 +214,7 @@ installers, setup scripts, and generated clients. The response is covered by
214
214
  `schemas/version-output.schema.json`:
215
215
 
216
216
  ```json
217
- {"name":"zmr","version":"0.2.7","protocolVersion":"2026-04-28","minimumCompatibleProtocolVersion":"2026-04-28","stability":"dev-preview","breakingChangePolicy":"version-and-changelog"}
217
+ {"name":"zmr","version":"0.2.8","protocolVersion":"2026-04-28","minimumCompatibleProtocolVersion":"2026-04-28","stability":"dev-preview","breakingChangePolicy":"version-and-changelog"}
218
218
  ```
219
219
 
220
220
  ## Capabilities Output Contract
@@ -226,7 +226,7 @@ and method inventory for JSON-RPC clients. The result object is covered by
226
226
  iOS simulator, or physical iOS workflows are available.
227
227
 
228
228
  ```json
229
- {"name":"zmr","version":"0.2.7","protocolVersion":"2026-04-28","protocol":{"version":"2026-04-28","minimumCompatibleVersion":"2026-04-28","stability":"dev-preview","breakingChangePolicy":"version-and-changelog"},"platforms":["android","ios"],"platformSupport":{"android":{"status":"supported","deviceTypes":["emulator","physical"],"automation":["adb","uiautomator","android-shim"]},"ios":{"status":"supported","deviceTypes":["simulator","physical"],"automation":["simctl","devicectl","xctest-shim"],"physicalDevices":true}},"iosPreview":false,"transports":["stdio","tcp"],"methods":["runner.capabilities","device.list","session.create","session.close","app.install","app.launch","app.stop","app.openLink","app.clearState","observe.snapshot","observe.semanticSnapshot","ui.tap","ui.type","ui.eraseText","ui.hideKeyboard","ui.swipe","ui.pressBack","ui.scrollUntilVisible","wait.until","wait.any","wait.gone","assert.visible","assert.notVisible","assert.healthy","scenario.validate","trace.events","trace.explore","trace.discover","trace.explain","trace.export"]}
229
+ {"name":"zmr","version":"0.2.8","protocolVersion":"2026-04-28","protocol":{"version":"2026-04-28","minimumCompatibleVersion":"2026-04-28","stability":"dev-preview","breakingChangePolicy":"version-and-changelog"},"platforms":["android","ios"],"platformSupport":{"android":{"status":"supported","deviceTypes":["emulator","physical"],"automation":["adb","uiautomator","android-shim"]},"ios":{"status":"supported","deviceTypes":["simulator","physical"],"automation":["simctl","devicectl","xctest-shim"],"physicalDevices":true}},"iosPreview":false,"transports":["stdio","tcp"],"methods":["runner.capabilities","device.list","session.create","session.close","app.install","app.launch","app.stop","app.openLink","app.clearState","observe.snapshot","observe.semanticSnapshot","ui.tap","ui.type","ui.eraseText","ui.hideKeyboard","ui.swipe","ui.pressBack","ui.scrollUntilVisible","wait.until","wait.any","wait.gone","assert.visible","assert.notVisible","assert.healthy","scenario.validate","trace.events","trace.explore","trace.discover","trace.explain","trace.export"]}
230
230
  ```
231
231
 
232
232
  ## Doctor Output Contract
@@ -432,7 +432,7 @@ Request:
432
432
  Response:
433
433
 
434
434
  ```json
435
- {"jsonrpc":"2.0","id":1,"result":{"name":"zmr","version":"0.2.7","protocolVersion":"2026-04-28","protocol":{"version":"2026-04-28","minimumCompatibleVersion":"2026-04-28","stability":"dev-preview","breakingChangePolicy":"version-and-changelog"},"platforms":["android","ios"],"platformSupport":{"android":{"status":"supported","deviceTypes":["emulator","physical"],"automation":["adb","uiautomator","android-shim"]},"ios":{"status":"supported","deviceTypes":["simulator","physical"],"automation":["simctl","devicectl","xctest-shim"],"physicalDevices":true}},"iosPreview":false,"transports":["stdio","tcp"],"methods":["runner.capabilities","device.list","session.create","session.close","app.install","app.launch","app.stop","app.openLink","app.clearState","observe.snapshot","observe.semanticSnapshot","ui.tap","ui.type","ui.eraseText","ui.hideKeyboard","ui.swipe","ui.pressBack","ui.scrollUntilVisible","wait.until","wait.any","wait.gone","assert.visible","assert.notVisible","assert.healthy","scenario.validate","trace.events","trace.explore","trace.discover","trace.explain","trace.export"]}}
435
+ {"jsonrpc":"2.0","id":1,"result":{"name":"zmr","version":"0.2.8","protocolVersion":"2026-04-28","protocol":{"version":"2026-04-28","minimumCompatibleVersion":"2026-04-28","stability":"dev-preview","breakingChangePolicy":"version-and-changelog"},"platforms":["android","ios"],"platformSupport":{"android":{"status":"supported","deviceTypes":["emulator","physical"],"automation":["adb","uiautomator","android-shim"]},"ios":{"status":"supported","deviceTypes":["simulator","physical"],"automation":["simctl","devicectl","xctest-shim"],"physicalDevices":true}},"iosPreview":false,"transports":["stdio","tcp"],"methods":["runner.capabilities","device.list","session.create","session.close","app.install","app.launch","app.stop","app.openLink","app.clearState","observe.snapshot","observe.semanticSnapshot","ui.tap","ui.type","ui.eraseText","ui.hideKeyboard","ui.swipe","ui.pressBack","ui.scrollUntilVisible","wait.until","wait.any","wait.gone","assert.visible","assert.notVisible","assert.healthy","scenario.validate","trace.events","trace.explore","trace.discover","trace.explain","trace.export"]}}
436
436
  ```
437
437
 
438
438
  ### `trace.events`
@@ -514,7 +514,7 @@ Request:
514
514
  Response:
515
515
 
516
516
  ```json
517
- {"jsonrpc":"2.0","id":25,"result":{"ok":true,"mode":"discover","schemaVersion":1,"runnerVersion":"0.2.7","protocolVersion":"2026-04-28","out":".zmr/discovered/agent-smoke.json","traceDir":"traces/agent-session","sourceSnapshot":"traces/agent-session/artifacts/snapshot-1.json","name":"agent smoke","appId":"com.example.mobiletest","selectorCount":1,"stepCount":4,"replay":{"enabled":true,"eventCount":2,"stepCount":1,"skippedEventCount":1},"warnings":["draft requires human review before commit"],"validated":true,"validation":{"ok":true,"path":".zmr/discovered/agent-smoke.json","name":"agent smoke","appId":"com.example.mobiletest","stepCount":4},"nextCommands":["zmr validate --json .zmr/discovered/agent-smoke.json","zmr run .zmr/discovered/agent-smoke.json --json --trace-dir traces/agent-session"]}}
517
+ {"jsonrpc":"2.0","id":25,"result":{"ok":true,"mode":"discover","schemaVersion":1,"runnerVersion":"0.2.8","protocolVersion":"2026-04-28","out":".zmr/discovered/agent-smoke.json","traceDir":"traces/agent-session","sourceSnapshot":"traces/agent-session/artifacts/snapshot-1.json","name":"agent smoke","appId":"com.example.mobiletest","selectorCount":1,"stepCount":4,"replay":{"enabled":true,"eventCount":2,"stepCount":1,"skippedEventCount":1},"warnings":["draft requires human review before commit"],"validated":true,"validation":{"ok":true,"path":".zmr/discovered/agent-smoke.json","name":"agent smoke","appId":"com.example.mobiletest","stepCount":4},"nextCommands":["zmr validate --json .zmr/discovered/agent-smoke.json","zmr run .zmr/discovered/agent-smoke.json --json --trace-dir traces/agent-session"]}}
518
518
  ```
519
519
 
520
520
  Without `--trace-dir`, it returns `ok: false` with `traceDir: null`. Generated
@@ -537,7 +537,7 @@ Request:
537
537
  Response:
538
538
 
539
539
  ```json
540
- {"jsonrpc":"2.0","id":27,"result":{"ok":true,"mode":"explore","schemaVersion":1,"runnerVersion":"0.2.7","protocolVersion":"2026-04-28","out":".zmr/discovered/agent-goal.json","traceDir":"traces/agent-session","sourceSnapshot":"traces/agent-session/artifacts/snapshot-1.json","name":"agent goal smoke","appId":"com.example.mobiletest","selectorCount":1,"stepCount":4,"replay":{"enabled":true,"eventCount":2,"stepCount":1,"skippedEventCount":1},"warnings":["draft requires human review before commit"],"validated":true,"validation":{"ok":true,"path":".zmr/discovered/agent-goal.json","name":"agent goal smoke","appId":"com.example.mobiletest","stepCount":4},"nextCommands":["zmr validate --json .zmr/discovered/agent-goal.json","zmr run .zmr/discovered/agent-goal.json --json --trace-dir traces/agent-session"],"goal":"find a stable login smoke","autonomous":false,"reviewRequired":true,"guardrails":["writes from existing trace evidence only","does not crawl the app","does not discover credentials or secrets","requires human review before commit"]}}
540
+ {"jsonrpc":"2.0","id":27,"result":{"ok":true,"mode":"explore","schemaVersion":1,"runnerVersion":"0.2.8","protocolVersion":"2026-04-28","out":".zmr/discovered/agent-goal.json","traceDir":"traces/agent-session","sourceSnapshot":"traces/agent-session/artifacts/snapshot-1.json","name":"agent goal smoke","appId":"com.example.mobiletest","selectorCount":1,"stepCount":4,"replay":{"enabled":true,"eventCount":2,"stepCount":1,"skippedEventCount":1},"warnings":["draft requires human review before commit"],"validated":true,"validation":{"ok":true,"path":".zmr/discovered/agent-goal.json","name":"agent goal smoke","appId":"com.example.mobiletest","stepCount":4},"nextCommands":["zmr validate --json .zmr/discovered/agent-goal.json","zmr run .zmr/discovered/agent-goal.json --json --trace-dir traces/agent-session"],"goal":"find a stable login smoke","autonomous":false,"reviewRequired":true,"guardrails":["writes from existing trace evidence only","does not crawl the app","does not discover credentials or secrets","requires human review before commit"]}}
541
541
  ```
542
542
 
543
543
  Without `--trace-dir`, it returns `ok: false` with `traceDir: null`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zeno-mobile-runner",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "Agent-native mobile app test runner for React Native, Expo, Flutter, and native Android/iOS.",
5
5
  "main": "npm/index.mjs",
6
6
  "repository": {
Binary file
Binary file
Binary file
Binary file
@@ -48,11 +48,16 @@ pub fn run(allocator: std.mem.Allocator, args: *std.process.Args.Iterator) !void
48
48
  stdout_io.init(.stdout());
49
49
  defer stdout_io.deinit();
50
50
  const stdout = stdout_io.writer();
51
- if (json) return try device_registry.writeJson(stdout, registryPlatform(platform), devices);
52
- if (devices.len == 0) return try stdout.print("No {s} devices found.\n", .{@tagName(platform)});
53
- for (devices) |device| {
54
- try stdout.print("{s}\t{s}\n", .{ device.serial, device.state });
51
+ if (json) {
52
+ try device_registry.writeJson(stdout, registryPlatform(platform), devices);
53
+ } else if (devices.len == 0) {
54
+ try stdout.print("No {s} devices found.\n", .{@tagName(platform)});
55
+ } else {
56
+ for (devices) |device| {
57
+ try stdout.print("{s}\t{s}\n", .{ device.serial, device.state });
58
+ }
55
59
  }
60
+ try stdout_io.flush();
56
61
  }
57
62
 
58
63
  fn parsePlatform(value: []const u8) !run_options.Platform {
@@ -106,6 +106,7 @@ pub fn run(allocator: std.mem.Allocator, args: *std.process.Args.Iterator) !void
106
106
  try cli_output.writeShellArg(stdout, discovered.summary.draft.out_path);
107
107
  try stdout.writeAll("\n");
108
108
  }
109
+ try stdout_io.flush();
109
110
  if (!discovered.summary.ok) std.process.exit(1);
110
111
  }
111
112
 
@@ -114,5 +114,6 @@ fn runParsed(allocator: std.mem.Allocator, parsed: ParsedArgs) !void {
114
114
  } else {
115
115
  try cli_output.writeDoctorText(stdout, config_check, checks);
116
116
  }
117
+ try stdout_io.flush();
117
118
  if (parsed.strict and !cli_output.doctorChecksHealthy(config_check, checks)) std.process.exit(1);
118
119
  }
package/src/cli_draft.zig CHANGED
@@ -125,13 +125,13 @@ pub fn run(allocator: std.mem.Allocator, args: *std.process.Args.Iterator) !void
125
125
  const stdout = stdout_io.writer();
126
126
  if (parsed.json) {
127
127
  try writeJson(stdout, draft.summary);
128
- return;
128
+ } else {
129
+ try stdout.print("wrote {s}\n", .{draft.summary.out_path});
130
+ try stdout.writeAll("next: zmr validate --json ");
131
+ try cli_output.writeShellArg(stdout, draft.summary.out_path);
132
+ try stdout.writeAll("\n");
129
133
  }
130
-
131
- try stdout.print("wrote {s}\n", .{draft.summary.out_path});
132
- try stdout.writeAll("next: zmr validate --json ");
133
- try cli_output.writeShellArg(stdout, draft.summary.out_path);
134
- try stdout.writeAll("\n");
134
+ try stdout_io.flush();
135
135
  }
136
136
 
137
137
  pub fn draftFromTrace(allocator: std.mem.Allocator, parsed: ParsedArgs) !OwnedDraft {
@@ -99,6 +99,7 @@ pub fn run(allocator: std.mem.Allocator, args: *std.process.Args.Iterator) !void
99
99
  try cli_output.writeShellArg(stdout, explored.discovered.summary.draft.out_path);
100
100
  try stdout.writeAll("\n");
101
101
  }
102
+ try stdout_io.flush();
102
103
  if (!explored.summary.ok) std.process.exit(1);
103
104
  }
104
105
 
@@ -82,9 +82,13 @@ pub fn run(allocator: std.mem.Allocator, args: *std.process.Args.Iterator) !void
82
82
  stdout_io.init(.stdout());
83
83
  defer stdout_io.deinit();
84
84
  const stdout = stdout_io.writer();
85
- if (parsed.json) return try cli_output.writeImportJson(stdout, parsed.format, parsed.source_path, result);
86
- try stdout.print("wrote {s}\n", .{result.out_path});
87
- try stdout.writeAll("next: zmr validate ");
88
- try cli_output.writeShellArg(stdout, result.out_path);
89
- try stdout.writeAll("\n");
85
+ if (parsed.json) {
86
+ try cli_output.writeImportJson(stdout, parsed.format, parsed.source_path, result);
87
+ } else {
88
+ try stdout.print("wrote {s}\n", .{result.out_path});
89
+ try stdout.writeAll("next: zmr validate ");
90
+ try cli_output.writeShellArg(stdout, result.out_path);
91
+ try stdout.writeAll("\n");
92
+ }
93
+ try stdout_io.flush();
90
94
  }
package/src/cli_info.zig CHANGED
@@ -22,8 +22,12 @@ pub fn runVersion(allocator: std.mem.Allocator, args: *std.process.Args.Iterator
22
22
  stdout_io.init(.stdout());
23
23
  defer stdout_io.deinit();
24
24
  const stdout = stdout_io.writer();
25
- if (json) return try version.writeJson(stdout);
26
- try version.writePlain(stdout);
25
+ if (json) {
26
+ try version.writeJson(stdout);
27
+ } else {
28
+ try version.writePlain(stdout);
29
+ }
30
+ try stdout_io.flush();
27
31
  }
28
32
 
29
33
  pub fn runSchemas(allocator: std.mem.Allocator, args: *std.process.Args.Iterator) !void {
@@ -32,10 +36,14 @@ pub fn runSchemas(allocator: std.mem.Allocator, args: *std.process.Args.Iterator
32
36
  stdout_io.init(.stdout());
33
37
  defer stdout_io.deinit();
34
38
  const stdout = stdout_io.writer();
35
- if (json) return try schema_registry.writeJson(stdout);
36
- for (schema_registry.all()) |schema_info| {
37
- try stdout.print("{s}\t{s}\n", .{ schema_info.name, schema_info.path });
39
+ if (json) {
40
+ try schema_registry.writeJson(stdout);
41
+ } else {
42
+ for (schema_registry.all()) |schema_info| {
43
+ try stdout.print("{s}\t{s}\n", .{ schema_info.name, schema_info.path });
44
+ }
38
45
  }
46
+ try stdout_io.flush();
39
47
  }
40
48
 
41
49
  fn parseArgIterator(allocator: std.mem.Allocator, args: *std.process.Args.Iterator) !bool {
package/src/cli_init.zig CHANGED
@@ -57,20 +57,28 @@ pub fn run(allocator: std.mem.Allocator, args: *std.process.Args.Iterator) !void
57
57
  const stdout = stdout_io.writer();
58
58
  if (parsed.app_scaffold) {
59
59
  try scaffold.writeAppScaffold(allocator, parsed.dir, parsed.app_id, parsed.force);
60
- if (parsed.json) return try cli_output.writeInitAppJson(stdout, parsed.dir, parsed.app_id);
61
- for (scaffold.app_created_files) |path| {
62
- try stdout.print("created {s}/{s}\n", .{ parsed.dir, path });
60
+ if (parsed.json) {
61
+ try cli_output.writeInitAppJson(stdout, parsed.dir, parsed.app_id);
62
+ } else {
63
+ for (scaffold.app_created_files) |path| {
64
+ try stdout.print("created {s}/{s}\n", .{ parsed.dir, path });
65
+ }
66
+ try stdout.writeAll("next: zmr doctor --strict --json --config ");
67
+ try cli_output.writeJoinedPathShellArg(stdout, parsed.dir, scaffold.app_config_file);
68
+ try stdout.writeAll("\n");
63
69
  }
64
- try stdout.writeAll("next: zmr doctor --strict --json --config ");
65
- try cli_output.writeJoinedPathShellArg(stdout, parsed.dir, scaffold.app_config_file);
66
- try stdout.writeAll("\n");
70
+ try stdout_io.flush();
67
71
  return;
68
72
  }
69
73
 
70
74
  try scaffold.writeStarterScenario(allocator, parsed.path, parsed.app_id, parsed.force);
71
- if (parsed.json) return try cli_output.writeInitScenarioJson(stdout, parsed.path, parsed.app_id);
72
- try stdout.print("created {s}\n", .{parsed.path});
73
- try stdout.writeAll("next: zmr validate ");
74
- try cli_output.writeShellArg(stdout, parsed.path);
75
- try stdout.writeAll("\n");
75
+ if (parsed.json) {
76
+ try cli_output.writeInitScenarioJson(stdout, parsed.path, parsed.app_id);
77
+ } else {
78
+ try stdout.print("created {s}\n", .{parsed.path});
79
+ try stdout.writeAll("next: zmr validate ");
80
+ try cli_output.writeShellArg(stdout, parsed.path);
81
+ try stdout.writeAll("\n");
82
+ }
83
+ try stdout_io.flush();
76
84
  }
@@ -84,6 +84,7 @@ pub fn run(allocator: std.mem.Allocator, args: *std.process.Args.Iterator) !void
84
84
  } else {
85
85
  try writeText(stdout, owned.inspection);
86
86
  }
87
+ try stdout_io.flush();
87
88
  }
88
89
 
89
90
  fn inspect(allocator: std.mem.Allocator, parsed: ParsedArgs) !OwnedInspection {
package/src/cli_run.zig CHANGED
@@ -226,6 +226,7 @@ pub fn run(allocator: std.mem.Allocator, args: *std.process.Args.Iterator) !void
226
226
  run_error,
227
227
  run_discovery,
228
228
  );
229
+ try stdout_io.flush();
229
230
  }
230
231
  if (run_error) |err| return err;
231
232
  }
package/src/cli_trace.zig CHANGED
@@ -120,6 +120,7 @@ pub fn runReport(allocator: std.mem.Allocator, args: *std.process.Args.Iterator)
120
120
  try report.writeJUnitReport(allocator, parsed.input_path, junit_path);
121
121
  try stdout.print("wrote {s}\n", .{junit_path});
122
122
  }
123
+ try stdout_io.flush();
123
124
  }
124
125
 
125
126
  pub fn runExplain(allocator: std.mem.Allocator, args: *std.process.Args.Iterator) !void {
@@ -132,8 +133,12 @@ pub fn runExplain(allocator: std.mem.Allocator, args: *std.process.Args.Iterator
132
133
  stdout_io.init(.stdout());
133
134
  defer stdout_io.deinit();
134
135
  const stdout = stdout_io.writer();
135
- if (parsed.json) return try report.writeTraceExplanationJson(allocator, parsed.trace_dir.?, stdout);
136
- try report.writeTraceExplanation(allocator, parsed.trace_dir.?, stdout);
136
+ if (parsed.json) {
137
+ try report.writeTraceExplanationJson(allocator, parsed.trace_dir.?, stdout);
138
+ } else {
139
+ try report.writeTraceExplanation(allocator, parsed.trace_dir.?, stdout);
140
+ }
141
+ try stdout_io.flush();
137
142
  }
138
143
 
139
144
  pub fn runExport(allocator: std.mem.Allocator, args: *std.process.Args.Iterator) !void {
@@ -151,4 +156,5 @@ pub fn runExport(allocator: std.mem.Allocator, args: *std.process.Args.Iterator)
151
156
  defer stdout_io.deinit();
152
157
  const stdout = stdout_io.writer();
153
158
  try stdout.print("wrote {s}\n", .{parsed.out_path.?});
159
+ try stdout_io.flush();
154
160
  }
@@ -47,5 +47,6 @@ pub fn run(allocator: std.mem.Allocator, args: *std.process.Args.Iterator) !void
47
47
  } else {
48
48
  try cli_output.writeValidationText(stdout, parsed.path, result);
49
49
  }
50
+ try stdout_io.flush();
50
51
  if (!result.ok) std.process.exit(1);
51
52
  }
package/src/command.zig CHANGED
@@ -1,3 +1,4 @@
1
+ const builtin = @import("builtin");
1
2
  const std = @import("std");
2
3
  const stdio = @import("stdio.zig");
3
4
 
@@ -61,6 +62,7 @@ pub fn runWithInputTimeout(
61
62
  .stdin = .pipe,
62
63
  .stdout = .pipe,
63
64
  .stderr = .pipe,
65
+ .pgid = processGroupForTimeout(timeout_ms),
64
66
  });
65
67
  defer if (child.id != null) child.kill(stdio.io());
66
68
 
@@ -84,21 +86,16 @@ pub fn runWithTimeout(
84
86
  ) !ExecResult {
85
87
  if (timeout_ms == 0) return run(allocator, argv, max_output_bytes);
86
88
 
87
- const result = std.process.run(allocator, stdio.io(), .{
89
+ var child = try std.process.spawn(stdio.io(), .{
88
90
  .argv = argv,
89
- .stdout_limit = .limited(max_output_bytes),
90
- .stderr_limit = .limited(max_output_bytes),
91
- .timeout = timeoutForMs(timeout_ms),
92
- }) catch |err| switch (err) {
93
- error.Timeout => return timedOutResult(allocator),
94
- else => |actual| return actual,
95
- };
96
- return .{
97
- .stdout = result.stdout,
98
- .stderr = result.stderr,
99
- .term = result.term,
100
- .timed_out = false,
101
- };
91
+ .stdin = .ignore,
92
+ .stdout = .pipe,
93
+ .stderr = .pipe,
94
+ .pgid = processGroupForTimeout(timeout_ms),
95
+ });
96
+ defer if (child.id != null) child.kill(stdio.io());
97
+
98
+ return try collectSpawnedOutput(allocator, &child, max_output_bytes, timeoutForMs(timeout_ms));
102
99
  }
103
100
 
104
101
  fn collectSpawnedOutput(
@@ -121,7 +118,7 @@ fn collectSpawnedOutput(
121
118
  } else |err| switch (err) {
122
119
  error.EndOfStream => {},
123
120
  error.Timeout => {
124
- child.kill(stdio.io());
121
+ terminateTimedOutChild(child);
125
122
  return timedOutResult(allocator);
126
123
  },
127
124
  else => |actual| return actual,
@@ -143,6 +140,34 @@ fn collectSpawnedOutput(
143
140
  };
144
141
  }
145
142
 
143
+ fn processGroupForTimeout(timeout_ms: u64) ?std.posix.pid_t {
144
+ if (timeout_ms == 0) return null;
145
+ return switch (builtin.os.tag) {
146
+ .linux,
147
+ .macos,
148
+ .ios,
149
+ .tvos,
150
+ .watchos,
151
+ .visionos,
152
+ .freebsd,
153
+ .netbsd,
154
+ .openbsd,
155
+ .haiku,
156
+ .illumos,
157
+ => 0,
158
+ else => null,
159
+ };
160
+ }
161
+
162
+ fn terminateTimedOutChild(child: *std.process.Child) void {
163
+ if (child.id) |pid| {
164
+ if (processGroupForTimeout(1) != null and pid > 0) {
165
+ std.posix.kill(-pid, .TERM) catch {};
166
+ }
167
+ }
168
+ child.kill(stdio.io());
169
+ }
170
+
146
171
  fn timedOutResult(allocator: std.mem.Allocator) !ExecResult {
147
172
  return .{
148
173
  .stdout = try allocator.dupe(u8, ""),
package/src/ios.zig CHANGED
@@ -149,11 +149,18 @@ pub const IosDevice = struct {
149
149
  }
150
150
 
151
151
  pub fn visibleBySelector(self: *IosDevice, wanted: selector.Selector) !?bool {
152
+ return try self.visibleBySelectorWithTimeout(wanted, shimTimeoutMs());
153
+ }
154
+
155
+ pub fn visibleBySelectorWithTimeout(self: *IosDevice, wanted: selector.Selector, timeout_ms: u64) !?bool {
152
156
  if (self.shim_path == null) return null;
153
157
  const shim_selector = try ios_shim.selectorString(self.allocator, wanted) orelse return null;
154
158
  defer self.allocator.free(shim_selector);
155
159
 
156
- const response = try self.runShim(.{ .kind = .query, .selector = shim_selector });
160
+ const response = try self.runShimWithTimeout(.{
161
+ .kind = .query,
162
+ .selector = shim_selector,
163
+ }, @max(timeout_ms, 1));
157
164
  defer self.allocator.free(response);
158
165
  return try ios_shim.parseQueryResponse(response);
159
166
  }
@@ -460,7 +467,7 @@ test "ios simulator openLink keeps sweeping delayed XCTest interruptions until a
460
467
  \\if [[ "$count" -lt 6 ]]; then
461
468
  \\ printf '{{"status":"ok","accepted":false,"count":0}}\n'
462
469
  \\else
463
- \\ printf '{{"status":"ok","accepted":true,"label":"Brick Rewards Test","count":1}}\n'
470
+ \\ printf '{{"status":"ok","accepted":true,"label":"Demo App","count":1}}\n'
464
471
  \\fi
465
472
  \\
466
473
  , .{ tmp.sub_path, tmp.sub_path });
package/src/main.zig CHANGED
@@ -165,4 +165,5 @@ fn usage() !void {
165
165
  \\assertNotVisible, assertNoneVisible, assertHealthy, snapshot, sleep. Any step may use "optional": true.
166
166
  \\
167
167
  );
168
+ try stdout_io.flush();
168
169
  }
package/src/runner.zig CHANGED
@@ -456,6 +456,106 @@ test "assertHealthy retries through a transient observation command failure" {
456
456
  try std.testing.expect(std.mem.indexOf(u8, events, "\"status\":\"ok\"") != null);
457
457
  }
458
458
 
459
+ test "native selector waits pass bounded query timeouts instead of legacy blocking queries" {
460
+ const allocator = std.testing.allocator;
461
+ const dir = "zig-cache-test-runner-native-wait-bounded-query-timeout";
462
+ std.Io.Dir.cwd().deleteTree(stdio.io(), dir) catch {};
463
+ defer std.Io.Dir.cwd().deleteTree(stdio.io(), dir) catch {};
464
+
465
+ const NativeBoundedWaitDevice = struct {
466
+ allocator: std.mem.Allocator,
467
+ legacy_queries: usize = 0,
468
+ bounded_queries: usize = 0,
469
+ largest_query_timeout_ms: u64 = 0,
470
+ snapshots: usize = 0,
471
+
472
+ pub fn visibleBySelector(self: *@This(), wanted: selector.Selector) !?bool {
473
+ _ = wanted;
474
+ self.legacy_queries += 1;
475
+ return false;
476
+ }
477
+
478
+ pub fn visibleBySelectorWithTimeout(self: *@This(), wanted: selector.Selector, timeout_ms: u64) !?bool {
479
+ _ = wanted;
480
+ self.bounded_queries += 1;
481
+ self.largest_query_timeout_ms = @max(self.largest_query_timeout_ms, timeout_ms);
482
+ return false;
483
+ }
484
+
485
+ pub fn snapshot(self: *@This(), writer: anytype) !types.ObservationSnapshot {
486
+ _ = writer;
487
+ self.snapshots += 1;
488
+ const nodes = try self.allocator.alloc(types.UiNode, 0);
489
+ return .{
490
+ .id = try self.allocator.dupe(u8, "native-bounded-timeout-final"),
491
+ .timestamp_ms = 1,
492
+ .nodes = nodes,
493
+ };
494
+ }
495
+ };
496
+
497
+ var device = NativeBoundedWaitDevice{ .allocator = allocator };
498
+ var tw = try trace.TraceWriter.init(allocator, dir);
499
+ defer tw.deinit();
500
+
501
+ try std.testing.expect(!try waitUntilVisible(&device, .{ .id = "never-visible" }, 25, &tw, .{ .poll_ms = 0 }));
502
+ try std.testing.expect(device.bounded_queries > 0);
503
+ try std.testing.expectEqual(@as(usize, 0), device.legacy_queries);
504
+ try std.testing.expect(device.largest_query_timeout_ms > 0);
505
+ try std.testing.expect(device.largest_query_timeout_ms <= 25);
506
+ try std.testing.expectEqual(@as(usize, 1), device.snapshots);
507
+ }
508
+
509
+ test "native selector wait retries bounded query command timeouts before falling back" {
510
+ const allocator = std.testing.allocator;
511
+ const dir = "zig-cache-test-runner-native-wait-retries-bounded-query-timeout";
512
+ std.Io.Dir.cwd().deleteTree(stdio.io(), dir) catch {};
513
+ defer std.Io.Dir.cwd().deleteTree(stdio.io(), dir) catch {};
514
+
515
+ const NativeFlakyBoundedWaitDevice = struct {
516
+ allocator: std.mem.Allocator,
517
+ legacy_queries: usize = 0,
518
+ bounded_queries: usize = 0,
519
+ snapshots: usize = 0,
520
+
521
+ pub fn visibleBySelector(self: *@This(), wanted: selector.Selector) !?bool {
522
+ _ = wanted;
523
+ self.legacy_queries += 1;
524
+ return error.LegacyNativeQueryUsed;
525
+ }
526
+
527
+ pub fn visibleBySelectorWithTimeout(self: *@This(), wanted: selector.Selector, timeout_ms: u64) !?bool {
528
+ _ = wanted;
529
+ try std.testing.expect(timeout_ms > 0);
530
+ self.bounded_queries += 1;
531
+ if (self.bounded_queries == 1) return error.CommandTimedOut;
532
+ return true;
533
+ }
534
+
535
+ pub fn snapshot(self: *@This(), writer: anytype) !types.ObservationSnapshot {
536
+ _ = writer;
537
+ self.snapshots += 1;
538
+ return error.UnexpectedSnapshotFallback;
539
+ }
540
+ };
541
+
542
+ var device = NativeFlakyBoundedWaitDevice{ .allocator = allocator };
543
+ var tw = try trace.TraceWriter.init(allocator, dir);
544
+ defer tw.deinit();
545
+
546
+ try std.testing.expect(try waitUntilVisible(&device, .{ .text = "Ready" }, 100, &tw, .{ .poll_ms = 0 }));
547
+ try std.testing.expectEqual(@as(usize, 0), device.legacy_queries);
548
+ try std.testing.expectEqual(@as(usize, 2), device.bounded_queries);
549
+ try std.testing.expectEqual(@as(usize, 0), device.snapshots);
550
+
551
+ const events_path = try std.fs.path.join(allocator, &.{ dir, "events.jsonl" });
552
+ defer allocator.free(events_path);
553
+ const events = try stdio.readFileAlloc(allocator, events_path, 1024 * 1024);
554
+ defer allocator.free(events);
555
+ try std.testing.expect(std.mem.indexOf(u8, events, "\"kind\":\"observe.retry\"") != null);
556
+ try std.testing.expect(std.mem.indexOf(u8, events, "\"error\":\"CommandTimedOut\"") != null);
557
+ }
558
+
459
559
  test "assertHealthy uses native selector probes before broad snapshots" {
460
560
  const allocator = std.testing.allocator;
461
561
  const dir = "zig-cache-test-runner-assert-healthy-native-selector";
@@ -3,7 +3,6 @@ const stdio = @import("stdio.zig");
3
3
  const health = @import("health.zig");
4
4
  const runner_config = @import("runner_config.zig");
5
5
  const runner_events = @import("runner_events.zig");
6
- const runner_native = @import("runner_native.zig");
7
6
  const scenario = @import("scenario.zig");
8
7
  const selector = @import("selector.zig");
9
8
  const trace = @import("trace.zig");
@@ -40,18 +39,28 @@ fn untilVisibleKind(
40
39
  ) !bool {
41
40
  const deadline = stdio.nowMs() + @as(i64, @intCast(timeout_ms));
42
41
  while (true) {
43
- if (try nativeVisibleBySelector(device, wanted)) |visible| {
44
- if (visible) {
45
- if (writer) |tw| try runner_events.recordNativeWait(tw, kind, wanted, null, timeout_ms);
46
- return true;
47
- }
48
- if (stdio.nowMs() >= deadline) {
49
- if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, kind, &[_]selector.Selector{wanted}, timeout_ms);
50
- return false;
42
+ if (nativeSelectorQueryTimeoutMs(deadline)) |query_timeout_ms| {
43
+ const native_result = nativeVisibleBySelector(device, wanted, query_timeout_ms) catch |err| {
44
+ if (try retryTransientObservation(err, kind, writer, deadline, options)) continue;
45
+ return err;
46
+ };
47
+ if (native_result) |visible| {
48
+ if (visible) {
49
+ if (writer) |tw| try runner_events.recordNativeWait(tw, kind, wanted, null, timeout_ms);
50
+ return true;
51
+ }
52
+ if (stdio.nowMs() >= deadline) {
53
+ if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, kind, &[_]selector.Selector{wanted}, timeout_ms);
54
+ return false;
55
+ }
56
+ try sleepMs(options.poll_ms);
57
+ continue;
51
58
  }
52
- try sleepMs(options.poll_ms);
53
- continue;
59
+ } else if (hasNativeSelectorQuery(device)) {
60
+ if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, kind, &[_]selector.Selector{wanted}, timeout_ms);
61
+ return false;
54
62
  }
63
+
55
64
  var snap = device.snapshot(writer) catch |err| {
56
65
  if (try retryTransientObservation(err, kind, writer, deadline, options)) continue;
57
66
  return err;
@@ -110,18 +119,28 @@ fn untilNotVisibleKind(
110
119
  ) !bool {
111
120
  const deadline = stdio.nowMs() + @as(i64, @intCast(timeout_ms));
112
121
  while (true) {
113
- if (try nativeVisibleBySelector(device, wanted)) |visible| {
114
- if (!visible) {
115
- if (writer) |tw| try runner_events.recordNativeWait(tw, kind, wanted, null, timeout_ms);
116
- return true;
117
- }
118
- if (stdio.nowMs() >= deadline) {
119
- if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, kind, &[_]selector.Selector{wanted}, timeout_ms);
120
- return false;
122
+ if (nativeSelectorQueryTimeoutMs(deadline)) |query_timeout_ms| {
123
+ const native_result = nativeVisibleBySelector(device, wanted, query_timeout_ms) catch |err| {
124
+ if (try retryTransientObservation(err, kind, writer, deadline, options)) continue;
125
+ return err;
126
+ };
127
+ if (native_result) |visible| {
128
+ if (!visible) {
129
+ if (writer) |tw| try runner_events.recordNativeWait(tw, kind, wanted, null, timeout_ms);
130
+ return true;
131
+ }
132
+ if (stdio.nowMs() >= deadline) {
133
+ if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, kind, &[_]selector.Selector{wanted}, timeout_ms);
134
+ return false;
135
+ }
136
+ try sleepMs(options.poll_ms);
137
+ continue;
121
138
  }
122
- try sleepMs(options.poll_ms);
123
- continue;
139
+ } else if (hasNativeSelectorQuery(device)) {
140
+ if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, kind, &[_]selector.Selector{wanted}, timeout_ms);
141
+ return false;
124
142
  }
143
+
125
144
  var snap = device.snapshot(writer) catch |err| {
126
145
  if (try retryTransientObservation(err, kind, writer, deadline, options)) continue;
127
146
  return err;
@@ -161,7 +180,19 @@ pub fn waitUntilAnyVisible(
161
180
  while (true) {
162
181
  var all_native = true;
163
182
  for (selectors, 0..) |wanted, index| {
164
- if (try nativeVisibleBySelector(device, wanted)) |visible| {
183
+ const query_timeout_ms = nativeSelectorQueryTimeoutMs(deadline) orelse {
184
+ if (hasNativeSelectorQuery(device)) {
185
+ if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, "wait.any", selectors, timeout_ms);
186
+ return null;
187
+ }
188
+ all_native = false;
189
+ break;
190
+ };
191
+ const native_result = nativeVisibleBySelector(device, wanted, query_timeout_ms) catch |err| {
192
+ if (try retryTransientObservation(err, "wait.any", writer, deadline, options)) continue;
193
+ return err;
194
+ };
195
+ if (native_result) |visible| {
165
196
  if (visible) {
166
197
  if (writer) |tw| try runner_events.recordNativeWait(tw, "wait.any", wanted, index, timeout_ms);
167
198
  return index;
@@ -171,6 +202,7 @@ pub fn waitUntilAnyVisible(
171
202
  break;
172
203
  }
173
204
  }
205
+
174
206
  if (all_native) {
175
207
  if (stdio.nowMs() >= deadline) {
176
208
  if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, "wait.any", selectors, timeout_ms);
@@ -179,6 +211,7 @@ pub fn waitUntilAnyVisible(
179
211
  try sleepMs(options.poll_ms);
180
212
  continue;
181
213
  }
214
+
182
215
  var snap = device.snapshot(writer) catch |err| {
183
216
  if (try retryTransientObservation(err, "wait.any", writer, deadline, options)) continue;
184
217
  return err;
@@ -285,11 +318,12 @@ fn nativeAssertHealthy(
285
318
  deadline: i64,
286
319
  options: RunOptions,
287
320
  ) !?bool {
288
- if (!@hasDecl(@TypeOf(device.*), "visibleBySelector")) return null;
321
+ if (!hasNativeSelectorQuery(device)) return null;
289
322
 
290
323
  probe: while (true) {
291
324
  for (health_selectors, 0..) |wanted, index| {
292
- const result = device.visibleBySelector(wanted) catch |err| {
325
+ const query_timeout_ms = nativeSelectorQueryTimeoutMs(deadline) orelse return false;
326
+ const result = nativeVisibleBySelector(device, wanted, query_timeout_ms) catch |err| {
293
327
  if (try retryTransientObservation(err, "assert.healthy", writer, deadline, options)) continue :probe;
294
328
  return err;
295
329
  };
@@ -369,11 +403,22 @@ pub fn scrollUntilVisible(
369
403
  }
370
404
  }
371
405
 
372
- fn nativeVisibleBySelector(device: anytype, wanted: selector.Selector) !?bool {
406
+ fn hasNativeSelectorQuery(device: anytype) bool {
407
+ return @hasDecl(@TypeOf(device.*), "visibleBySelectorWithTimeout") or @hasDecl(@TypeOf(device.*), "visibleBySelector");
408
+ }
409
+
410
+ fn nativeVisibleBySelector(device: anytype, wanted: selector.Selector, timeout_ms: u64) !?bool {
411
+ if (@hasDecl(@TypeOf(device.*), "visibleBySelectorWithTimeout")) return try device.visibleBySelectorWithTimeout(wanted, timeout_ms);
373
412
  if (!@hasDecl(@TypeOf(device.*), "visibleBySelector")) return null;
374
413
  return try device.visibleBySelector(wanted);
375
414
  }
376
415
 
416
+ fn nativeSelectorQueryTimeoutMs(deadline: i64) ?u64 {
417
+ const now = stdio.nowMs();
418
+ if (now >= deadline) return null;
419
+ return @as(u64, @intCast(deadline - now));
420
+ }
421
+
377
422
  fn retryTransientObservation(
378
423
  err: anyerror,
379
424
  kind: []const u8,
package/src/stdio.zig CHANGED
@@ -1,3 +1,4 @@
1
+ const builtin = @import("builtin");
1
2
  const std = @import("std");
2
3
 
3
4
  const default_buffer_size = 8192;
@@ -6,6 +7,7 @@ var process_threaded: ?std.Io.Threaded = null;
6
7
 
7
8
  fn processIo() std.Io {
8
9
  if (process_threaded) |*threaded| return threaded.io();
10
+ if (builtin.is_test) return std.testing.io;
9
11
  return std.Io.Threaded.global_single_threaded.io();
10
12
  }
11
13
 
@@ -46,7 +48,12 @@ pub fn nowMs() i64 {
46
48
  }
47
49
 
48
50
  pub fn getenv(name: []const u8) ?[]const u8 {
49
- const environ = process_environ orelse return null;
51
+ const environ = process_environ orelse {
52
+ if (builtin.is_test) {
53
+ return std.process.Environ.getPosix(std.testing.environ, name);
54
+ }
55
+ return null;
56
+ };
50
57
  const block = environ.block;
51
58
  const Block = @TypeOf(block);
52
59
  if (Block != std.process.Environ.PosixBlock) return null;
@@ -86,7 +93,15 @@ pub const Output = struct {
86
93
  }
87
94
 
88
95
  pub fn flush(self: *Output) !void {
89
- if (self.initialized) try self.file_writer.interface.flush();
96
+ if (!self.initialized) return;
97
+ self.file_writer.interface.flush() catch |err| switch (err) {
98
+ error.WriteFailed => {
99
+ if (self.file_writer.err) |actual| return actual;
100
+ if (self.file_writer.write_file_err) |actual| return actual;
101
+ return err;
102
+ },
103
+ else => |actual| return actual,
104
+ };
90
105
  }
91
106
 
92
107
  pub fn deinit(self: *Output) void {
@@ -0,0 +1,60 @@
1
+ const std = @import("std");
2
+ const stdio = @import("stdio.zig");
3
+
4
+ pub fn cwd() Cwd {
5
+ return .{};
6
+ }
7
+
8
+ pub const Cwd = struct {
9
+ pub fn makePath(_: Cwd, path: []const u8) !void {
10
+ return std.Io.Dir.cwd().createDirPath(stdio.io(), path);
11
+ }
12
+
13
+ pub fn writeFile(_: Cwd, options: std.Io.Dir.WriteFileOptions) !void {
14
+ return std.Io.Dir.cwd().writeFile(stdio.io(), options);
15
+ }
16
+
17
+ pub fn readFileAlloc(_: Cwd, allocator: std.mem.Allocator, path: []const u8, limit: usize) ![]u8 {
18
+ return std.Io.Dir.cwd().readFileAlloc(stdio.io(), path, allocator, .limited(limit));
19
+ }
20
+
21
+ pub fn createFile(_: Cwd, path: []const u8, flags: std.Io.Dir.CreateFileOptions) !File {
22
+ return createFileIn(std.Io.Dir.cwd(), path, flags);
23
+ }
24
+
25
+ pub fn deleteTree(_: Cwd, path: []const u8) !void {
26
+ return std.Io.Dir.cwd().deleteTree(stdio.io(), path);
27
+ }
28
+
29
+ pub fn deleteFile(_: Cwd, path: []const u8) !void {
30
+ return std.Io.Dir.cwd().deleteFile(stdio.io(), path);
31
+ }
32
+
33
+ pub fn access(_: Cwd, path: []const u8, options: std.Io.Dir.AccessOptions) !void {
34
+ return std.Io.Dir.cwd().access(stdio.io(), path, options);
35
+ }
36
+ };
37
+
38
+ pub const File = struct {
39
+ inner: std.Io.File,
40
+
41
+ pub fn writeAll(self: *File, bytes: []const u8) !void {
42
+ return std.Io.File.writeStreamingAll(self.inner, stdio.io(), bytes);
43
+ }
44
+
45
+ pub fn chmod(self: *File, mode: std.posix.mode_t) !void {
46
+ return self.inner.setPermissions(stdio.io(), .fromMode(mode));
47
+ }
48
+
49
+ pub fn close(self: *File) void {
50
+ self.inner.close(stdio.io());
51
+ }
52
+ };
53
+
54
+ pub fn createFileIn(dir: std.Io.Dir, path: []const u8, flags: std.Io.Dir.CreateFileOptions) !File {
55
+ return .{ .inner = try dir.createFile(stdio.io(), path, flags) };
56
+ }
57
+
58
+ pub fn readFileAllocIn(dir: std.Io.Dir, allocator: std.mem.Allocator, path: []const u8, limit: usize) ![]u8 {
59
+ return dir.readFileAlloc(stdio.io(), path, allocator, .limited(limit));
60
+ }
package/src/version.zig CHANGED
@@ -1,4 +1,4 @@
1
- pub const runner_version = "0.2.7";
1
+ pub const runner_version = "0.2.8";
2
2
  pub const protocol_version = "2026-04-28";
3
3
  pub const protocol_min_compatible_version = "2026-04-28";
4
4
  pub const protocol_stability = "dev-preview";