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.
- package/CHANGELOG.md +45 -0
- package/CONTRIBUTING.md +20 -7
- package/FEATURES.md +29 -20
- package/README.md +73 -57
- package/SECURITY.md +11 -6
- package/clients/README.md +8 -7
- package/clients/go/README.md +2 -2
- package/clients/kotlin/README.md +2 -2
- package/clients/kotlin/build.gradle.kts +1 -1
- package/clients/python/README.md +2 -1
- package/clients/python/pyproject.toml +1 -1
- package/clients/rust/Cargo.lock +1 -1
- package/clients/rust/Cargo.toml +1 -1
- package/clients/rust/README.md +2 -2
- package/clients/swift/README.md +2 -2
- package/clients/typescript/README.md +2 -1
- package/clients/typescript/package.json +1 -1
- package/docs/adr/0001-agent-native-runner-boundary.md +1 -1
- package/docs/adr/README.md +7 -5
- package/docs/agent-discovery.md +15 -15
- package/docs/ai-agents.md +30 -20
- package/docs/app-integration.md +59 -27
- package/docs/benchmarking.md +16 -8
- package/docs/benchmarks/README.md +3 -1
- package/docs/benchmarks/benchmark-lab-v1.md +1 -1
- package/docs/client-installation.md +18 -9
- package/docs/clients.md +7 -6
- package/docs/config.md +29 -15
- package/docs/demo.md +14 -9
- package/docs/expo-smoke.md +12 -18
- package/docs/frameworks.md +30 -21
- package/docs/install.md +63 -13
- package/docs/npm.md +45 -27
- package/docs/production-readiness.md +32 -17
- package/docs/protocol-fixtures/core-session.responses.jsonl +1 -1
- package/docs/protocol-versioning.md +5 -3
- package/docs/protocol.md +33 -18
- package/docs/scenario-authoring.md +15 -8
- package/docs/support-matrix.md +38 -0
- package/docs/trace-privacy.md +5 -3
- package/docs/troubleshooting.md +17 -14
- package/npm/app-config.mjs +2 -0
- package/npm/commands.mjs +4 -4
- package/npm/scaffold.mjs +2 -2
- package/package.json +2 -2
- 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/schemas/README.md +6 -3
- package/schemas/import-output.schema.json +1 -1
- package/schemas/scenario.schema.json +2 -0
- package/schemas/zmr-config.schema.json +2 -1
- package/scripts/install-ios-shim.sh +39 -4
- package/scripts/public-metadata-guard.sh +101 -0
- package/shims/android/README.md +4 -3
- package/shims/android/protocol.md +3 -2
- package/shims/ios/README.md +8 -7
- package/shims/ios/ZMRShimUITestCase.swift +58 -17
- package/shims/ios/protocol.md +2 -1
- package/skills/zmr-mobile-testing/SKILL.md +9 -8
- package/src/android_emulator.zig +54 -5
- package/src/cli_import.zig +15 -2
- package/src/cli_output.zig +2 -0
- package/src/cli_run.zig +8 -0
- package/src/config.zig +3 -0
- package/src/errors.zig +3 -0
- package/src/ios_devices.zig +100 -0
- package/src/main.zig +1 -1
- package/src/mcp_protocol.zig +12 -9
- package/src/run_options.zig +4 -0
- package/src/scaffold.zig +10 -8
- package/src/scenario.zig +43 -0
- package/src/selector.zig +53 -9
- package/src/trace_json.zig +4 -0
- package/src/validation.zig +5 -0
- 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.
|
|
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
|
package/prebuilds/darwin-x64/zmr
CHANGED
|
Binary file
|
|
Binary file
|
package/prebuilds/linux-x64/zmr
CHANGED
|
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
|
|
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
|
|
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": { "
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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"
|
package/shims/android/README.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# ZMR Android Shim
|
|
2
2
|
|
|
3
|
-
This directory contains the
|
|
4
|
-
supplements the
|
|
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
|
-
|
|
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
|
|
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
|
|
6
|
+
The implementation should mirror the public ZMR action model:
|
|
6
7
|
|
|
7
8
|
- `snapshot`
|
|
8
9
|
- `tap`
|
package/shims/ios/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# ZMR iOS Shim
|
|
2
2
|
|
|
3
|
-
This directory contains the XCTest/XCUIAutomation shim scaffold used
|
|
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
|
|
7
|
-
protocol is an internal local transport between the Zig runner and an
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
414
|
-
|
|
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
|
)
|
package/shims/ios/protocol.md
CHANGED
|
@@ -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
|
|
9
|
-
outside the runner; use ZMR for device discovery, observations,
|
|
10
|
-
assertions, traces, and
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
101
|
-
|
|
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
|
package/src/android_emulator.zig
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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 (
|
|
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);
|