zeno-mobile-runner 0.2.12 → 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 +15 -0
- package/FEATURES.md +1 -1
- package/README.md +1 -1
- 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/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/shims/ios/ZMRShim.swift +2 -0
- package/shims/ios/ZMRShimUITestCase.swift +112 -40
- package/shims/ios/protocol.md +8 -0
- package/src/ios.zig +25 -5
- package/src/ios_shim.zig +24 -0
- package/src/runner_events.zig +17 -0
- package/src/runner_waits.zig +173 -54
- package/src/version.zig +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,21 @@ 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
|
+
|
|
7
22
|
## 0.2.12 (2026-06-22)
|
|
8
23
|
|
|
9
24
|
### 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
|
@@ -199,7 +199,7 @@ comparisons against your current E2E tool, and multi-device matrices, see
|
|
|
199
199
|
Slow CI hardware can extend the generated iOS shim build timeout with
|
|
200
200
|
`ZMR_IOS_SHIM_BUILD_TIMEOUT_SECONDS`; `ZMR_IOS_SHIM_RESPONSE_TIMEOUT_SECONDS`
|
|
201
201
|
bounds each in-flight request, and `ZMR_IOS_SHIM_TIMEOUT_MS` remains the outer
|
|
202
|
-
process ceiling. Current release: `0.2.
|
|
202
|
+
process ceiling. Current release: `0.2.13` developer preview.
|
|
203
203
|
Protocol version: `2026-04-28`.
|
|
204
204
|
|
|
205
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/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/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
|
}
|
|
@@ -322,20 +332,25 @@ pub const IosDevice = struct {
|
|
|
322
332
|
try ios_shim.parseOkResponse(response);
|
|
323
333
|
}
|
|
324
334
|
|
|
325
|
-
fn acceptOpenURLConfirmationBestEffort(self: *IosDevice) void {
|
|
335
|
+
fn acceptOpenURLConfirmationBestEffort(self: *IosDevice, url: []const u8) void {
|
|
326
336
|
if (self.shim_path == null) return;
|
|
327
337
|
var attempt: usize = 0;
|
|
328
338
|
while (attempt < open_link_interruption_attempts) {
|
|
329
339
|
attempt += 1;
|
|
330
|
-
if (self.acceptOpenURLConfirmationOnce() catch return) return;
|
|
340
|
+
if (self.acceptOpenURLConfirmationOnce(url) catch return) return;
|
|
331
341
|
if (attempt < open_link_interruption_attempts) {
|
|
332
342
|
stdio.sleepNs(open_link_interruption_retry_delay_ms * std.time.ns_per_ms);
|
|
333
343
|
}
|
|
334
344
|
}
|
|
335
345
|
}
|
|
336
346
|
|
|
337
|
-
fn acceptOpenURLConfirmationOnce(self: *IosDevice) !bool {
|
|
338
|
-
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);
|
|
339
354
|
defer self.allocator.free(response);
|
|
340
355
|
return try ios_shim.parseAcceptSystemAlertResponse(response);
|
|
341
356
|
}
|
|
@@ -460,6 +475,11 @@ fn parseShimTimeoutMs(raw: ?[]const u8) u64 {
|
|
|
460
475
|
return parsed;
|
|
461
476
|
}
|
|
462
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
|
+
|
|
463
483
|
test "ios simulator openLink keeps sweeping delayed XCTest interruptions until accepted" {
|
|
464
484
|
const allocator = std.heap.page_allocator;
|
|
465
485
|
const argv = [_][*:0]const u8{"zmr-ios-test"};
|
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/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_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