zeno-mobile-runner 0.2.15 → 0.2.17

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.
Files changed (77) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/CONTRIBUTING.md +20 -7
  3. package/FEATURES.md +29 -20
  4. package/README.md +73 -57
  5. package/SECURITY.md +11 -6
  6. package/clients/README.md +8 -7
  7. package/clients/go/README.md +2 -2
  8. package/clients/kotlin/README.md +2 -2
  9. package/clients/kotlin/build.gradle.kts +1 -1
  10. package/clients/python/README.md +2 -1
  11. package/clients/python/pyproject.toml +1 -1
  12. package/clients/rust/Cargo.lock +1 -1
  13. package/clients/rust/Cargo.toml +1 -1
  14. package/clients/rust/README.md +2 -2
  15. package/clients/swift/README.md +2 -2
  16. package/clients/typescript/README.md +2 -1
  17. package/clients/typescript/package.json +1 -1
  18. package/docs/adr/0001-agent-native-runner-boundary.md +1 -1
  19. package/docs/adr/README.md +7 -5
  20. package/docs/agent-discovery.md +15 -15
  21. package/docs/ai-agents.md +30 -20
  22. package/docs/app-integration.md +59 -27
  23. package/docs/benchmarking.md +16 -8
  24. package/docs/benchmarks/README.md +3 -1
  25. package/docs/benchmarks/benchmark-lab-v1.md +1 -1
  26. package/docs/client-installation.md +18 -9
  27. package/docs/clients.md +7 -6
  28. package/docs/config.md +29 -15
  29. package/docs/demo.md +14 -9
  30. package/docs/expo-smoke.md +12 -18
  31. package/docs/frameworks.md +30 -21
  32. package/docs/install.md +63 -13
  33. package/docs/npm.md +45 -27
  34. package/docs/production-readiness.md +32 -17
  35. package/docs/protocol-fixtures/core-session.responses.jsonl +1 -1
  36. package/docs/protocol-versioning.md +5 -3
  37. package/docs/protocol.md +33 -18
  38. package/docs/scenario-authoring.md +15 -8
  39. package/docs/support-matrix.md +38 -0
  40. package/docs/trace-privacy.md +5 -3
  41. package/docs/troubleshooting.md +17 -14
  42. package/npm/app-config.mjs +2 -0
  43. package/npm/commands.mjs +4 -4
  44. package/npm/scaffold.mjs +2 -2
  45. package/package.json +2 -2
  46. package/prebuilds/darwin-arm64/zmr +0 -0
  47. package/prebuilds/darwin-x64/zmr +0 -0
  48. package/prebuilds/linux-arm64/zmr +0 -0
  49. package/prebuilds/linux-x64/zmr +0 -0
  50. package/schemas/README.md +6 -3
  51. package/schemas/import-output.schema.json +1 -1
  52. package/schemas/scenario.schema.json +2 -0
  53. package/schemas/zmr-config.schema.json +2 -1
  54. package/scripts/install-ios-shim.sh +39 -4
  55. package/scripts/public-metadata-guard.sh +101 -0
  56. package/shims/android/README.md +4 -3
  57. package/shims/android/protocol.md +3 -2
  58. package/shims/ios/README.md +8 -7
  59. package/shims/ios/ZMRShimUITestCase.swift +58 -17
  60. package/shims/ios/protocol.md +2 -1
  61. package/skills/zmr-mobile-testing/SKILL.md +9 -8
  62. package/src/android_emulator.zig +54 -5
  63. package/src/cli_import.zig +15 -2
  64. package/src/cli_output.zig +2 -0
  65. package/src/cli_run.zig +8 -0
  66. package/src/config.zig +3 -0
  67. package/src/errors.zig +3 -0
  68. package/src/ios_devices.zig +100 -0
  69. package/src/main.zig +1 -1
  70. package/src/mcp_protocol.zig +12 -9
  71. package/src/run_options.zig +4 -0
  72. package/src/scaffold.zig +10 -8
  73. package/src/scenario.zig +43 -0
  74. package/src/selector.zig +53 -9
  75. package/src/trace_json.zig +4 -0
  76. package/src/validation.zig +5 -0
  77. package/src/version.zig +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zeno-mobile-runner",
3
- "version": "0.2.15",
3
+ "version": "0.2.17",
4
4
  "description": "Agent-native mobile app test runner for React Native, Expo, Flutter, and native Android/iOS.",
5
5
  "main": "npm/index.mjs",
6
6
  "repository": {
@@ -104,7 +104,7 @@
104
104
  "build:zmr": "node npm/build-zmr.mjs",
105
105
  "pack:npm": "bash scripts/build-npm-package.sh",
106
106
  "zmr:demo": "node npm/zmr.mjs validate examples/demo-fake.json",
107
- "test": "node --test tests/viewer-parser.test.mjs tests/npm-scaffold-helpers.test.mjs tests/npm-cli.test.mjs tests/npm-package.test.mjs tests/typescript-client.test.mjs && python3 -W error -m unittest tests/python_client_test.py && bash tests/go-client-test.sh && bash tests/rust-client-test.sh"
107
+ "test": "npm run build:zmr && node --test tests/viewer-parser.test.mjs tests/npm-scaffold-helpers.test.mjs tests/npm-cli.test.mjs tests/npm-package.test.mjs tests/typescript-client.test.mjs && python3 -W error -m unittest tests/python_client_test.py && bash tests/go-client-test.sh && bash tests/rust-client-test.sh"
108
108
  },
109
109
  "keywords": [
110
110
  "mobile-testing",
Binary file
Binary file
Binary file
Binary file
package/schemas/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # ZMR Schemas
2
2
 
3
- This directory contains draft 2020-12 JSON Schemas for public ZMR file and protocol payloads.
3
+ This directory contains draft 2020-12 JSON Schemas for public ZMR files,
4
+ commands, diagnostics, and protocol payloads. Agents and app setup scripts
5
+ should use these schemas for validation instead of inferring shapes from prose.
4
6
 
5
7
  - `scenario.schema.json`: scenario files consumed by `zmr run` and `zmr validate`
6
8
  - `snapshot.schema.json`: `ObservationSnapshot` JSON emitted by live RPC and persisted trace snapshots, including viewport and optional display density metrics
@@ -13,7 +15,7 @@ This directory contains draft 2020-12 JSON Schemas for public ZMR file and proto
13
15
  - `doctor-output.schema.json`: machine-readable `zmr doctor --json` setup diagnostics, including remediation hints for actionable checks
14
16
  - `init-output.schema.json`: machine-readable `zmr init --json` bootstrap output for scenario and app-local `.zmr/` initialization
15
17
  - `import-output.schema.json`: machine-readable `zmr import --json` output for one-time scenario migration helpers
16
- - `devices-output.schema.json`: machine-readable `zmr devices --json` output for Android, iOS simulator, and physical iOS discovery
18
+ - `devices-output.schema.json`: machine-readable `zmr devices --json` output for Android, iOS/iPadOS simulator, and physical iOS/iPadOS discovery
17
19
  - `validate-output.schema.json`: machine-readable `zmr validate --json` scenario preflight output
18
20
  - `version-output.schema.json`: machine-readable `zmr version --json` output for runner and protocol compatibility discovery
19
21
  - `capabilities-output.schema.json`: machine-readable `runner.capabilities` JSON-RPC result for protocol, platform support, transport, and method discovery
@@ -27,4 +29,5 @@ This directory contains draft 2020-12 JSON Schemas for public ZMR file and proto
27
29
  - `release-readiness-output.schema.json`: machine-readable `zmr-release-readiness --json` release evidence gate output
28
30
  - `schemas-output.schema.json`: machine-readable `zmr schemas --json` index of public schema names, paths, ids, and descriptions
29
31
 
30
- The Zig test suite verifies these files parse as JSON. Full schema validation is intentionally left to client tooling for now.
32
+ The Zig test suite verifies these files parse as JSON. Full schema validation is
33
+ intentionally left to client tooling for now.
@@ -7,7 +7,7 @@
7
7
  "required": ["ok", "format", "source", "out", "name", "appId", "stepCount", "next", "nextCommands"],
8
8
  "properties": {
9
9
  "ok": { "const": true },
10
- "format": { "enum": ["flow-yaml"] },
10
+ "format": { "const": "flow-yaml" },
11
11
  "source": { "type": "string", "minLength": 1 },
12
12
  "out": { "type": "string", "minLength": 1 },
13
13
  "name": { "type": "string", "minLength": 1 },
@@ -22,6 +22,7 @@
22
22
  "properties": {
23
23
  "id": { "type": "string" },
24
24
  "resourceId": { "type": "string" },
25
+ "stableId": { "type": "string" },
25
26
  "text": { "type": "string" },
26
27
  "textContains": { "type": "string" },
27
28
  "contentDesc": { "type": "string" },
@@ -31,6 +32,7 @@
31
32
  },
32
33
  "step": {
33
34
  "type": "object",
35
+ "additionalProperties": false,
34
36
  "required": ["action"],
35
37
  "properties": {
36
38
  "action": {
@@ -68,7 +68,8 @@
68
68
  "avdSystemImage": { "type": "string", "minLength": 1 },
69
69
  "avdDeviceProfile": { "type": "string", "minLength": 1 },
70
70
  "resetBeforeRun": { "type": "boolean", "default": false },
71
- "waitReady": { "type": "boolean", "default": false }
71
+ "waitReady": { "type": "boolean", "default": false },
72
+ "ensureDevice": { "type": "boolean", "default": false }
72
73
  }
73
74
  }
74
75
  }
@@ -165,8 +165,28 @@ fi
165
165
 
166
166
  mkdir -p "$APP_ROOT"
167
167
  APP_ROOT="$(cd "$APP_ROOT" && pwd)"
168
- mkdir -p "$APP_ROOT/.zmr" "$APP_ROOT/.zmr/shims/ios"
169
- rm -f "$APP_ROOT/.zmr/ios-shim-state/build-for-testing.ready"
168
+ SHIM_INSTALL_FINGERPRINT="$(
169
+ {
170
+ printf '%s\n' \
171
+ "$SCHEME" \
172
+ "$TEST_TARGET" \
173
+ "$TEST_BUNDLE_ID" \
174
+ "$WORKSPACE" \
175
+ "$PROJECT" \
176
+ "$APP_TARGET" \
177
+ "$BUNDLE_ID" \
178
+ "$DERIVED_DATA_PATH" \
179
+ "$DEVICE_TYPE" \
180
+ "$CONFIGURATION" \
181
+ "$DEPLOYMENT_TARGET"
182
+ shasum -a 256 \
183
+ "$ROOT/scripts/install-ios-shim.sh" \
184
+ "$ROOT/scripts/ensure-ios-shim-target.rb" \
185
+ "$ROOT/shims/ios/ZMRShim.swift" \
186
+ "$ROOT/shims/ios/ZMRShimUITestCase.swift"
187
+ } | shasum -a 256 | awk '{print $1}'
188
+ )"
189
+ mkdir -p "$APP_ROOT/.zmr" "$APP_ROOT/.zmr/shims/ios" "$APP_ROOT/.zmr/ios-shim-state"
170
190
  rm -f "$APP_ROOT/.zmr/ios-shim-state/destination.id"
171
191
  rm -f "$APP_ROOT/.zmr/ios-shim-state/xcodebuild.pid"
172
192
  rm -f "$APP_ROOT/.zmr/ios-shim-state/xcodebuild.log"
@@ -222,8 +242,13 @@ PID_FILE="\$STATE_DIR/xcodebuild.pid"
222
242
  READY_FILE="\$SERVER_DIR/ready"
223
243
  DESTINATION_ID_FILE="\$STATE_DIR/destination.id"
224
244
  BUILD_READY_FILE="\$STATE_DIR/build-for-testing.ready"
245
+ BUILD_FINGERPRINT_FILE="\$STATE_DIR/build-for-testing.fingerprint"
246
+ DERIVED_DATA_CLEAN_FINGERPRINT_FILE="\$STATE_DIR/derived-data-clean.fingerprint"
225
247
  LOG_FILE="\$STATE_DIR/xcodebuild.log"
226
248
  DERIVED_DATA_PATH_VALUE="$DERIVED_DATA_PATH"
249
+ INSTALL_FINGERPRINT_VALUE="$SHIM_INSTALL_FINGERPRINT"
250
+ ZMR_TEST_TARGET_NAME="$TEST_TARGET"
251
+ ZMR_SCHEME_NAME="$SCHEME"
227
252
  STDIN_FILE="\$(mktemp)"
228
253
  trap 'rm -f "\$STDIN_FILE"' EXIT
229
254
 
@@ -337,6 +362,9 @@ clean_zmr_derived_data() {
337
362
  if [[ -z "\$DERIVED_DATA_PATH_VALUE" ]]; then
338
363
  return 0
339
364
  fi
365
+ if [[ "\${ZMR_IOS_SHIM_FORCE_REBUILD:-}" != "1" && -f "\$DERIVED_DATA_CLEAN_FINGERPRINT_FILE" ]] && [[ "\$(cat "\$DERIVED_DATA_CLEAN_FINGERPRINT_FILE" 2>/dev/null || true)" == "\$INSTALL_FINGERPRINT_VALUE" ]]; then
366
+ return 0
367
+ fi
340
368
 
341
369
  local derived_data_abs
342
370
  if [[ "\$DERIVED_DATA_PATH_VALUE" == /* ]]; then
@@ -350,7 +378,13 @@ clean_zmr_derived_data() {
350
378
 
351
379
  case "\$derived_data_abs" in
352
380
  "$APP_ROOT/ZMRDerivedData"|"$APP_ROOT"/*/ZMRDerivedData)
353
- rm -rf "\$derived_data_abs"
381
+ if [[ -d "\$derived_data_abs/Build/Products" ]]; then
382
+ find "\$derived_data_abs/Build/Products" -depth \\( -name "\$ZMR_TEST_TARGET_NAME*" -o -name "\$ZMR_SCHEME_NAME*" \\) -exec rm -rf {} +
383
+ fi
384
+ if [[ -d "\$derived_data_abs/Build/Intermediates.noindex" ]]; then
385
+ find "\$derived_data_abs/Build/Intermediates.noindex" -depth \\( -name "\$ZMR_TEST_TARGET_NAME.build" -o -name "\$ZMR_SCHEME_NAME.build" \\) -exec rm -rf {} +
386
+ fi
387
+ printf '%s\\n' "\$INSTALL_FINGERPRINT_VALUE" > "\$DERIVED_DATA_CLEAN_FINGERPRINT_FILE"
354
388
  ;;
355
389
  *)
356
390
  echo "warning: refusing to delete non-ZMR derived data path: \$DERIVED_DATA_PATH_VALUE" >&2
@@ -418,7 +452,7 @@ wait_for_ready() {
418
452
  }
419
453
 
420
454
  build_for_testing() {
421
- if [[ "\${ZMR_IOS_SHIM_FORCE_REBUILD:-}" != "1" && -f "\$BUILD_READY_FILE" ]]; then
455
+ if [[ "\${ZMR_IOS_SHIM_FORCE_REBUILD:-}" != "1" && -f "\$BUILD_READY_FILE" && -f "\$BUILD_FINGERPRINT_FILE" ]] && [[ "\$(cat "\$BUILD_FINGERPRINT_FILE" 2>/dev/null || true)" == "\$INSTALL_FINGERPRINT_VALUE" ]]; then
422
456
  return 0
423
457
  fi
424
458
 
@@ -437,6 +471,7 @@ build_for_testing() {
437
471
  ZMR_SHIM_SERVER_DIR="\$SERVER_DIR" \\
438
472
  ZMR_APP_BUNDLE_ID="$BUNDLE_ID"
439
473
 
474
+ printf '%s\\n' "\$INSTALL_FINGERPRINT_VALUE" > "\$BUILD_FINGERPRINT_FILE"
440
475
  touch "\$BUILD_READY_FILE"
441
476
  }
442
477
 
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
+
6
+ usage() {
7
+ cat <<'USAGE'
8
+ Usage:
9
+ scripts/public-metadata-guard.sh [--repo <path>]
10
+
11
+ Rejects unwanted public contributor/client strings in public docs, public branch
12
+ commit metadata, and tag metadata. Local backup refs are intentionally ignored.
13
+ USAGE
14
+ }
15
+
16
+ die() {
17
+ echo "error: $*" >&2
18
+ exit 2
19
+ }
20
+
21
+ require_value() {
22
+ local flag="$1"
23
+ local value="${2-}"
24
+ if [[ -z "$value" || "$value" == --* ]]; then
25
+ die "$flag requires a value"
26
+ fi
27
+ }
28
+
29
+ while [[ "$#" -gt 0 ]]; do
30
+ case "$1" in
31
+ --repo)
32
+ require_value "$1" "${2-}"
33
+ ROOT="$2"
34
+ shift 2
35
+ ;;
36
+ -h|--help)
37
+ usage
38
+ exit 0
39
+ ;;
40
+ *)
41
+ echo "unknown argument: $1" >&2
42
+ usage >&2
43
+ exit 2
44
+ ;;
45
+ esac
46
+ done
47
+
48
+ ROOT="$(cd "$ROOT" && pwd)"
49
+
50
+ bad_claude="Clau""de"
51
+ bad_claude_lower="clau""de"
52
+ bad_anthropic="anth""ropic"
53
+ metadata_deny_regex="(${bad_claude} Fable|noreply@${bad_anthropic}[.]com|Co-Authored-By:[[:space:]]*${bad_claude})"
54
+ doc_deny_regex="(${metadata_deny_regex}|${bad_claude} Code|${bad_claude_lower}[[:space:]]+mcp[[:space:]]+add)"
55
+
56
+ scan_public_files() {
57
+ git -C "$ROOT" ls-files -z -- \
58
+ README.md \
59
+ FEATURES.md \
60
+ CHANGELOG.md \
61
+ SECURITY.md \
62
+ CONTRIBUTING.md \
63
+ docs \
64
+ skills \
65
+ .github
66
+ }
67
+
68
+ while IFS= read -r -d '' path; do
69
+ if LC_ALL=C grep -nI -i -E "$doc_deny_regex" "$ROOT/$path" >/dev/null 2>&1; then
70
+ echo "denied public metadata string in file contents: $path" >&2
71
+ exit 1
72
+ fi
73
+ done < <(scan_public_files)
74
+
75
+ current_ref="$(git -C "$ROOT" symbolic-ref -q HEAD || true)"
76
+ {
77
+ if [[ -n "$current_ref" ]]; then
78
+ printf '%s\n' "$current_ref"
79
+ else
80
+ printf '%s\n' HEAD
81
+ fi
82
+ git -C "$ROOT" for-each-ref --format='%(refname)' refs/remotes/origin refs/tags |
83
+ grep -v '^refs/remotes/origin/HEAD$' || true
84
+ } | sort -u | while IFS= read -r ref; do
85
+ if [[ -z "$ref" ]]; then
86
+ continue
87
+ fi
88
+ if git -C "$ROOT" log "$ref" --format='%H%n%an <%ae>%n%cn <%ce>%n%B' |
89
+ LC_ALL=C grep -i -E "$metadata_deny_regex" >/dev/null 2>&1; then
90
+ echo "denied public metadata string in commit metadata: $ref" >&2
91
+ exit 1
92
+ fi
93
+
94
+ if git -C "$ROOT" for-each-ref "$ref" --format='%(taggername) %(taggeremail)%0a%(contents)' |
95
+ LC_ALL=C grep -i -E "$metadata_deny_regex" >/dev/null 2>&1; then
96
+ echo "denied public metadata string in tag metadata: $ref" >&2
97
+ exit 1
98
+ fi
99
+ done
100
+
101
+ printf 'public metadata verified: %s\n' "$ROOT"
@@ -1,7 +1,8 @@
1
1
  # ZMR Android Shim
2
2
 
3
- This directory contains the native Android instrumentation shim scaffold that
4
- supplements the current ADB/UI Automator adapter.
3
+ This directory contains the app-local Android instrumentation shim scaffold.
4
+ The shim supplements the ADB/UI Automator adapter; it does not replace the
5
+ public scenario or JSON-RPC contracts.
5
6
 
6
7
  Current status:
7
8
 
@@ -13,7 +14,7 @@ Current status:
13
14
  command and copies the instrumentation source file into the app repo for
14
15
  inclusion in `androidTest`.
15
16
 
16
- V1 target:
17
+ Target behavior:
17
18
 
18
19
  - Faster hierarchy retrieval than repeated shell UI Automator dumps.
19
20
  - Reliable tap, type, swipe, and key actions with ADB fallback.
@@ -1,8 +1,9 @@
1
1
  # Android Shim Protocol
2
2
 
3
- The Android shim protocol is internal and may change before `v1.0.0`.
3
+ The Android shim protocol is internal. It may change before `v1.0.0` without a
4
+ public protocol version bump.
4
5
 
5
- The first implementation should mirror the public ZMR action model:
6
+ The implementation should mirror the public ZMR action model:
6
7
 
7
8
  - `snapshot`
8
9
  - `tap`
@@ -1,11 +1,11 @@
1
1
  # ZMR iOS Shim
2
2
 
3
- This directory contains the XCTest/XCUIAutomation shim scaffold used for
4
- selector-grade iOS automation.
3
+ This directory contains the app-local XCTest/XCUIAutomation shim scaffold used
4
+ for selector-grade iOS and iPadOS automation.
5
5
 
6
- The public ZMR API remains the scenario file and JSON-RPC protocol. The shim
7
- protocol is an internal local transport between the Zig runner and an app-local
8
- UI test runner.
6
+ The public ZMR API remains scenario JSON, JSON-RPC, MCP tools, and CLI flags.
7
+ The shim protocol is an internal local transport between the Zig runner and an
8
+ app-local UI test runner.
9
9
 
10
10
  Current status:
11
11
 
@@ -21,8 +21,9 @@ Current status:
21
21
  refresh the cached test bundle, or `ZMR_IOS_SHIM_ONESHOT=1` to force the
22
22
  slower one-command XCTest fallback for debugging. When configured with a
23
23
  ZMR-owned derived data path ending in `ZMRDerivedData`, the command removes
24
- that directory before each `build-for-testing` refresh and refuses to delete
25
- arbitrary shared DerivedData paths.
24
+ only stale shim target products and intermediates before a needed
25
+ `build-for-testing` refresh, preserves app and Pods build outputs, and
26
+ refuses to delete arbitrary shared DerivedData paths.
26
27
  - The iOS adapter still uses `xcrun simctl` for simulator install, launch,
27
28
  terminate, open link, screenshots, and logs. It uses `xcrun devicectl` for
28
29
  physical-device lifecycle where Apple exposes a supported local command, and
@@ -2,6 +2,8 @@ import Foundation
2
2
  import XCTest
3
3
 
4
4
  final class ZMRShimUITestCase: XCTestCase {
5
+ private let expoDevClientRecoveryTimeout: TimeInterval = 10
6
+
5
7
  func testRunZMRCommand() throws {
6
8
  let environment = ProcessInfo.processInfo.environment
7
9
  let app = makeApplication(bundleIdentifier: shimRuntimeValue("ZMR_APP_BUNDLE_ID", environment: environment))
@@ -318,6 +320,7 @@ final class ZMRShimUITestCase: XCTestCase {
318
320
 
319
321
  if app.staticTexts["Deep link received:"].waitForExistence(timeout: 1) {
320
322
  if tapFirstMatchingExpoCandidate(
323
+ app: app,
321
324
  queries: [app.buttons, app.cells, app.staticTexts],
322
325
  predicate: predicate
323
326
  ) {
@@ -328,12 +331,29 @@ final class ZMRShimUITestCase: XCTestCase {
328
331
  if expoDevClientFallback,
329
332
  isCustomSchemeURL(openedURL),
330
333
  !isExpoDevClientURL(openedURL) {
331
- if tapExpoDevClientDeepLinkCoordinateFallback(app: app) {
332
- return (true, "expo-dev-client-deep-link-coordinate")
333
- }
334
- if tapExpoDevClientDeepLinkCandidateFallback(app: app, predicate: predicate) {
334
+ return waitForExpoDevClientRecovery(app: app, deepLinkPredicate: predicate)
335
+ }
336
+
337
+ return (false, "")
338
+ }
339
+
340
+ private func waitForExpoDevClientRecovery(
341
+ app: XCUIApplication,
342
+ deepLinkPredicate: NSPredicate
343
+ ) -> (accepted: Bool, label: String) {
344
+ let deadline = Date().addingTimeInterval(expoDevClientRecoveryTimeout)
345
+ while Date() < deadline {
346
+ if app.staticTexts["Deep link received:"].exists,
347
+ tapExpoDevClientDeepLinkCandidateFallback(app: app, predicate: deepLinkPredicate) {
335
348
  return (true, "expo-dev-client-deep-link-candidate")
336
349
  }
350
+
351
+ let homeSelection = resumeExpoDevClientHome(app: app)
352
+ if homeSelection.accepted {
353
+ return homeSelection
354
+ }
355
+
356
+ Thread.sleep(forTimeInterval: 0.2)
337
357
  }
338
358
 
339
359
  return (false, "")
@@ -367,6 +387,7 @@ final class ZMRShimUITestCase: XCTestCase {
367
387
 
368
388
  let predicate = NSPredicate(format: "label CONTAINS[c] %@ OR label CONTAINS[c] %@", " http://", " https://")
369
389
  if tapFirstMatchingExpoCandidate(
390
+ app: app,
370
391
  queries: [app.buttons, app.cells, app.staticTexts],
371
392
  predicate: predicate
372
393
  ) {
@@ -399,6 +420,7 @@ final class ZMRShimUITestCase: XCTestCase {
399
420
  }
400
421
 
401
422
  private func tapFirstMatchingExpoCandidate(
423
+ app: XCUIApplication,
402
424
  queries: [XCUIElementQuery],
403
425
  predicate: NSPredicate
404
426
  ) -> Bool {
@@ -410,19 +432,44 @@ final class ZMRShimUITestCase: XCTestCase {
410
432
  break
411
433
  }
412
434
 
413
- guard element.isHittable else {
414
- continue
435
+ if tapMatchedExpoCandidate(element: element, app: app) {
436
+ return true
415
437
  }
416
-
417
- element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
418
- Thread.sleep(forTimeInterval: 1.0)
419
- return true
420
438
  }
421
439
  }
422
440
 
423
441
  return false
424
442
  }
425
443
 
444
+ private func tapMatchedExpoCandidate(element: XCUIElement, app: XCUIApplication) -> Bool {
445
+ if element.isHittable {
446
+ element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
447
+ Thread.sleep(forTimeInterval: 1.0)
448
+ return true
449
+ }
450
+
451
+ let visibleFrame = element.frame.intersection(app.frame)
452
+ guard !visibleFrame.isNull,
453
+ !visibleFrame.isEmpty,
454
+ app.frame.width > 0,
455
+ app.frame.height > 0 else {
456
+ return false
457
+ }
458
+
459
+ let normalizedX = (visibleFrame.midX - app.frame.minX) / app.frame.width
460
+ let normalizedY = (visibleFrame.midY - app.frame.minY) / app.frame.height
461
+ guard normalizedX >= 0,
462
+ normalizedX <= 1,
463
+ normalizedY >= 0,
464
+ normalizedY <= 1 else {
465
+ return false
466
+ }
467
+
468
+ app.coordinate(withNormalizedOffset: CGVector(dx: normalizedX, dy: normalizedY)).tap()
469
+ Thread.sleep(forTimeInterval: 1.0)
470
+ return true
471
+ }
472
+
426
473
  private func isCustomSchemeURL(_ value: String?) -> Bool {
427
474
  guard let value else {
428
475
  return false
@@ -437,15 +484,9 @@ final class ZMRShimUITestCase: XCTestCase {
437
484
  return value.hasPrefix("exp+") && value.contains("://expo-development-client/")
438
485
  }
439
486
 
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
487
  private func tapExpoDevClientDeepLinkCandidateFallback(app: XCUIApplication, predicate: NSPredicate) -> Bool {
448
488
  tapFirstMatchingExpoCandidate(
489
+ app: app,
449
490
  queries: [app.buttons, app.cells, app.staticTexts],
450
491
  predicate: predicate
451
492
  )
@@ -1,6 +1,7 @@
1
1
  # iOS Shim Protocol
2
2
 
3
- The iOS shim protocol is internal and may change before `v1.0.0`.
3
+ The iOS shim protocol is internal. It may change before `v1.0.0` without a
4
+ public protocol version bump.
4
5
 
5
6
  Commands are newline-delimited JSON objects:
6
7
 
@@ -5,9 +5,9 @@ description: Use when testing mobile apps with Zeno Mobile Runner, integrating a
5
5
 
6
6
  # ZMR Mobile Testing
7
7
 
8
- Use ZMR as the typed control plane for mobile app testing. Keep model reasoning
9
- outside the runner; use ZMR for device discovery, observations, actions, waits,
10
- assertions, traces, and diagnostics.
8
+ Use ZMR as the typed mobile control plane for agent-led app verification. Keep
9
+ model reasoning outside the runner; use ZMR for device discovery, observations,
10
+ actions, waits, assertions, traces, diagnostics, and redacted evidence.
11
11
 
12
12
  ## Start From App-Local State
13
13
 
@@ -15,7 +15,7 @@ assertions, traces, and diagnostics.
15
15
  2. If it is missing, scaffold it:
16
16
 
17
17
  ```bash
18
- npx zmr-wizard --app-id com.example.mobiletest --package-json
18
+ zmr init --app --app-id com.example.mobiletest
19
19
  ```
20
20
 
21
21
  3. Run setup diagnostics before touching a device:
@@ -30,7 +30,7 @@ Use `zmr doctor --strict --json` for CI-style gates.
30
30
 
31
31
  ## Agent Session Pattern
32
32
 
33
- Prefer JSON-RPC over stdio for interactive agent work:
33
+ Prefer a long-running JSON-RPC session for interactive agent work:
34
34
 
35
35
  ```bash
36
36
  zmr serve --transport stdio --config .zmr/config.json --trace-dir traces/zmr-agent
@@ -96,9 +96,10 @@ zmr run .zmr/<scenario>.json --json --trace-dir traces/zmr-<scenario>
96
96
  zmr explain --json traces/zmr-<scenario>
97
97
  ```
98
98
 
99
- Prefer stable selectors: resource id or accessibility identifier first,
100
- content description/accessibility label second, exact text third, textContains
101
- only when copy varies, coordinates last.
99
+ Prefer stable selectors: resource id or accessibility identifier first, content
100
+ description/accessibility label second, exact text third, `textContains` only
101
+ when copy varies, `stableId` only as a live-session fallback, and coordinates
102
+ last.
102
103
 
103
104
  Use `waitAny` for valid branches and `whenVisible` for optional screens. Keep
104
105
  credentials, private app terms, and private traces out of public docs and
@@ -1,5 +1,6 @@
1
1
  const std = @import("std");
2
2
  const stdio = @import("stdio.zig");
3
+ const android_device_info = @import("android_device_info.zig");
3
4
  const command = @import("command.zig");
4
5
 
5
6
  const default_timeout_ms = 15_000;
@@ -16,17 +17,38 @@ pub const PreflightOptions = struct {
16
17
  avd_device_profile: ?[]const u8 = null,
17
18
  reset_before_run: bool = false,
18
19
  wait_ready: bool = false,
20
+ ensure_ready: bool = false,
19
21
  event_log_path: ?[]const u8 = null,
20
22
  };
21
23
 
22
24
  pub fn hasWork(options: PreflightOptions) bool {
23
- return options.reset_before_run or options.wait_ready or options.create_avd_if_missing or options.avd_name != null or options.restore_snapshot != null;
25
+ return options.ensure_ready or options.reset_before_run or options.wait_ready or options.create_avd_if_missing or options.avd_name != null or options.restore_snapshot != null;
24
26
  }
25
27
 
26
28
  pub fn runPreflight(allocator: std.mem.Allocator, options: PreflightOptions) !void {
27
29
  if (!hasWork(options)) return;
28
30
 
29
- if ((options.reset_before_run or options.restore_snapshot != null or options.create_avd_if_missing or options.avd_name != null) and options.avd_name == null) {
31
+ const must_run_lifecycle = options.reset_before_run or options.restore_snapshot != null or options.create_avd_if_missing;
32
+ if (options.ensure_ready and !must_run_lifecycle and try requestedDeviceReady(allocator, options)) {
33
+ if (options.wait_ready) try waitReady(allocator, options);
34
+ return;
35
+ }
36
+
37
+ var owned_avd: ?[]u8 = null;
38
+ defer if (owned_avd) |avd| allocator.free(avd);
39
+ const avd_name = if (options.avd_name) |avd|
40
+ avd
41
+ else blk: {
42
+ if (!options.ensure_ready) break :blk null;
43
+ var list = try runEmulator(allocator, options, &.{"-list-avds"});
44
+ defer list.deinit(allocator);
45
+ try list.ensureSuccess();
46
+ const first = (try firstAvdNameFromList(list.stdout)) orelse return error.NoAndroidAvdAvailable;
47
+ owned_avd = try allocator.dupe(u8, first);
48
+ break :blk owned_avd.?;
49
+ };
50
+
51
+ if ((options.reset_before_run or options.restore_snapshot != null or options.create_avd_if_missing or avd_name != null) and avd_name == null) {
30
52
  return error.MissingAndroidAvdName;
31
53
  }
32
54
  if (options.create_avd_if_missing and options.avd_system_image == null) {
@@ -34,7 +56,7 @@ pub fn runPreflight(allocator: std.mem.Allocator, options: PreflightOptions) !vo
34
56
  }
35
57
 
36
58
  if (options.create_avd_if_missing) {
37
- try createAvdIfMissing(allocator, options, options.avd_name.?);
59
+ try createAvdIfMissing(allocator, options, avd_name.?);
38
60
  }
39
61
 
40
62
  if (options.reset_before_run) {
@@ -42,15 +64,32 @@ pub fn runPreflight(allocator: std.mem.Allocator, options: PreflightOptions) !vo
42
64
  if (reset) |result| result.deinit(allocator);
43
65
  }
44
66
 
45
- if (options.avd_name) |avd| {
67
+ if (avd_name) |avd| {
46
68
  try startEmulator(allocator, options, avd);
47
69
  }
48
70
 
49
- if (options.wait_ready) {
71
+ if (options.wait_ready or options.ensure_ready) {
50
72
  try waitReady(allocator, options);
51
73
  }
52
74
  }
53
75
 
76
+ fn requestedDeviceReady(allocator: std.mem.Allocator, options: PreflightOptions) !bool {
77
+ const devices = android_device_info.listDevices(allocator, options.adb_path) catch return false;
78
+ defer {
79
+ for (devices) |device| device.deinit(allocator);
80
+ allocator.free(devices);
81
+ }
82
+ for (devices) |device| {
83
+ if (!std.mem.eql(u8, device.state, "device")) continue;
84
+ if (options.device_serial) |serial| {
85
+ if (std.mem.eql(u8, device.serial, serial)) return true;
86
+ } else {
87
+ return true;
88
+ }
89
+ }
90
+ return false;
91
+ }
92
+
54
93
  fn createAvdIfMissing(allocator: std.mem.Allocator, options: PreflightOptions, avd: []const u8) !void {
55
94
  var list = try runEmulator(allocator, options, &.{"-list-avds"});
56
95
  defer list.deinit(allocator);
@@ -79,6 +118,16 @@ fn avdListContains(output: []const u8, avd: []const u8) bool {
79
118
  return false;
80
119
  }
81
120
 
121
+ pub fn firstAvdNameFromList(output: []const u8) !?[]const u8 {
122
+ var lines = std.mem.splitScalar(u8, output, '\n');
123
+ while (lines.next()) |raw_line| {
124
+ const line = std.mem.trim(u8, raw_line, " \t\r\n");
125
+ if (line.len == 0) continue;
126
+ return line;
127
+ }
128
+ return null;
129
+ }
130
+
82
131
  fn runEmulator(allocator: std.mem.Allocator, options: PreflightOptions, extra: []const []const u8) !command.ExecResult {
83
132
  var argv = std.ArrayList([]const u8).empty;
84
133
  defer argv.deinit(allocator);