zeno-mobile-runner 0.2.11 → 0.2.13

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,35 @@ All notable changes to Zeno Mobile Runner are tracked here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.2.13 (2026-06-23)
8
+
9
+ ### Fixed
10
+
11
+ - iOS native selector waits now cap each XCTest query to the action timeout,
12
+ retry one transient native command failure, and then fall back to semantic
13
+ snapshots with diagnostics. This prevents one stuck XCTest selector query from
14
+ consuming the whole wait budget while still allowing transient native queries
15
+ to recover.
16
+ - iOS native selector scrolling now reads the app-frame viewport from the XCTest
17
+ shim before generating swipe coordinates, so native scrolls use iOS point
18
+ dimensions instead of Android fallback dimensions.
19
+ - Expo dev-client URL opening now uses URL-aware fallback handling and avoids
20
+ broad static-text enumeration while accepting deep-link chooser prompts.
21
+
22
+ ## 0.2.12 (2026-06-22)
23
+
24
+ ### Fixed
25
+
26
+ - iOS XCTest-shim command failures now classify generated shim timeout and
27
+ server-exit stderr into typed ZMR errors, so traces and CLI output distinguish
28
+ response timeouts, server-start timeouts, build timeouts, and server exits from
29
+ generic device command failures.
30
+ - iOS native selector actions and semantic snapshot extraction now emit
31
+ `started` trace events before entering XCTest, making long-running simulator
32
+ commands visible while they are in flight.
33
+ - The generated app-local iOS shim removes completed request files together with
34
+ response files, preventing stale request buildup during long E2E sessions.
35
+
7
36
  ## 0.2.11 (2026-06-22)
8
37
 
9
38
  ### Fixed
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.11`, a public developer preview rather than
145
+ - Current release status is `0.2.13`, 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
@@ -196,8 +196,10 @@ comparisons against your current E2E tool, and multi-device matrices, see
196
196
  | iOS physical device | Supported, validate locally | `devicectl` lifecycle plus XCTest shim; pilot on your own app/device before relying on it in CI |
197
197
  | Cloud device farms | Not included | ZMR focuses on local and self-managed device targets in this preview |
198
198
 
199
- Slow CI hardware can extend the iOS shim cold-build timeout with
200
- `ZMR_IOS_SHIM_TIMEOUT_MS`. Current release: `0.2.11` developer preview.
199
+ Slow CI hardware can extend the generated iOS shim build timeout with
200
+ `ZMR_IOS_SHIM_BUILD_TIMEOUT_SECONDS`; `ZMR_IOS_SHIM_RESPONSE_TIMEOUT_SECONDS`
201
+ bounds each in-flight request, and `ZMR_IOS_SHIM_TIMEOUT_MS` remains the outer
202
+ process ceiling. Current release: `0.2.13` developer preview.
201
203
  Protocol version: `2026-04-28`.
202
204
 
203
205
  ## 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.11.jar"))
30
+ implementation(files("path/to/zeno-mobile-runner/clients/kotlin/build/libs/zmr-client-0.2.13.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.11"
7
+ version = "0.2.13"
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.11.dev1"
7
+ version = "0.2.13.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.11"
103
+ version = "0.2.13"
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.11"
3
+ version = "0.2.13"
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.11",
3
+ "version": "0.2.13",
4
4
  "type": "module",
5
5
  "main": "index.mjs",
6
6
  "types": "index.d.ts",
@@ -1,4 +1,4 @@
1
- {"jsonrpc":"2.0","id":1,"result":{"name":"zmr","version":"0.2.11","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.13","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.11`.
5
+ Current runner version: `0.2.13`.
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.11","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.13","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.11","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.13","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.11","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.13","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.11","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.13","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.11","protocolVersion":"2026-04-28","minimumCompatibleProtocolVersion":"2026-04-28","stability":"dev-preview","breakingChangePolicy":"version-and-changelog"}
217
+ {"name":"zmr","version":"0.2.13","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.11","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.13","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.11","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.13","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.11","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.13","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.11","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.13","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`.
@@ -188,11 +188,13 @@ app targets. Pass `--project` explicitly for still-ambiguous multi-project
188
188
  workspaces. Run with `--ios-shim ./.zmr/ios-shim` or set
189
189
  `tools.iosShimPath` in `.zmr/config.json`.
190
190
 
191
- A clean prebuild can push the shim's first `build-for-testing` through a full
192
- native dependency compile. ZMR waits up to 90 minutes by default; on slower CI
193
- hardware, raise the ceiling with the `ZMR_IOS_SHIM_TIMEOUT_MS` environment
194
- variable (milliseconds), for example `ZMR_IOS_SHIM_TIMEOUT_MS=10800000` for
195
- three hours.
191
+ A clean prebuild can push the generated shim's first `build-for-testing` through
192
+ a full native dependency compile. The app-local shim waits up to 90 minutes by
193
+ default; on slower CI hardware, raise the build ceiling with
194
+ `ZMR_IOS_SHIM_BUILD_TIMEOUT_SECONDS=10800` for three hours. Individual shim
195
+ requests are bounded separately by `ZMR_IOS_SHIM_RESPONSE_TIMEOUT_SECONDS`
196
+ (default 180 seconds), while `ZMR_IOS_SHIM_TIMEOUT_MS` remains the outer ZMR
197
+ process ceiling for the whole shim command.
196
198
 
197
199
  If a real iOS run fails with CoreSimulator or Xcode cache errors such as
198
200
  `Operation not permitted`, `CoreSimulatorService connection became invalid`, or
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zeno-mobile-runner",
3
- "version": "0.2.11",
3
+ "version": "0.2.13",
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
@@ -456,7 +456,7 @@ send_request() {
456
456
  if [[ -f "\$response_file" ]]; then
457
457
  cat "\$response_file"
458
458
  printf '\\n'
459
- rm -f "\$response_file"
459
+ rm -f "\$request_file" "\$response_file"
460
460
  return 0
461
461
  fi
462
462
  if ! is_server_running; then
@@ -5,6 +5,8 @@ struct ZMRShimCommand: Decodable {
5
5
  let cmd: String
6
6
  let selector: String?
7
7
  let text: String?
8
+ let url: String?
9
+ let expoDevClientFallback: Bool?
8
10
  let x: Int?
9
11
  let y: Int?
10
12
  let x1: Int?
@@ -116,6 +116,11 @@ final class ZMRShimUITestCase: XCTestCase {
116
116
  "viewport": ZMRShim.viewport(app: app).json,
117
117
  "nodes": ZMRShim.snapshot(app: app).map { $0.json }
118
118
  ]
119
+ case "viewport":
120
+ return [
121
+ "status": "ok",
122
+ "viewport": ZMRShim.viewport(app: app).json
123
+ ]
119
124
  case "screenshot":
120
125
  let screenshot = XCUIScreen.main.screenshot()
121
126
  return [
@@ -187,7 +192,12 @@ final class ZMRShimUITestCase: XCTestCase {
187
192
  case "appState":
188
193
  return ["status": "ok", "state": app.state.rawValue]
189
194
  case "acceptSystemAlert":
190
- return acceptSystemAlert(buttonText: command.text ?? "Open", app: app)
195
+ return acceptSystemAlert(
196
+ buttonText: command.text ?? "Open",
197
+ openedURL: command.url,
198
+ expoDevClientFallback: command.expoDevClientFallback ?? false,
199
+ app: app
200
+ )
191
201
  default:
192
202
  return error("unknown.command", "unsupported command: \(command.cmd)")
193
203
  }
@@ -195,7 +205,7 @@ final class ZMRShimUITestCase: XCTestCase {
195
205
 
196
206
  private func commandRequiresForeground(_ command: ZMRShimCommand) -> Bool {
197
207
  switch command.cmd {
198
- case "snapshot", "query", "tap", "type", "eraseText", "hideKeyboard", "swipe", "settle":
208
+ case "snapshot", "viewport", "query", "tap", "type", "eraseText", "hideKeyboard", "swipe", "settle":
199
209
  return true
200
210
  default:
201
211
  return false
@@ -229,7 +239,12 @@ final class ZMRShimUITestCase: XCTestCase {
229
239
  ["status": "error", "code": code, "message": message]
230
240
  }
231
241
 
232
- private func acceptSystemAlert(buttonText: String, app: XCUIApplication) -> [String: Any] {
242
+ private func acceptSystemAlert(
243
+ buttonText: String,
244
+ openedURL: String?,
245
+ expoDevClientFallback: Bool,
246
+ app: XCUIApplication
247
+ ) -> [String: Any] {
233
248
  let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
234
249
  var labels = [buttonText, "Open", "Allow", "OK", "Continue"]
235
250
  labels = labels.reduce(into: [String]()) { unique, label in
@@ -264,7 +279,11 @@ final class ZMRShimUITestCase: XCTestCase {
264
279
  }
265
280
  }
266
281
 
267
- let expoDeepLinkSelection = acceptExpoDevClientDeepLink(app: app)
282
+ let expoDeepLinkSelection = acceptExpoDevClientDeepLink(
283
+ openedURL: openedURL,
284
+ expoDevClientFallback: expoDevClientFallback,
285
+ app: app
286
+ )
268
287
  if expoDeepLinkSelection.accepted {
269
288
  acceptedCount += 1
270
289
  lastAcceptedLabel = expoDeepLinkSelection.label
@@ -282,27 +301,38 @@ final class ZMRShimUITestCase: XCTestCase {
282
301
  return ["status": "ok", "accepted": false, "count": 0]
283
302
  }
284
303
 
285
- private func acceptExpoDevClientDeepLink(app: XCUIApplication) -> (accepted: Bool, label: String) {
286
- guard app.staticTexts["Deep link received:"].waitForExistence(timeout: 1) else {
287
- return (false, "")
288
- }
289
-
290
- let candidateQueries = [
291
- app.buttons.allElementsBoundByIndex,
292
- app.cells.allElementsBoundByIndex,
293
- app.staticTexts.allElementsBoundByIndex
294
- ]
304
+ private func acceptExpoDevClientDeepLink(
305
+ openedURL: String?,
306
+ expoDevClientFallback: Bool,
307
+ app: XCUIApplication
308
+ ) -> (accepted: Bool, label: String) {
309
+ let predicate = NSPredicate(
310
+ format: "label != '' AND label != %@ AND label != %@ AND label != %@ AND NOT label CONTAINS[c] %@ AND NOT label BEGINSWITH[c] %@ AND NOT label CONTAINS[c] %@",
311
+ "Deep link received:",
312
+ "Select an app to open it:",
313
+ "Go back",
314
+ "://",
315
+ "Note:",
316
+ "next app you open"
317
+ )
295
318
 
296
- for elements in candidateQueries {
297
- for element in elements {
298
- let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
299
- guard isExpoDevClientDeepLinkTarget(label: label), element.isHittable else {
300
- continue
301
- }
319
+ if app.staticTexts["Deep link received:"].waitForExistence(timeout: 1) {
320
+ if tapFirstMatchingExpoCandidate(
321
+ queries: [app.buttons, app.cells, app.staticTexts],
322
+ predicate: predicate
323
+ ) {
324
+ return (true, "expo-dev-client-deep-link")
325
+ }
326
+ }
302
327
 
303
- element.tap()
304
- Thread.sleep(forTimeInterval: 1.0)
305
- return (true, label)
328
+ if expoDevClientFallback,
329
+ isCustomSchemeURL(openedURL),
330
+ !isExpoDevClientURL(openedURL) {
331
+ if tapExpoDevClientDeepLinkCoordinateFallback(app: app) {
332
+ return (true, "expo-dev-client-deep-link-coordinate")
333
+ }
334
+ if tapExpoDevClientDeepLinkCandidateFallback(app: app, predicate: predicate) {
335
+ return (true, "expo-dev-client-deep-link-candidate")
306
336
  }
307
337
  }
308
338
 
@@ -335,23 +365,12 @@ final class ZMRShimUITestCase: XCTestCase {
335
365
  return (false, "")
336
366
  }
337
367
 
338
- let candidateQueries = [
339
- app.buttons.allElementsBoundByIndex,
340
- app.cells.allElementsBoundByIndex,
341
- app.staticTexts.allElementsBoundByIndex
342
- ]
343
-
344
- for elements in candidateQueries {
345
- for element in elements {
346
- let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
347
- guard isExpoDevClientProjectTarget(label: label), element.exists else {
348
- continue
349
- }
350
-
351
- element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
352
- Thread.sleep(forTimeInterval: 1.0)
353
- return (true, label)
354
- }
368
+ let predicate = NSPredicate(format: "label CONTAINS[c] %@ OR label CONTAINS[c] %@", " http://", " https://")
369
+ if tapFirstMatchingExpoCandidate(
370
+ queries: [app.buttons, app.cells, app.staticTexts],
371
+ predicate: predicate
372
+ ) {
373
+ return (true, "expo-dev-client-home")
355
374
  }
356
375
 
357
376
  return (false, "")
@@ -379,6 +398,59 @@ final class ZMRShimUITestCase: XCTestCase {
379
398
  return label.contains(" http://") || label.contains(" https://")
380
399
  }
381
400
 
401
+ private func tapFirstMatchingExpoCandidate(
402
+ queries: [XCUIElementQuery],
403
+ predicate: NSPredicate
404
+ ) -> Bool {
405
+ for query in queries {
406
+ let matching = query.matching(predicate)
407
+ for candidateIndex in 0..<6 {
408
+ let element = matching.element(boundBy: candidateIndex)
409
+ guard element.exists else {
410
+ break
411
+ }
412
+
413
+ guard element.isHittable else {
414
+ continue
415
+ }
416
+
417
+ element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
418
+ Thread.sleep(forTimeInterval: 1.0)
419
+ return true
420
+ }
421
+ }
422
+
423
+ return false
424
+ }
425
+
426
+ private func isCustomSchemeURL(_ value: String?) -> Bool {
427
+ guard let value else {
428
+ return false
429
+ }
430
+ return value.contains("://") && !value.hasPrefix("http://") && !value.hasPrefix("https://")
431
+ }
432
+
433
+ private func isExpoDevClientURL(_ value: String?) -> Bool {
434
+ guard let value else {
435
+ return false
436
+ }
437
+ return value.hasPrefix("exp+") && value.contains("://expo-development-client/")
438
+ }
439
+
440
+ private func tapExpoDevClientDeepLinkCoordinateFallback(app: XCUIApplication) -> Bool {
441
+ Thread.sleep(forTimeInterval: 1.5)
442
+ app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.6)).tap()
443
+ Thread.sleep(forTimeInterval: 1.0)
444
+ return true
445
+ }
446
+
447
+ private func tapExpoDevClientDeepLinkCandidateFallback(app: XCUIApplication, predicate: NSPredicate) -> Bool {
448
+ tapFirstMatchingExpoCandidate(
449
+ queries: [app.buttons, app.cells, app.staticTexts],
450
+ predicate: predicate
451
+ )
452
+ }
453
+
382
454
  private func hideKeyboard(app: XCUIApplication) -> [String: Any] {
383
455
  guard app.keyboards.firstMatch.exists else {
384
456
  return ok()
@@ -6,6 +6,7 @@ Commands are newline-delimited JSON objects:
6
6
 
7
7
  ```json
8
8
  {"cmd":"snapshot"}
9
+ {"cmd":"viewport"}
9
10
  {"cmd":"tap","selector":"text=Continue","x":20,"y":40}
10
11
  {"cmd":"type","selector":"identifier=email","text":"hello"}
11
12
  {"cmd":"eraseText","selector":"identifier=email","maxChars":20}
@@ -43,6 +44,13 @@ that XCTest can evaluate natively. It returns:
43
44
  {"status":"ok","format":"png","base64":"..."}
44
45
  ```
45
46
 
47
+ `viewport` returns the target application frame in XCTest point coordinates
48
+ without crawling the element hierarchy:
49
+
50
+ ```json
51
+ {"status":"ok","viewport":{"width":390,"height":844}}
52
+ ```
53
+
46
54
  Snapshot responses return bounded XCTest element data in a shape Zig can map
47
55
  into `UiNode`. The shim captures common interactive and readable element
48
56
  families and caps the response at 256 nodes so large application trees do not
package/src/errors.zig CHANGED
@@ -58,7 +58,12 @@ pub fn classify(err: anyerror) PublicError {
58
58
  error.AssertionFailed => .{ .code = "runner.assertion_failed", .message = "assertion failed" },
59
59
  error.SelectorNotFound => .{ .code = "runner.selector_not_found", .message = "selector not found" },
60
60
  error.CommandFailed => .{ .code = "device.command_failed", .message = "device command failed" },
61
+ error.CommandTimedOut => .{ .code = "device.command_timed_out", .message = "device command timed out" },
61
62
  error.IosXCTestShimRequired => .{ .code = "ios.xctest_shim_required", .message = "iOS selector interaction requires the XCTest shim" },
63
+ error.IosXCTestShimResponseTimedOut => .{ .code = "ios.xctest_shim_response_timeout", .message = "iOS XCTest shim response timed out" },
64
+ error.IosXCTestShimStartTimedOut => .{ .code = "ios.xctest_shim_start_timeout", .message = "iOS XCTest shim server startup timed out" },
65
+ error.IosXCTestShimBuildTimedOut => .{ .code = "ios.xctest_shim_build_timeout", .message = "iOS XCTest shim build timed out" },
66
+ error.IosXCTestShimServerExited => .{ .code = "ios.xctest_shim_server_exited", .message = "iOS XCTest shim server exited" },
62
67
  else => .{ .code = "internal.error", .message = @errorName(err) },
63
68
  };
64
69
  }
package/src/ios.zig CHANGED
@@ -33,6 +33,7 @@ pub const IosDevice = struct {
33
33
  app_id: []const u8,
34
34
  shim_path: ?[]const u8 = null,
35
35
  target_kind: TargetKind = .simulator,
36
+ expo_dev_client_open_link_mode: bool = false,
36
37
 
37
38
  pub fn init(
38
39
  allocator: std.mem.Allocator,
@@ -131,13 +132,16 @@ pub const IosDevice = struct {
131
132
  const result = try self.runSimctl(&.{ "openurl", self.target(), url }, default_max_output);
132
133
  defer result.deinit(self.allocator);
133
134
  try result.ensureSuccess();
135
+ if (isExpoDevClientOpenLink(url)) {
136
+ self.expo_dev_client_open_link_mode = true;
137
+ }
134
138
  // Opening a URL on the simulator can raise a SpringBoard "Open in <App>?"
135
139
  // confirmation for universal links (http/https) and, just as often, for
136
140
  // custom schemes — the common Expo dev-client case
137
141
  // (exp+scheme://expo-development-client/...). Attempt a best-effort accept
138
142
  // whenever a shim is configured; the shim probes briefly and returns fast
139
143
  // when no dialog is present, so this stays cheap on the no-prompt path.
140
- self.acceptOpenURLConfirmationBestEffort();
144
+ self.acceptOpenURLConfirmationBestEffort(url);
141
145
  }
142
146
 
143
147
  pub fn tap(self: *IosDevice, x: i32, y: i32) !void {
@@ -189,6 +193,12 @@ pub const IosDevice = struct {
189
193
  try self.runShimAction(.{ .kind = .swipe, .x1 = x1, .y1 = y1, .x2 = x2, .y2 = y2, .duration_ms = duration_ms });
190
194
  }
191
195
 
196
+ pub fn scrollViewport(self: *IosDevice) !types.Viewport {
197
+ const response = try self.runShim(.{ .kind = .viewport });
198
+ defer self.allocator.free(response);
199
+ return try ios_shim.parseViewportResponse(response);
200
+ }
201
+
192
202
  pub fn pressBack(self: *IosDevice) !void {
193
203
  try self.runShimAction(.{ .kind = .press_back });
194
204
  }
@@ -232,6 +242,7 @@ pub const IosDevice = struct {
232
242
  errdefer self.allocator.free(active_package);
233
243
  var shim_viewport: ?types.Viewport = null;
234
244
  const nodes = if (self.shim_path != null) blk: {
245
+ if (writer) |tw| try self.recordSnapshotSemanticStarted(tw);
235
246
  const shim_snapshot = self.snapshotFromShim() catch |err| {
236
247
  if (screenshot_artifact == null) return err;
237
248
  if (writer) |tw| try self.recordSnapshotSemanticFailure(tw, screenshot_artifact.?, err);
@@ -291,6 +302,11 @@ pub const IosDevice = struct {
291
302
  return try ios_shim.parseSnapshotResponse(self.allocator, response);
292
303
  }
293
304
 
305
+ fn recordSnapshotSemanticStarted(self: *IosDevice, writer: *trace.TraceWriter) !void {
306
+ try writer.recordEvent("observe.snapshot.semanticExtraction", "{\"status\":\"started\",\"source\":\"ios-xctest-shim\"}");
307
+ _ = self;
308
+ }
309
+
294
310
  fn recordSnapshotSemanticFailure(self: *IosDevice, writer: *trace.TraceWriter, screenshot_artifact: []const u8, err: anyerror) !void {
295
311
  var payload: std.Io.Writer.Allocating = .init(writer.allocator);
296
312
  defer payload.deinit();
@@ -316,20 +332,25 @@ pub const IosDevice = struct {
316
332
  try ios_shim.parseOkResponse(response);
317
333
  }
318
334
 
319
- fn acceptOpenURLConfirmationBestEffort(self: *IosDevice) void {
335
+ fn acceptOpenURLConfirmationBestEffort(self: *IosDevice, url: []const u8) void {
320
336
  if (self.shim_path == null) return;
321
337
  var attempt: usize = 0;
322
338
  while (attempt < open_link_interruption_attempts) {
323
339
  attempt += 1;
324
- if (self.acceptOpenURLConfirmationOnce() catch return) return;
340
+ if (self.acceptOpenURLConfirmationOnce(url) catch return) return;
325
341
  if (attempt < open_link_interruption_attempts) {
326
342
  stdio.sleepNs(open_link_interruption_retry_delay_ms * std.time.ns_per_ms);
327
343
  }
328
344
  }
329
345
  }
330
346
 
331
- fn acceptOpenURLConfirmationOnce(self: *IosDevice) !bool {
332
- const response = try self.runShimWithTimeout(.{ .kind = .accept_system_alert, .text = "Open" }, shim_best_effort_timeout_ms);
347
+ fn acceptOpenURLConfirmationOnce(self: *IosDevice, url: []const u8) !bool {
348
+ const response = try self.runShimWithTimeout(.{
349
+ .kind = .accept_system_alert,
350
+ .text = "Open",
351
+ .url = url,
352
+ .expo_dev_client_fallback = self.expo_dev_client_open_link_mode,
353
+ }, shim_best_effort_timeout_ms);
333
354
  defer self.allocator.free(response);
334
355
  return try ios_shim.parseAcceptSystemAlertResponse(response);
335
356
  }
@@ -378,7 +399,7 @@ pub const IosDevice = struct {
378
399
  stdio.sleepNs(shim_bootstrap_retry_delay_ms * std.time.ns_per_ms);
379
400
  continue;
380
401
  }
381
- return err;
402
+ return classifyShimCommandFailure(result);
382
403
  };
383
404
  return try self.allocator.dupe(u8, result.stdout);
384
405
  }
@@ -422,6 +443,27 @@ fn isTransientShimBootstrapFailure(result: command.ExecResult) bool {
422
443
  std.mem.indexOf(u8, result.stderr, "operation never finished bootstrapping") != null;
423
444
  }
424
445
 
446
+ fn classifyShimCommandFailure(result: command.ExecResult) anyerror {
447
+ if (result.timed_out) return error.CommandTimedOut;
448
+ if (std.mem.indexOf(u8, result.stderr, "timed out waiting for iOS shim response") != null) {
449
+ return error.IosXCTestShimResponseTimedOut;
450
+ }
451
+ if (std.mem.indexOf(u8, result.stderr, "timed out waiting for iOS shim server readiness") != null) {
452
+ return error.IosXCTestShimStartTimedOut;
453
+ }
454
+ if (std.mem.indexOf(u8, result.stderr, "timed out waiting for iOS shim build-for-testing") != null) {
455
+ return error.IosXCTestShimBuildTimedOut;
456
+ }
457
+ if (std.mem.indexOf(u8, result.stderr, "iOS shim server exited before it became ready") != null or
458
+ std.mem.indexOf(u8, result.stderr, "iOS shim server exited while waiting for response") != null or
459
+ std.mem.indexOf(u8, result.stderr, "Early unexpected exit") != null or
460
+ std.mem.indexOf(u8, result.stderr, "operation never finished bootstrapping") != null)
461
+ {
462
+ return error.IosXCTestShimServerExited;
463
+ }
464
+ return error.CommandFailed;
465
+ }
466
+
425
467
  fn shimTimeoutMs() u64 {
426
468
  return parseShimTimeoutMs(stdio.getenv(shim_timeout_env));
427
469
  }
@@ -433,6 +475,11 @@ fn parseShimTimeoutMs(raw: ?[]const u8) u64 {
433
475
  return parsed;
434
476
  }
435
477
 
478
+ fn isExpoDevClientOpenLink(url: []const u8) bool {
479
+ return std.mem.startsWith(u8, url, "exp+") and
480
+ std.mem.indexOf(u8, url, "://expo-development-client/") != null;
481
+ }
482
+
436
483
  test "ios simulator openLink keeps sweeping delayed XCTest interruptions until accepted" {
437
484
  const allocator = std.heap.page_allocator;
438
485
  const argv = [_][*:0]const u8{"zmr-ios-test"};
@@ -523,3 +570,25 @@ test "ios xctest shim timeout env override" {
523
570
  try std.testing.expectEqual(@as(u64, default_shim_timeout_ms), parseShimTimeoutMs("not-a-number"));
524
571
  try std.testing.expectEqual(@as(u64, default_shim_timeout_ms), parseShimTimeoutMs("0"));
525
572
  }
573
+
574
+ test "ios xctest shim command failures classify generated shim timeout causes" {
575
+ try expectClassifiedShimFailure(error.IosXCTestShimResponseTimedOut, "timed out waiting for iOS shim response 12345\n");
576
+ try expectClassifiedShimFailure(error.IosXCTestShimStartTimedOut, "timed out waiting for iOS shim server readiness\n");
577
+ try expectClassifiedShimFailure(error.IosXCTestShimBuildTimedOut, "timed out waiting for iOS shim build-for-testing after 5400s\n");
578
+ try expectClassifiedShimFailure(error.IosXCTestShimServerExited, "iOS shim server exited while waiting for response 12345\n");
579
+ try expectClassifiedShimFailure(error.CommandFailed, "xcodebuild failed for another reason\n");
580
+ }
581
+
582
+ fn expectClassifiedShimFailure(expected: anyerror, stderr: []const u8) !void {
583
+ const allocator = std.testing.allocator;
584
+ const stdout_owned = try allocator.dupe(u8, "");
585
+ defer allocator.free(stdout_owned);
586
+ const stderr_owned = try allocator.dupe(u8, stderr);
587
+ defer allocator.free(stderr_owned);
588
+ const result = command.ExecResult{
589
+ .stdout = stdout_owned,
590
+ .stderr = stderr_owned,
591
+ .term = .{ .exited = 1 },
592
+ };
593
+ try std.testing.expectEqual(expected, classifyShimCommandFailure(result));
594
+ }
package/src/ios_shim.zig CHANGED
@@ -5,6 +5,7 @@ const types = @import("types.zig");
5
5
 
6
6
  pub const CommandKind = enum {
7
7
  snapshot,
8
+ viewport,
8
9
  screenshot,
9
10
  tap,
10
11
  type_text,
@@ -22,6 +23,8 @@ pub const Command = struct {
22
23
  kind: CommandKind,
23
24
  selector: ?[]const u8 = null,
24
25
  text: ?[]const u8 = null,
26
+ url: ?[]const u8 = null,
27
+ expo_dev_client_fallback: bool = false,
25
28
  x: ?i32 = null,
26
29
  y: ?i32 = null,
27
30
  x1: ?i32 = null,
@@ -42,6 +45,19 @@ pub const SnapshotResponse = struct {
42
45
  }
43
46
  };
44
47
 
48
+ pub fn parseViewportResponse(content: []const u8) !types.Viewport {
49
+ var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
50
+ defer arena.deinit();
51
+ const parsed = try std.json.parseFromSlice(std.json.Value, arena.allocator(), content, .{});
52
+ if (parsed.value != .object) return error.IosShimResponseMustBeObject;
53
+ const status = fieldString(parsed.value.object, "status") orelse return error.IosShimMissingStatus;
54
+ if (!std.mem.eql(u8, status, "ok")) return error.IosShimResponseNotOk;
55
+ const viewport_value = parsed.value.object.get("viewport") orelse return error.IosShimMissingViewport;
56
+ const viewport = parseViewport(viewport_value);
57
+ if (viewport.width == 0 or viewport.height == 0) return error.IosShimInvalidViewport;
58
+ return viewport;
59
+ }
60
+
45
61
  pub fn writeCommandJson(writer: anytype, command: Command) !void {
46
62
  try writer.writeAll("{\"cmd\":");
47
63
  try trace.writeJsonString(writer, commandName(command.kind));
@@ -53,6 +69,13 @@ pub fn writeCommandJson(writer: anytype, command: Command) !void {
53
69
  try writer.writeAll(",\"text\":");
54
70
  try trace.writeJsonString(writer, text);
55
71
  }
72
+ if (command.url) |url| {
73
+ try writer.writeAll(",\"url\":");
74
+ try trace.writeJsonString(writer, url);
75
+ }
76
+ if (command.expo_dev_client_fallback) {
77
+ try writer.writeAll(",\"expoDevClientFallback\":true");
78
+ }
56
79
  if (command.x) |value| try writer.print(",\"x\":{d}", .{value});
57
80
  if (command.y) |value| try writer.print(",\"y\":{d}", .{value});
58
81
  if (command.x1) |value| try writer.print(",\"x1\":{d}", .{value});
@@ -270,6 +293,7 @@ pub fn selectorString(allocator: std.mem.Allocator, wanted: selectors.Selector)
270
293
  fn commandName(kind: CommandKind) []const u8 {
271
294
  return switch (kind) {
272
295
  .snapshot => "snapshot",
296
+ .viewport => "viewport",
273
297
  .screenshot => "screenshot",
274
298
  .tap => "tap",
275
299
  .type_text => "type",
package/src/main.zig CHANGED
@@ -94,7 +94,10 @@ fn writeTopLevelError(err: anyerror) void {
94
94
  defer stderr_io.deinit();
95
95
  const stderr = stderr_io.writer();
96
96
  stderr.print("error[{s}]: {s}\n", .{ public.code, public.message }) catch {};
97
- if (err == error.CommandFailed) {
97
+ if (err == error.CommandFailed or err == error.CommandTimedOut or
98
+ err == error.IosXCTestShimResponseTimedOut or err == error.IosXCTestShimStartTimedOut or
99
+ err == error.IosXCTestShimBuildTimedOut or err == error.IosXCTestShimServerExited)
100
+ {
98
101
  stderr.writeAll("hint: run `zmr doctor --json` for setup diagnostics.\n") catch {};
99
102
  }
100
103
  if (err == error.unknownFlag) {
@@ -26,6 +26,23 @@ pub fn recordNativeWait(tw: *trace.TraceWriter, kind: []const u8, wanted: select
26
26
  try tw.recordEvent(kind, writer.buffered());
27
27
  }
28
28
 
29
+ pub fn recordNativeScrollUntilVisible(
30
+ tw: *trace.TraceWriter,
31
+ wanted: selector.Selector,
32
+ direction: []const u8,
33
+ timeout_ms: u64,
34
+ ) !void {
35
+ var payload: std.Io.Writer.Allocating = .init(tw.allocator);
36
+ defer payload.deinit();
37
+ const writer = &payload.writer;
38
+ try writer.writeAll("{\"status\":\"ok\",\"strategy\":\"nativeSelector\",\"selector\":");
39
+ try trace.writeSelectorJson(writer, wanted);
40
+ try writer.writeAll(",\"direction\":");
41
+ try trace.writeJsonString(writer, direction);
42
+ try writer.print(",\"timeoutMs\":{d}}}", .{timeout_ms});
43
+ try tw.recordEvent("ui.scrollUntilVisible", writer.buffered());
44
+ }
45
+
29
46
  pub fn recordNativeWaitTimeout(tw: *trace.TraceWriter, kind: []const u8, selectors: []const selector.Selector, timeout_ms: u64) !void {
30
47
  var payload: std.Io.Writer.Allocating = .init(tw.allocator);
31
48
  defer payload.deinit();
@@ -9,6 +9,7 @@ pub fn tryTapSelector(
9
9
  settle_ms: u64,
10
10
  ) !bool {
11
11
  if (!@hasDecl(@TypeOf(device.*), "tapBySelector")) return false;
12
+ if (writer) |tw| try recordSelectorActionStarted(tw, "ui.tap", wanted);
12
13
  const tapped = device.tapBySelector(wanted) catch |err| {
13
14
  if (writer) |tw| try recordSelectorActionFailure(tw, "ui.tap", wanted, err);
14
15
  return err;
@@ -27,6 +28,7 @@ pub fn tryTypeTextSelector(
27
28
  settle_ms: u64,
28
29
  ) !bool {
29
30
  if (!@hasDecl(@TypeOf(device.*), "typeTextBySelector")) return false;
31
+ if (writer) |tw| try recordSelectorActionStarted(tw, "ui.type", wanted);
30
32
  const typed = device.typeTextBySelector(wanted, text) catch |err| {
31
33
  if (writer) |tw| try recordSelectorActionFailure(tw, "ui.type", wanted, err);
32
34
  return err;
@@ -45,6 +47,7 @@ pub fn tryEraseTextSelector(
45
47
  settle_ms: u64,
46
48
  ) !bool {
47
49
  if (!@hasDecl(@TypeOf(device.*), "eraseTextBySelector")) return false;
50
+ if (writer) |tw| try recordSelectorActionStarted(tw, "ui.eraseText", wanted);
48
51
  const erased = device.eraseTextBySelector(wanted, max_chars) catch |err| {
49
52
  if (writer) |tw| try recordSelectorActionFailure(tw, "ui.eraseText", wanted, err);
50
53
  return err;
@@ -55,6 +58,20 @@ pub fn tryEraseTextSelector(
55
58
  return true;
56
59
  }
57
60
 
61
+ fn recordSelectorActionStarted(
62
+ tw: *trace.TraceWriter,
63
+ kind: []const u8,
64
+ wanted: selector.Selector,
65
+ ) !void {
66
+ var payload: std.Io.Writer.Allocating = .init(tw.allocator);
67
+ defer payload.deinit();
68
+ const writer = &payload.writer;
69
+ try writer.writeAll("{\"status\":\"started\",\"strategy\":\"nativeSelector\",\"selector\":");
70
+ try trace.writeSelectorJson(writer, wanted);
71
+ try writer.writeAll("}");
72
+ try tw.recordEvent(kind, writer.buffered());
73
+ }
74
+
58
75
  fn recordSelectorAction(
59
76
  tw: *trace.TraceWriter,
60
77
  kind: []const u8,
@@ -9,6 +9,7 @@ const trace = @import("trace.zig");
9
9
 
10
10
  const RunOptions = runner_config.RunOptions;
11
11
  const native_health_probe_timeout_ms: u64 = 1000;
12
+ const native_selector_transient_retry_limit: usize = 1;
12
13
 
13
14
  pub fn waitUntilVisible(
14
15
  device: anytype,
@@ -39,23 +40,36 @@ fn untilVisibleKind(
39
40
  kind: []const u8,
40
41
  ) !bool {
41
42
  const deadline = stdio.nowMs() + @as(i64, @intCast(timeout_ms));
43
+ var native_query_failures: usize = 0;
42
44
  while (true) {
43
- if (nativeSelectorQueryTimeoutMs(deadline)) |query_timeout_ms| {
44
- const native_result = nativeVisibleBySelector(device, wanted, query_timeout_ms) catch |err| {
45
- if (try retryTransientObservation(err, kind, writer, deadline, options)) continue;
45
+ if (nativeSelectorQueryTimeoutMs(deadline, options)) |query_timeout_ms| {
46
+ var native_query_failed = false;
47
+ const native_result = nativeVisibleBySelector(device, wanted, query_timeout_ms) catch |err| blk: {
48
+ if (try recordTransientNativeSelectorObservation(err, kind, writer)) {
49
+ native_query_failures += 1;
50
+ if (native_query_failures <= native_selector_transient_retry_limit and stdio.nowMs() < deadline) {
51
+ try sleepMs(options.poll_ms);
52
+ continue;
53
+ }
54
+ native_query_failed = true;
55
+ break :blk null;
56
+ }
46
57
  return err;
47
58
  };
48
- if (native_result) |visible| {
49
- if (visible) {
50
- if (writer) |tw| try runner_events.recordNativeWait(tw, kind, wanted, null, timeout_ms);
51
- return true;
52
- }
53
- if (stdio.nowMs() >= deadline) {
54
- if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, kind, &[_]selector.Selector{wanted}, timeout_ms);
55
- return false;
59
+ if (!native_query_failed) native_query_failures = 0;
60
+ if (!native_query_failed) {
61
+ if (native_result) |visible| {
62
+ if (visible) {
63
+ if (writer) |tw| try runner_events.recordNativeWait(tw, kind, wanted, null, timeout_ms);
64
+ return true;
65
+ }
66
+ if (stdio.nowMs() >= deadline) {
67
+ if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, kind, &[_]selector.Selector{wanted}, timeout_ms);
68
+ return false;
69
+ }
70
+ try sleepMs(options.poll_ms);
71
+ continue;
56
72
  }
57
- try sleepMs(options.poll_ms);
58
- continue;
59
73
  }
60
74
  } else if (hasNativeSelectorQuery(device)) {
61
75
  if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, kind, &[_]selector.Selector{wanted}, timeout_ms);
@@ -119,23 +133,36 @@ fn untilNotVisibleKind(
119
133
  kind: []const u8,
120
134
  ) !bool {
121
135
  const deadline = stdio.nowMs() + @as(i64, @intCast(timeout_ms));
136
+ var native_query_failures: usize = 0;
122
137
  while (true) {
123
- if (nativeSelectorQueryTimeoutMs(deadline)) |query_timeout_ms| {
124
- const native_result = nativeVisibleBySelector(device, wanted, query_timeout_ms) catch |err| {
125
- if (try retryTransientObservation(err, kind, writer, deadline, options)) continue;
138
+ if (nativeSelectorQueryTimeoutMs(deadline, options)) |query_timeout_ms| {
139
+ var native_query_failed = false;
140
+ const native_result = nativeVisibleBySelector(device, wanted, query_timeout_ms) catch |err| blk: {
141
+ if (try recordTransientNativeSelectorObservation(err, kind, writer)) {
142
+ native_query_failures += 1;
143
+ if (native_query_failures <= native_selector_transient_retry_limit and stdio.nowMs() < deadline) {
144
+ try sleepMs(options.poll_ms);
145
+ continue;
146
+ }
147
+ native_query_failed = true;
148
+ break :blk null;
149
+ }
126
150
  return err;
127
151
  };
128
- if (native_result) |visible| {
129
- if (!visible) {
130
- if (writer) |tw| try runner_events.recordNativeWait(tw, kind, wanted, null, timeout_ms);
131
- return true;
132
- }
133
- if (stdio.nowMs() >= deadline) {
134
- if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, kind, &[_]selector.Selector{wanted}, timeout_ms);
135
- return false;
152
+ if (!native_query_failed) native_query_failures = 0;
153
+ if (!native_query_failed) {
154
+ if (native_result) |visible| {
155
+ if (!visible) {
156
+ if (writer) |tw| try runner_events.recordNativeWait(tw, kind, wanted, null, timeout_ms);
157
+ return true;
158
+ }
159
+ if (stdio.nowMs() >= deadline) {
160
+ if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, kind, &[_]selector.Selector{wanted}, timeout_ms);
161
+ return false;
162
+ }
163
+ try sleepMs(options.poll_ms);
164
+ continue;
136
165
  }
137
- try sleepMs(options.poll_ms);
138
- continue;
139
166
  }
140
167
  } else if (hasNativeSelectorQuery(device)) {
141
168
  if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, kind, &[_]selector.Selector{wanted}, timeout_ms);
@@ -178,10 +205,11 @@ pub fn waitUntilAnyVisible(
178
205
  options: RunOptions,
179
206
  ) !?usize {
180
207
  const deadline = stdio.nowMs() + @as(i64, @intCast(timeout_ms));
181
- while (true) {
208
+ var native_query_failures: usize = 0;
209
+ native_poll: while (true) {
182
210
  var all_native = true;
183
211
  for (selectors, 0..) |wanted, index| {
184
- const query_timeout_ms = nativeSelectorQueryTimeoutMs(deadline) orelse {
212
+ const query_timeout_ms = nativeSelectorQueryTimeoutMs(deadline, options) orelse {
185
213
  if (hasNativeSelectorQuery(device)) {
186
214
  if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, "wait.any", selectors, timeout_ms);
187
215
  return null;
@@ -190,9 +218,18 @@ pub fn waitUntilAnyVisible(
190
218
  break;
191
219
  };
192
220
  const native_result = nativeVisibleBySelector(device, wanted, query_timeout_ms) catch |err| {
193
- if (try retryTransientObservation(err, "wait.any", writer, deadline, options)) continue;
221
+ if (try recordTransientNativeSelectorObservation(err, "wait.any", writer)) {
222
+ native_query_failures += 1;
223
+ if (native_query_failures <= native_selector_transient_retry_limit and stdio.nowMs() < deadline) {
224
+ try sleepMs(options.poll_ms);
225
+ continue :native_poll;
226
+ }
227
+ all_native = false;
228
+ break;
229
+ }
194
230
  return err;
195
231
  };
232
+ native_query_failures = 0;
196
233
  if (native_result) |visible| {
197
234
  if (visible) {
198
235
  if (writer) |tw| try runner_events.recordNativeWait(tw, "wait.any", wanted, index, timeout_ms);
@@ -322,7 +359,7 @@ fn nativeAssertHealthy(
322
359
 
323
360
  const deadline = stdio.nowMs() + @as(i64, @intCast(timeout_ms));
324
361
  native_probe: while (true) {
325
- const remaining_ms = nativeSelectorQueryTimeoutMs(deadline) orelse return null;
362
+ const remaining_ms = nativeSelectorRemainingTimeoutMs(deadline) orelse return null;
326
363
  const query_timeout_ms = @min(remaining_ms, nativeHealthProbeTimeoutMs(timeout_ms, options));
327
364
 
328
365
  for (health_selectors, 0..) |wanted, index| {
@@ -356,7 +393,47 @@ pub fn scrollUntilVisible(
356
393
  options: RunOptions,
357
394
  ) !bool {
358
395
  const deadline = stdio.nowMs() + @as(i64, @intCast(timeout_ms));
396
+ var native_query_failures: usize = 0;
359
397
  while (true) {
398
+ if (nativeSelectorQueryTimeoutMs(deadline, options)) |query_timeout_ms| {
399
+ var native_query_failed = false;
400
+ const native_result = nativeVisibleBySelector(device, wanted, query_timeout_ms) catch |err| blk: {
401
+ if (try recordTransientNativeSelectorObservation(err, "ui.scrollUntilVisible", writer)) {
402
+ native_query_failures += 1;
403
+ if (native_query_failures <= native_selector_transient_retry_limit and stdio.nowMs() < deadline) {
404
+ try sleepMs(options.poll_ms);
405
+ continue;
406
+ }
407
+ native_query_failed = true;
408
+ break :blk null;
409
+ }
410
+ return err;
411
+ };
412
+ if (!native_query_failed) native_query_failures = 0;
413
+ if (!native_query_failed) {
414
+ if (native_result) |visible| {
415
+ if (visible) {
416
+ if (writer) |tw| try runner_events.recordNativeScrollUntilVisible(
417
+ tw,
418
+ wanted,
419
+ if (direction == .down) "down" else "up",
420
+ timeout_ms,
421
+ );
422
+ return true;
423
+ }
424
+ if (stdio.nowMs() >= deadline) {
425
+ if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, "ui.scrollUntilVisible", &[_]selector.Selector{wanted}, timeout_ms);
426
+ return false;
427
+ }
428
+ try scrollDevice(device, direction, writer, options);
429
+ continue;
430
+ }
431
+ }
432
+ } else if (hasNativeSelectorQuery(device)) {
433
+ if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, "ui.scrollUntilVisible", &[_]selector.Selector{wanted}, timeout_ms);
434
+ return false;
435
+ }
436
+
360
437
  var snap = device.snapshot(writer) catch |err| {
361
438
  if (try retryTransientObservation(err, "ui.scrollUntilVisible", writer, deadline, options)) continue;
362
439
  return err;
@@ -385,29 +462,7 @@ pub fn scrollUntilVisible(
385
462
  return false;
386
463
  }
387
464
 
388
- const width = if (snap.viewport.width == 0) @as(i32, 720) else @as(i32, @intCast(snap.viewport.width));
389
- const height = if (snap.viewport.height == 0) @as(i32, 1280) else @as(i32, @intCast(snap.viewport.height));
390
- const x = @divTrunc(width, 2);
391
- const start_y = switch (direction) {
392
- .down => @divTrunc(height * 4, 5),
393
- .up => @divTrunc(height * 3, 10),
394
- };
395
- const end_y = switch (direction) {
396
- .down => @divTrunc(height * 3, 10),
397
- .up => @divTrunc(height * 4, 5),
398
- };
399
- try device.swipe(x, start_y, x, end_y, 350);
400
- if (writer) |tw| {
401
- const payload = try std.fmt.allocPrint(tw.allocator, "{{\"direction\":\"{s}\",\"x\":{d},\"y1\":{d},\"y2\":{d}}}", .{
402
- if (direction == .down) "down" else "up",
403
- x,
404
- start_y,
405
- end_y,
406
- });
407
- defer tw.allocator.free(payload);
408
- try tw.recordEvent("ui.scroll", payload);
409
- }
410
- try settleDevice(device, options);
465
+ try scrollDeviceWithViewport(device, direction, writer, options, snap.viewport);
411
466
  }
412
467
  }
413
468
 
@@ -421,7 +476,13 @@ fn nativeVisibleBySelector(device: anytype, wanted: selector.Selector, timeout_m
421
476
  return try device.visibleBySelector(wanted);
422
477
  }
423
478
 
424
- fn nativeSelectorQueryTimeoutMs(deadline: i64) ?u64 {
479
+ fn nativeSelectorQueryTimeoutMs(deadline: i64, options: RunOptions) ?u64 {
480
+ const remaining_ms = nativeSelectorRemainingTimeoutMs(deadline) orelse return null;
481
+ if (options.action_timeout_ms == 0) return remaining_ms;
482
+ return @max(@as(u64, 1), @min(remaining_ms, options.action_timeout_ms));
483
+ }
484
+
485
+ fn nativeSelectorRemainingTimeoutMs(deadline: i64) ?u64 {
425
486
  const now = stdio.nowMs();
426
487
  if (now >= deadline) return null;
427
488
  return @as(u64, @intCast(deadline - now));
@@ -434,6 +495,16 @@ fn nativeHealthProbeTimeoutMs(timeout_ms: u64, options: RunOptions) u64 {
434
495
  return @max(probe_timeout_ms, 1);
435
496
  }
436
497
 
498
+ fn recordTransientNativeSelectorObservation(
499
+ err: anyerror,
500
+ kind: []const u8,
501
+ writer: ?*trace.TraceWriter,
502
+ ) !bool {
503
+ if (err != error.CommandTimedOut and err != error.CommandFailed) return false;
504
+ if (writer) |tw| try runner_events.recordObservationRetry(tw, kind, err);
505
+ return true;
506
+ }
507
+
437
508
  fn retryTransientObservation(
438
509
  err: anyerror,
439
510
  kind: []const u8,
@@ -448,6 +519,54 @@ fn retryTransientObservation(
448
519
  return true;
449
520
  }
450
521
 
522
+ fn scrollDevice(
523
+ device: anytype,
524
+ direction: scenario.ScrollDirection,
525
+ writer: ?*trace.TraceWriter,
526
+ options: RunOptions,
527
+ ) !void {
528
+ try scrollDeviceWithViewport(device, direction, writer, options, try scrollViewport(device));
529
+ }
530
+
531
+ fn scrollViewport(device: anytype) !@import("types.zig").Viewport {
532
+ if (@hasDecl(@TypeOf(device.*), "scrollViewport")) {
533
+ return try device.scrollViewport();
534
+ }
535
+ return .{};
536
+ }
537
+
538
+ fn scrollDeviceWithViewport(
539
+ device: anytype,
540
+ direction: scenario.ScrollDirection,
541
+ writer: ?*trace.TraceWriter,
542
+ options: RunOptions,
543
+ viewport: @import("types.zig").Viewport,
544
+ ) !void {
545
+ const width = if (viewport.width == 0) @as(i32, 720) else @as(i32, @intCast(viewport.width));
546
+ const height = if (viewport.height == 0) @as(i32, 1280) else @as(i32, @intCast(viewport.height));
547
+ const x = @divTrunc(width, 2);
548
+ const start_y = switch (direction) {
549
+ .down => @divTrunc(height * 4, 5),
550
+ .up => @divTrunc(height * 3, 10),
551
+ };
552
+ const end_y = switch (direction) {
553
+ .down => @divTrunc(height * 3, 10),
554
+ .up => @divTrunc(height * 4, 5),
555
+ };
556
+ try device.swipe(x, start_y, x, end_y, 350);
557
+ if (writer) |tw| {
558
+ const payload = try std.fmt.allocPrint(tw.allocator, "{{\"direction\":\"{s}\",\"x\":{d},\"y1\":{d},\"y2\":{d}}}", .{
559
+ if (direction == .down) "down" else "up",
560
+ x,
561
+ start_y,
562
+ end_y,
563
+ });
564
+ defer tw.allocator.free(payload);
565
+ try tw.recordEvent("ui.scroll", payload);
566
+ }
567
+ try settleDevice(device, options);
568
+ }
569
+
451
570
  fn settleDevice(device: anytype, options: RunOptions) !void {
452
571
  try device.settle(options.settle_ms);
453
572
  }
package/src/version.zig CHANGED
@@ -1,4 +1,4 @@
1
- pub const runner_version = "0.2.11";
1
+ pub const runner_version = "0.2.13";
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";