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 +29 -0
- package/FEATURES.md +1 -1
- package/README.md +4 -2
- package/clients/kotlin/README.md +1 -1
- package/clients/kotlin/build.gradle.kts +1 -1
- package/clients/python/pyproject.toml +1 -1
- package/clients/rust/Cargo.lock +1 -1
- package/clients/rust/Cargo.toml +1 -1
- package/clients/typescript/package.json +1 -1
- package/docs/protocol-fixtures/core-session.responses.jsonl +1 -1
- package/docs/protocol.md +10 -10
- package/docs/troubleshooting.md +7 -5
- package/package.json +1 -1
- package/prebuilds/darwin-arm64/zmr +0 -0
- package/prebuilds/darwin-x64/zmr +0 -0
- package/prebuilds/linux-arm64/zmr +0 -0
- package/prebuilds/linux-x64/zmr +0 -0
- package/scripts/install-ios-shim.sh +1 -1
- package/shims/ios/ZMRShim.swift +2 -0
- package/shims/ios/ZMRShimUITestCase.swift +112 -40
- package/shims/ios/protocol.md +8 -0
- package/src/errors.zig +5 -0
- package/src/ios.zig +75 -6
- package/src/ios_shim.zig +24 -0
- package/src/main.zig +4 -1
- package/src/runner_events.zig +17 -0
- package/src/runner_native.zig +17 -0
- package/src/runner_waits.zig +173 -54
- package/src/version.zig +1 -1
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.
|
|
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
|
|
200
|
-
`
|
|
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
|
package/clients/kotlin/README.md
CHANGED
|
@@ -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.
|
|
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
|
package/clients/rust/Cargo.lock
CHANGED
package/clients/rust/Cargo.toml
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
{"jsonrpc":"2.0","id":1,"result":{"name":"zmr","version":"0.2.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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`.
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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
|
|
192
|
-
native dependency compile.
|
|
193
|
-
hardware, raise the ceiling with
|
|
194
|
-
|
|
195
|
-
|
|
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
|
Binary file
|
package/prebuilds/darwin-x64/zmr
CHANGED
|
Binary file
|
|
Binary file
|
package/prebuilds/linux-x64/zmr
CHANGED
|
Binary file
|
package/shims/ios/ZMRShim.swift
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
let
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
app
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
339
|
-
|
|
340
|
-
app.cells.
|
|
341
|
-
|
|
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()
|
package/shims/ios/protocol.md
CHANGED
|
@@ -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(.{
|
|
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
|
|
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) {
|
package/src/runner_events.zig
CHANGED
|
@@ -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();
|
package/src/runner_native.zig
CHANGED
|
@@ -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,
|
package/src/runner_waits.zig
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
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 (
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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 (
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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