zig-mobile-runner 0.1.0

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 (225) hide show
  1. package/CHANGELOG.md +484 -0
  2. package/CONTRIBUTING.md +42 -0
  3. package/FEATURES.md +112 -0
  4. package/LICENSE +21 -0
  5. package/README.md +255 -0
  6. package/SECURITY.md +34 -0
  7. package/build.zig +38 -0
  8. package/build.zig.zon +7 -0
  9. package/clients/README.md +144 -0
  10. package/clients/go/README.md +24 -0
  11. package/clients/go/examples/fake-session/main.go +93 -0
  12. package/clients/go/go.mod +3 -0
  13. package/clients/go/zmr/client.go +432 -0
  14. package/clients/kotlin/README.md +35 -0
  15. package/clients/kotlin/build.gradle.kts +35 -0
  16. package/clients/kotlin/settings.gradle.kts +15 -0
  17. package/clients/kotlin/src/main/kotlin/dev/zmr/FakeSession.kt +86 -0
  18. package/clients/kotlin/src/main/kotlin/dev/zmr/ZmrClient.kt +67 -0
  19. package/clients/python/README.md +29 -0
  20. package/clients/python/examples/fake_session.py +48 -0
  21. package/clients/python/pyproject.toml +13 -0
  22. package/clients/python/zmr_client.py +202 -0
  23. package/clients/rust/Cargo.lock +107 -0
  24. package/clients/rust/Cargo.toml +10 -0
  25. package/clients/rust/README.md +19 -0
  26. package/clients/rust/examples/fake_session.rs +70 -0
  27. package/clients/rust/src/lib.rs +461 -0
  28. package/clients/swift/Package.swift +16 -0
  29. package/clients/swift/README.md +36 -0
  30. package/clients/swift/Sources/ZMRClient/ZMRClient.swift +114 -0
  31. package/clients/swift/Sources/ZMRFakeSession/main.swift +86 -0
  32. package/clients/typescript/README.md +34 -0
  33. package/clients/typescript/examples/fake-session.mjs +36 -0
  34. package/clients/typescript/index.d.ts +144 -0
  35. package/clients/typescript/index.mjs +192 -0
  36. package/clients/typescript/package.json +8 -0
  37. package/docs/adr/0001-agent-native-runner-boundary.md +31 -0
  38. package/docs/adr/0002-app-local-zmr-contract.md +39 -0
  39. package/docs/adr/0003-ios-simulator-xctest-shim.md +41 -0
  40. package/docs/adr/0004-benchmark-claims-and-baseline-collection.md +37 -0
  41. package/docs/adr/README.md +12 -0
  42. package/docs/ai-agents.md +156 -0
  43. package/docs/app-integration.md +316 -0
  44. package/docs/benchmarking.md +275 -0
  45. package/docs/client-installation.md +141 -0
  46. package/docs/clients.md +98 -0
  47. package/docs/config.md +175 -0
  48. package/docs/demo.md +259 -0
  49. package/docs/dsl.md +57 -0
  50. package/docs/install.md +233 -0
  51. package/docs/market-positioning.md +70 -0
  52. package/docs/npm.md +359 -0
  53. package/docs/protocol-fixtures/README.md +8 -0
  54. package/docs/protocol-fixtures/core-session.requests.jsonl +8 -0
  55. package/docs/protocol-fixtures/core-session.responses.jsonl +8 -0
  56. package/docs/protocol-versioning.md +65 -0
  57. package/docs/protocol.md +560 -0
  58. package/docs/publication.md +77 -0
  59. package/docs/release-audit.md +99 -0
  60. package/docs/release-candidate.md +111 -0
  61. package/docs/release-evidence.md +188 -0
  62. package/docs/release-notes-template.md +58 -0
  63. package/docs/roadmap.md +334 -0
  64. package/docs/scenario-authoring.md +88 -0
  65. package/docs/shipping.md +170 -0
  66. package/docs/trace-privacy.md +88 -0
  67. package/docs/troubleshooting.md +256 -0
  68. package/examples/android-app-auth-probe.json +89 -0
  69. package/examples/android-app-error-state.json +13 -0
  70. package/examples/android-app-login-smoke.json +192 -0
  71. package/examples/android-app-onboarding.json +12 -0
  72. package/examples/android-app-referral-deep-link.json +12 -0
  73. package/examples/android-shim-smoke.json +19 -0
  74. package/examples/demo-failure.json +12 -0
  75. package/examples/demo-fake.json +14 -0
  76. package/examples/ios-dev-client-open-link.json +26 -0
  77. package/examples/ios-dev-client-route-snapshot.json +24 -0
  78. package/examples/ios-shim-smoke.json +23 -0
  79. package/examples/ios-smoke.json +9 -0
  80. package/go.work +3 -0
  81. package/npm/agents.mjs +183 -0
  82. package/npm/app-config.mjs +95 -0
  83. package/npm/build-zmr.mjs +21 -0
  84. package/npm/commands.mjs +104 -0
  85. package/npm/generated-files.mjs +50 -0
  86. package/npm/index.mjs +75 -0
  87. package/npm/init-app.mjs +80 -0
  88. package/npm/package-scripts.mjs +72 -0
  89. package/npm/postinstall.mjs +21 -0
  90. package/npm/scaffold.mjs +179 -0
  91. package/npm/scenarios.mjs +93 -0
  92. package/npm/setup.mjs +69 -0
  93. package/npm/wizard.mjs +117 -0
  94. package/npm/zmr.mjs +23 -0
  95. package/package.json +114 -0
  96. package/prebuilds/darwin-arm64/zmr +0 -0
  97. package/prebuilds/darwin-x64/zmr +0 -0
  98. package/prebuilds/linux-arm64/zmr +0 -0
  99. package/prebuilds/linux-x64/zmr +0 -0
  100. package/schemas/README.md +26 -0
  101. package/schemas/action-result.schema.json +27 -0
  102. package/schemas/capabilities-output.schema.json +98 -0
  103. package/schemas/devices-output.schema.json +25 -0
  104. package/schemas/doctor-output.schema.json +51 -0
  105. package/schemas/explain-output.schema.json +51 -0
  106. package/schemas/import-output.schema.json +23 -0
  107. package/schemas/init-output.schema.json +71 -0
  108. package/schemas/json-rpc.schema.json +55 -0
  109. package/schemas/release-manifest.schema.json +43 -0
  110. package/schemas/release-readiness-output.schema.json +127 -0
  111. package/schemas/run-output.schema.json +43 -0
  112. package/schemas/scenario.schema.json +128 -0
  113. package/schemas/schemas-output.schema.json +26 -0
  114. package/schemas/semantic-snapshot.schema.json +116 -0
  115. package/schemas/snapshot.schema.json +60 -0
  116. package/schemas/trace-event.schema.json +14 -0
  117. package/schemas/trace-manifest.schema.json +59 -0
  118. package/schemas/validate-output.schema.json +42 -0
  119. package/schemas/version-output.schema.json +23 -0
  120. package/schemas/zmr-config.schema.json +75 -0
  121. package/scripts/android-emulator.sh +126 -0
  122. package/scripts/assert-ios-physical-ready.sh +213 -0
  123. package/scripts/benchmark-command.sh +307 -0
  124. package/scripts/benchmark.sh +359 -0
  125. package/scripts/benchmark_gate.py +117 -0
  126. package/scripts/benchmark_result_row.py +88 -0
  127. package/scripts/compare-benchmarks.py +288 -0
  128. package/scripts/create-android-demo-app.sh +342 -0
  129. package/scripts/create-ios-demo-app.sh +261 -0
  130. package/scripts/demo-android-real.sh +232 -0
  131. package/scripts/demo-ios-real.sh +270 -0
  132. package/scripts/demo.sh +464 -0
  133. package/scripts/device-matrix.sh +338 -0
  134. package/scripts/ensure-ios-shim-target.rb +237 -0
  135. package/scripts/install-android-shim.sh +281 -0
  136. package/scripts/install-ios-shim.sh +589 -0
  137. package/scripts/pilot-gate.sh +560 -0
  138. package/scripts/release-readiness.py +838 -0
  139. package/scripts/release-readiness.sh +91 -0
  140. package/scripts/run-android-pilot.sh +561 -0
  141. package/scripts/run-ios-pilot.sh +509 -0
  142. package/shims/android/README.md +21 -0
  143. package/shims/android/ZMRShimInstrumentedTest.java +152 -0
  144. package/shims/android/protocol.md +18 -0
  145. package/shims/ios/README.md +50 -0
  146. package/shims/ios/ZMRShim.swift +110 -0
  147. package/shims/ios/ZMRShimUITestCase.swift +475 -0
  148. package/shims/ios/protocol.md +74 -0
  149. package/skills/zmr-mobile-testing/SKILL.md +127 -0
  150. package/src/android.zig +344 -0
  151. package/src/android_device_info.zig +99 -0
  152. package/src/android_emulator.zig +154 -0
  153. package/src/android_screen_recording.zig +112 -0
  154. package/src/android_shell.zig +112 -0
  155. package/src/bundle.zig +124 -0
  156. package/src/bundle_redaction.zig +272 -0
  157. package/src/bundle_tar.zig +123 -0
  158. package/src/cli_devices.zig +97 -0
  159. package/src/cli_doctor.zig +114 -0
  160. package/src/cli_import.zig +70 -0
  161. package/src/cli_info.zig +39 -0
  162. package/src/cli_init.zig +72 -0
  163. package/src/cli_output.zig +467 -0
  164. package/src/cli_run.zig +259 -0
  165. package/src/cli_serve.zig +287 -0
  166. package/src/cli_trace.zig +111 -0
  167. package/src/cli_validate.zig +41 -0
  168. package/src/command.zig +211 -0
  169. package/src/config.zig +305 -0
  170. package/src/config_diagnostics.zig +212 -0
  171. package/src/config_paths.zig +49 -0
  172. package/src/device_registry.zig +37 -0
  173. package/src/doctor.zig +412 -0
  174. package/src/doctor_hints.zig +52 -0
  175. package/src/errors.zig +55 -0
  176. package/src/fake_device.zig +163 -0
  177. package/src/health.zig +28 -0
  178. package/src/importer.zig +343 -0
  179. package/src/importer_json.zig +100 -0
  180. package/src/importer_model.zig +103 -0
  181. package/src/ios.zig +399 -0
  182. package/src/ios_devices.zig +219 -0
  183. package/src/ios_lifecycle.zig +72 -0
  184. package/src/ios_shim.zig +242 -0
  185. package/src/ios_snapshot.zig +20 -0
  186. package/src/json_fields.zig +80 -0
  187. package/src/json_rpc.zig +150 -0
  188. package/src/json_rpc_methods.zig +318 -0
  189. package/src/json_rpc_observation.zig +31 -0
  190. package/src/json_rpc_params.zig +52 -0
  191. package/src/json_rpc_protocol.zig +110 -0
  192. package/src/json_rpc_trace.zig +73 -0
  193. package/src/main.zig +135 -0
  194. package/src/mcp.zig +234 -0
  195. package/src/mcp_protocol.zig +64 -0
  196. package/src/mcp_trace.zig +83 -0
  197. package/src/report.zig +346 -0
  198. package/src/report_html.zig +63 -0
  199. package/src/report_values.zig +27 -0
  200. package/src/run_options.zig +152 -0
  201. package/src/runner.zig +280 -0
  202. package/src/runner_actions.zig +109 -0
  203. package/src/runner_config.zig +6 -0
  204. package/src/runner_diagnostics.zig +268 -0
  205. package/src/runner_events.zig +170 -0
  206. package/src/runner_native.zig +88 -0
  207. package/src/runner_waits.zig +300 -0
  208. package/src/scaffold.zig +472 -0
  209. package/src/scenario.zig +346 -0
  210. package/src/scenario_fields.zig +50 -0
  211. package/src/schema_registry.zig +53 -0
  212. package/src/selector.zig +84 -0
  213. package/src/semantic.zig +171 -0
  214. package/src/trace.zig +315 -0
  215. package/src/trace_json.zig +340 -0
  216. package/src/trace_summary.zig +218 -0
  217. package/src/trace_summary_diagnostic.zig +202 -0
  218. package/src/types.zig +120 -0
  219. package/src/uiautomator.zig +164 -0
  220. package/src/validation.zig +187 -0
  221. package/src/version.zig +22 -0
  222. package/viewer/app.js +373 -0
  223. package/viewer/index.html +126 -0
  224. package/viewer/parser.js +233 -0
  225. package/viewer/styles.css +585 -0
@@ -0,0 +1,288 @@
1
+ #!/usr/bin/env python3
2
+ import argparse
3
+ import json
4
+ import math
5
+ import shlex
6
+ import statistics
7
+ import sys
8
+ import time
9
+ from collections import defaultdict
10
+ from pathlib import Path
11
+
12
+ CONTEXT_FIELDS = ("platform", "device", "appId", "scenario", "appBuild")
13
+
14
+
15
+ def parse_args():
16
+ parser = argparse.ArgumentParser(
17
+ description="Compare benchmark JSONL rows for two runner labels.",
18
+ formatter_class=argparse.RawDescriptionHelpFormatter,
19
+ epilog=(
20
+ "--evidence-out requires --min-candidate-pass-rate, "
21
+ "--max-candidate-failures, --min-mean-speedup, and "
22
+ "--min-p95-speedup so market-claim evidence includes explicit gates."
23
+ ),
24
+ )
25
+ parser.add_argument("--results", required=True, help="Path to a benchmark results.jsonl file.")
26
+ parser.add_argument("--candidate", default="zmr", help="Candidate tool label. Default: zmr.")
27
+ parser.add_argument("--baseline", required=True, help="Baseline tool label to compare against.")
28
+ parser.add_argument("--format", choices=("markdown", "json"), default="markdown", help="Output format.")
29
+ parser.add_argument("--out", help="Optional output file. Defaults to stdout.")
30
+ parser.add_argument("--min-candidate-pass-rate", type=float, help="Minimum candidate pass rate percentage.")
31
+ parser.add_argument("--max-candidate-failures", type=int, help="Maximum allowed candidate failures.")
32
+ parser.add_argument("--min-mean-speedup", type=float, help="Minimum required mean speedup versus baseline.")
33
+ parser.add_argument("--min-p95-speedup", type=float, help="Minimum required p95 speedup versus baseline.")
34
+ parser.add_argument("--evidence-out", help="Optional JSONL file to append a market-claim readiness evidence row.")
35
+ args = parser.parse_args()
36
+ for name in (
37
+ "min_candidate_pass_rate",
38
+ "max_candidate_failures",
39
+ "min_mean_speedup",
40
+ "min_p95_speedup",
41
+ ):
42
+ value = getattr(args, name)
43
+ if value is not None and value < 0:
44
+ parser.error(f"--{name.replace('_', '-')} must be non-negative")
45
+ if args.evidence_out:
46
+ missing_gate_args = [
47
+ f"--{name.replace('_', '-')}"
48
+ for name in (
49
+ "min_candidate_pass_rate",
50
+ "max_candidate_failures",
51
+ "min_mean_speedup",
52
+ "min_p95_speedup",
53
+ )
54
+ if getattr(args, name) is None
55
+ ]
56
+ if missing_gate_args:
57
+ parser.error(
58
+ "; ".join(f"{name} is required with --evidence-out" for name in missing_gate_args)
59
+ )
60
+ return args
61
+
62
+
63
+ def is_pass(row):
64
+ if row.get("status") != "ok":
65
+ return False
66
+ trace_status = row.get("traceStatus")
67
+ return trace_status in (None, "passed")
68
+
69
+
70
+ def p95(durations):
71
+ if not durations:
72
+ return 0
73
+ ordered = sorted(durations)
74
+ index = max(0, math.ceil(len(ordered) * 0.95) - 1)
75
+ return ordered[index]
76
+
77
+
78
+ def read_rows(path):
79
+ rows = []
80
+ with Path(path).open(encoding="utf-8") as handle:
81
+ for line_number, line in enumerate(handle, start=1):
82
+ line = line.strip()
83
+ if not line:
84
+ continue
85
+ try:
86
+ row = json.loads(line)
87
+ except json.JSONDecodeError as exc:
88
+ raise SystemExit(f"{path}:{line_number}: invalid json: {exc}") from exc
89
+ if not isinstance(row, dict):
90
+ raise SystemExit(f"{path}:{line_number}: expected object row")
91
+ rows.append(row)
92
+ return rows
93
+
94
+
95
+ def summarize(tool, rows):
96
+ durations = [int(row.get("durationMs", 0)) for row in rows]
97
+ failures = [row for row in rows if not is_pass(row)]
98
+ passed = len(rows) - len(failures)
99
+ pass_rate = (passed / len(rows) * 100.0) if rows else 0.0
100
+ mean_ms = round(statistics.mean(durations)) if durations else 0
101
+ return {
102
+ "tool": tool,
103
+ "runs": len(rows),
104
+ "passed": passed,
105
+ "failures": len(failures),
106
+ "passRate": pass_rate,
107
+ "meanMs": mean_ms,
108
+ "p95Ms": p95(durations),
109
+ }
110
+
111
+
112
+ def ratio(baseline_value, candidate_value):
113
+ if baseline_value <= 0 or candidate_value <= 0:
114
+ return None
115
+ return baseline_value / candidate_value
116
+
117
+
118
+ def percent_delta(candidate_value, baseline_value):
119
+ if baseline_value <= 0:
120
+ return None
121
+ return ((candidate_value - baseline_value) / baseline_value) * 100.0
122
+
123
+
124
+ def comparison(candidate, baseline):
125
+ return {
126
+ "candidate": candidate,
127
+ "baseline": baseline,
128
+ "meanSpeedup": ratio(baseline["meanMs"], candidate["meanMs"]),
129
+ "p95Speedup": ratio(baseline["p95Ms"], candidate["p95Ms"]),
130
+ "meanDeltaPct": percent_delta(candidate["meanMs"], baseline["meanMs"]),
131
+ "p95DeltaPct": percent_delta(candidate["p95Ms"], baseline["p95Ms"]),
132
+ }
133
+
134
+
135
+ def benchmark_context(candidate_rows, baseline_rows):
136
+ rows = candidate_rows + baseline_rows
137
+ context = {}
138
+ problems = []
139
+ for field in CONTEXT_FIELDS:
140
+ values = [str(row.get(field, "")).strip() for row in rows]
141
+ concrete = [value for value in values if value]
142
+ unique = sorted(set(concrete))
143
+ if len(concrete) != len(values):
144
+ problems.append(f"{field} missing")
145
+ elif len(unique) != 1:
146
+ problems.append(f"{field} mismatch: {', '.join(unique)}")
147
+ else:
148
+ context[field] = unique[0]
149
+ return {
150
+ "sameContext": not problems,
151
+ "context": context,
152
+ "contextProblems": problems,
153
+ }
154
+
155
+
156
+ def format_ratio(value):
157
+ return "n/a" if value is None else f"{value:.2f}x"
158
+
159
+
160
+ def format_pct(value):
161
+ return "n/a" if value is None else f"{value:+.1f}%"
162
+
163
+
164
+ def markdown_report(data):
165
+ candidate = data["candidate"]
166
+ baseline = data["baseline"]
167
+ lines = [
168
+ "# Benchmark Comparison",
169
+ "",
170
+ "| Tool | Runs | Pass rate | Failures | Mean ms | P95 ms |",
171
+ "| --- | ---: | ---: | ---: | ---: | ---: |",
172
+ f"| {candidate['tool']} | {candidate['runs']} | {candidate['passRate']:.2f}% | {candidate['failures']} | {candidate['meanMs']} | {candidate['p95Ms']} |",
173
+ f"| {baseline['tool']} | {baseline['runs']} | {baseline['passRate']:.2f}% | {baseline['failures']} | {baseline['meanMs']} | {baseline['p95Ms']} |",
174
+ "",
175
+ f"- Mean speedup: {format_ratio(data['meanSpeedup'])} ({format_pct(data['meanDeltaPct'])} candidate vs baseline)",
176
+ f"- P95 speedup: {format_ratio(data['p95Speedup'])} ({format_pct(data['p95DeltaPct'])} candidate vs baseline)",
177
+ f"- Same benchmark context: {'yes' if data.get('sameContext') else 'no'}",
178
+ "",
179
+ "Interpretation: negative deltas mean the candidate was faster for that metric. Compare only runs collected on the same host, device state, app build, and scenario.",
180
+ ]
181
+ return "\n".join(lines) + "\n"
182
+
183
+
184
+ def gate_failures(data, args):
185
+ failures = []
186
+ candidate = data["candidate"]
187
+ baseline = data["baseline"]
188
+ if args.evidence_out and candidate["runs"] < 20:
189
+ failures.append(f"candidateRuns {candidate['runs']} below minimum 20")
190
+ if args.evidence_out and baseline["runs"] < 20:
191
+ failures.append(f"baselineRuns {baseline['runs']} below minimum 20")
192
+ if args.min_candidate_pass_rate is not None and candidate["passRate"] < args.min_candidate_pass_rate:
193
+ failures.append(
194
+ f"candidate passRate {candidate['passRate']:.2f}% below minimum {args.min_candidate_pass_rate:.2f}%"
195
+ )
196
+ if args.max_candidate_failures is not None and candidate["failures"] > args.max_candidate_failures:
197
+ failures.append(
198
+ f"candidate failures={candidate['failures']} above maximum {args.max_candidate_failures}"
199
+ )
200
+ if args.min_mean_speedup is not None:
201
+ speedup = data["meanSpeedup"]
202
+ if speedup is None or speedup < args.min_mean_speedup:
203
+ actual = "n/a" if speedup is None else f"{speedup:.2f}x"
204
+ failures.append(f"meanSpeedup {actual} below minimum {args.min_mean_speedup:.2f}x")
205
+ if args.min_p95_speedup is not None:
206
+ speedup = data["p95Speedup"]
207
+ if speedup is None or speedup < args.min_p95_speedup:
208
+ actual = "n/a" if speedup is None else f"{speedup:.2f}x"
209
+ failures.append(f"p95Speedup {actual} below minimum {args.min_p95_speedup:.2f}x")
210
+ if args.evidence_out and not data.get("sameContext"):
211
+ details = "; ".join(data.get("contextProblems", [])) or "missing context"
212
+ failures.append(f"same benchmark context evidence required ({details})")
213
+ return failures
214
+
215
+
216
+ def write_evidence(args, data, failures, duration_ms):
217
+ if not args.evidence_out:
218
+ return
219
+ path = Path(args.evidence_out)
220
+ path.parent.mkdir(parents=True, exist_ok=True)
221
+ row = {
222
+ "name": "competitive benchmark comparison",
223
+ "status": "failed" if failures else "passed",
224
+ "durationMs": duration_ms,
225
+ "command": " ".join(shlex.quote(part) for part in sys.argv),
226
+ "candidate": args.candidate,
227
+ "baseline": args.baseline,
228
+ "results": args.results,
229
+ "minCandidatePassRate": args.min_candidate_pass_rate,
230
+ "maxCandidateFailures": args.max_candidate_failures,
231
+ "minMeanSpeedup": args.min_mean_speedup,
232
+ "minP95Speedup": args.min_p95_speedup,
233
+ "candidateRuns": data["candidate"]["runs"],
234
+ "baselineRuns": data["baseline"]["runs"],
235
+ "candidatePassRate": data["candidate"]["passRate"],
236
+ "candidateFailures": data["candidate"]["failures"],
237
+ "meanSpeedup": data["meanSpeedup"],
238
+ "p95Speedup": data["p95Speedup"],
239
+ "sameContext": data["sameContext"],
240
+ "context": data["context"],
241
+ }
242
+ if failures:
243
+ row["error"] = "; ".join(failures)
244
+ with path.open("a", encoding="utf-8") as handle:
245
+ handle.write(json.dumps(row, sort_keys=True, separators=(",", ":")) + "\n")
246
+
247
+
248
+ def main():
249
+ started = time.monotonic()
250
+ args = parse_args()
251
+ rows = read_rows(args.results)
252
+ by_tool = defaultdict(list)
253
+ for row in rows:
254
+ by_tool[str(row.get("tool", "unknown"))].append(row)
255
+
256
+ missing = [tool for tool in (args.candidate, args.baseline) if tool not in by_tool]
257
+ if missing:
258
+ print(f"missing benchmark rows for: {', '.join(missing)}", file=sys.stderr)
259
+ return 2
260
+
261
+ data = comparison(
262
+ summarize(args.candidate, by_tool[args.candidate]),
263
+ summarize(args.baseline, by_tool[args.baseline]),
264
+ )
265
+ data.update(benchmark_context(by_tool[args.candidate], by_tool[args.baseline]))
266
+
267
+ if args.format == "json":
268
+ output = json.dumps(data, sort_keys=True) + "\n"
269
+ else:
270
+ output = markdown_report(data)
271
+
272
+ if args.out:
273
+ Path(args.out).write_text(output, encoding="utf-8")
274
+ else:
275
+ sys.stdout.write(output)
276
+
277
+ failures = gate_failures(data, args)
278
+ duration_ms = round((time.monotonic() - started) * 1000)
279
+ write_evidence(args, data, failures, duration_ms)
280
+ if failures:
281
+ for failure in failures:
282
+ print(f"benchmark comparison gate failed: {failure}", file=sys.stderr)
283
+ return 1
284
+ return 0
285
+
286
+
287
+ if __name__ == "__main__":
288
+ raise SystemExit(main())
@@ -0,0 +1,342 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
+ OUT=""
6
+ APP_ID="com.example.mobiletest"
7
+ API="35"
8
+ BUILD_TOOLS="35.0.1"
9
+ ANDROID_SDK="${ANDROID_HOME:-$HOME/Library/Android/sdk}"
10
+ DRY_RUN=0
11
+
12
+ usage() {
13
+ cat <<'USAGE'
14
+ Usage:
15
+ scripts/create-android-demo-app.sh --out <dir> [options]
16
+
17
+ Creates a small public native Android demo app and a matching .zmr smoke
18
+ scenario. The generated app is intentionally generic and contains no private
19
+ app references. It uses Android SDK command-line tools directly, so it does not
20
+ need Gradle or network access.
21
+
22
+ Options:
23
+ --out <dir> Output app repository directory. Required.
24
+ --app-id <id> Android application id. Default: com.example.mobiletest.
25
+ --api <level> Android platform API level. Default: 35.
26
+ --build-tools <ver> Android build-tools version. Default: 35.0.1.
27
+ --android-sdk <path> Android SDK root. Default: ANDROID_HOME or ~/Library/Android/sdk.
28
+ --dry-run Print commands without executing them.
29
+ -h, --help Show this help.
30
+
31
+ After generation:
32
+ adb install -r <dir>/build/app-debug.apk
33
+ zmr run <dir>/.zmr/android-smoke.json --device emulator-5554 --app-id com.example.mobiletest --trace-dir <dir>/traces/android-demo
34
+ USAGE
35
+ }
36
+
37
+ die() {
38
+ echo "error: $*" >&2
39
+ exit 2
40
+ }
41
+
42
+ require_value() {
43
+ local flag="$1"
44
+ local value="${2-}"
45
+ if [[ -z "$value" || "$value" == --* ]]; then
46
+ die "$flag requires a value"
47
+ fi
48
+ printf '%s\n' "$value"
49
+ }
50
+
51
+ quote_cmd() {
52
+ local quoted=()
53
+ local arg
54
+ for arg in "$@"; do
55
+ quoted+=("$(printf '%q' "$arg")")
56
+ done
57
+ printf '%s\n' "${quoted[*]}"
58
+ }
59
+
60
+ run() {
61
+ echo "+ $(quote_cmd "$@")"
62
+ if [[ "$DRY_RUN" -eq 0 ]]; then
63
+ "$@"
64
+ fi
65
+ }
66
+
67
+ write_file() {
68
+ local path="$1"
69
+ local content="$2"
70
+ echo "+ write $path"
71
+ if [[ "$DRY_RUN" -eq 0 ]]; then
72
+ mkdir -p "$(dirname "$path")"
73
+ printf '%s' "$content" > "$path"
74
+ fi
75
+ }
76
+
77
+ while [[ $# -gt 0 ]]; do
78
+ case "$1" in
79
+ --out)
80
+ OUT="$(require_value "$1" "${2-}")"
81
+ shift 2
82
+ ;;
83
+ --app-id)
84
+ APP_ID="$(require_value "$1" "${2-}")"
85
+ shift 2
86
+ ;;
87
+ --api)
88
+ API="$(require_value "$1" "${2-}")"
89
+ shift 2
90
+ ;;
91
+ --build-tools)
92
+ BUILD_TOOLS="$(require_value "$1" "${2-}")"
93
+ shift 2
94
+ ;;
95
+ --android-sdk)
96
+ ANDROID_SDK="$(require_value "$1" "${2-}")"
97
+ shift 2
98
+ ;;
99
+ --dry-run)
100
+ DRY_RUN=1
101
+ shift
102
+ ;;
103
+ -h|--help)
104
+ usage
105
+ exit 0
106
+ ;;
107
+ *)
108
+ die "unknown argument: $1"
109
+ ;;
110
+ esac
111
+ done
112
+
113
+ [[ -n "$OUT" ]] || die "--out is required"
114
+ [[ "$APP_ID" =~ ^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$ ]] || die "--app-id must be a Java-style package id"
115
+ [[ "$API" =~ ^[0-9]+$ ]] || die "--api must be an integer"
116
+ [[ -n "$BUILD_TOOLS" ]] || die "--build-tools must be non-empty"
117
+
118
+ if [[ "$OUT" != /* ]]; then
119
+ OUT="$(pwd -P)/$OUT"
120
+ fi
121
+
122
+ ANDROID_DIR="$OUT/android"
123
+ SRC_DIR="$ANDROID_DIR/src/dev/zmr/demo"
124
+ RES_DIR="$ANDROID_DIR/res"
125
+ BUILD_DIR="$OUT/build"
126
+ GEN_DIR="$BUILD_DIR/generated"
127
+ CLASSES_DIR="$BUILD_DIR/classes"
128
+ DEX_DIR="$BUILD_DIR/dex"
129
+ COMPILED_RES="$BUILD_DIR/compiled-res.zip"
130
+ UNSIGNED_APK="$BUILD_DIR/app-unsigned.apk"
131
+ SIGNED_APK="$BUILD_DIR/app-debug.apk"
132
+ KEYSTORE="$BUILD_DIR/debug.keystore"
133
+ ANDROID_JAR="$ANDROID_SDK/platforms/android-$API/android.jar"
134
+ BUILD_TOOLS_DIR="$ANDROID_SDK/build-tools/$BUILD_TOOLS"
135
+ AAPT2="$BUILD_TOOLS_DIR/aapt2"
136
+ D8="$BUILD_TOOLS_DIR/d8"
137
+ APKSIGNER="$BUILD_TOOLS_DIR/apksigner"
138
+ ZMR_BIN="${ZMR_BIN:-}"
139
+
140
+ echo "Android demo app: $OUT"
141
+ echo "Android demo APK: $SIGNED_APK"
142
+ if [[ "$DRY_RUN" -eq 1 ]]; then
143
+ echo "DRY RUN: commands will be printed but not executed"
144
+ fi
145
+
146
+ if [[ "$DRY_RUN" -eq 0 ]]; then
147
+ [[ -f "$ANDROID_JAR" ]] || die "android.jar not found: $ANDROID_JAR"
148
+ [[ -x "$AAPT2" ]] || die "aapt2 not found: $AAPT2"
149
+ [[ -x "$D8" ]] || die "d8 not found: $D8"
150
+ [[ -x "$APKSIGNER" ]] || die "apksigner not found: $APKSIGNER"
151
+ command -v javac >/dev/null 2>&1 || die "javac is required"
152
+ command -v keytool >/dev/null 2>&1 || die "keytool is required"
153
+ command -v zip >/dev/null 2>&1 || die "zip is required"
154
+ fi
155
+
156
+ if [[ "$DRY_RUN" -eq 0 ]]; then
157
+ rm -rf "$BUILD_DIR"
158
+ fi
159
+ run mkdir -p "$SRC_DIR" "$RES_DIR/values" "$BUILD_DIR" "$GEN_DIR" "$CLASSES_DIR" "$DEX_DIR" "$OUT/.zmr"
160
+
161
+ write_file "$ANDROID_DIR/AndroidManifest.xml" "$(cat <<EOF
162
+ <?xml version="1.0" encoding="utf-8"?>
163
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="$APP_ID">
164
+ <uses-sdk android:minSdkVersion="23" android:targetSdkVersion="$API" />
165
+ <application android:theme="@style/AppTheme" android:label="ZMR Android Demo" android:allowBackup="false" android:supportsRtl="true">
166
+ <activity android:name="dev.zmr.demo.MainActivity" android:exported="true">
167
+ <intent-filter>
168
+ <action android:name="android.intent.action.MAIN" />
169
+ <category android:name="android.intent.category.LAUNCHER" />
170
+ </intent-filter>
171
+ <intent-filter>
172
+ <action android:name="android.intent.action.VIEW" />
173
+ <category android:name="android.intent.category.DEFAULT" />
174
+ <category android:name="android.intent.category.BROWSABLE" />
175
+ <data android:scheme="exampleapp" />
176
+ </intent-filter>
177
+ </activity>
178
+ </application>
179
+ </manifest>
180
+ EOF
181
+ )"
182
+
183
+ write_file "$RES_DIR/values/styles.xml" "$(cat <<'EOF'
184
+ <?xml version="1.0" encoding="utf-8"?>
185
+ <resources>
186
+ <style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
187
+ <item name="android:fontFamily">sans</item>
188
+ <item name="android:windowLightStatusBar">true</item>
189
+ <item name="android:colorAccent">#2563EB</item>
190
+ </style>
191
+ </resources>
192
+ EOF
193
+ )"
194
+
195
+ write_file "$RES_DIR/values/ids.xml" "$(cat <<'EOF'
196
+ <?xml version="1.0" encoding="utf-8"?>
197
+ <resources>
198
+ <item name="demo_title" type="id" />
199
+ <item name="continue_button" type="id" />
200
+ <item name="demo_input" type="id" />
201
+ <item name="demo_status" type="id" />
202
+ </resources>
203
+ EOF
204
+ )"
205
+
206
+ write_file "$SRC_DIR/MainActivity.java" "$(cat <<EOF
207
+ package dev.zmr.demo;
208
+
209
+ import android.app.Activity;
210
+ import android.graphics.Color;
211
+ import android.net.Uri;
212
+ import android.os.Bundle;
213
+ import android.view.View;
214
+ import android.view.Gravity;
215
+ import android.view.inputmethod.InputMethodManager;
216
+ import android.content.Context;
217
+ import android.widget.Button;
218
+ import android.widget.EditText;
219
+ import android.widget.LinearLayout;
220
+ import android.widget.TextView;
221
+
222
+ public class MainActivity extends Activity {
223
+ private TextView status;
224
+ private EditText input;
225
+
226
+ @Override
227
+ protected void onCreate(Bundle savedInstanceState) {
228
+ super.onCreate(savedInstanceState);
229
+
230
+ LinearLayout layout = new LinearLayout(this);
231
+ layout.setOrientation(LinearLayout.VERTICAL);
232
+ layout.setGravity(Gravity.CENTER_HORIZONTAL);
233
+ int padding = dp(24);
234
+ layout.setPadding(padding, padding, padding, padding);
235
+
236
+ TextView title = new TextView(this);
237
+ title.setId(R.id.demo_title);
238
+ title.setText("ZMR Android Demo");
239
+ title.setTextSize(24);
240
+ title.setTextColor(Color.rgb(17, 24, 39));
241
+ title.setGravity(Gravity.CENTER);
242
+ layout.addView(title, new LinearLayout.LayoutParams(-1, -2));
243
+
244
+ Button button = new Button(this);
245
+ button.setId(R.id.continue_button);
246
+ button.setText("Continue");
247
+ layout.addView(button, new LinearLayout.LayoutParams(-1, dp(56)));
248
+
249
+ input = new EditText(this);
250
+ input.setId(R.id.demo_input);
251
+ input.setHint("Type here");
252
+ input.setSingleLine(true);
253
+ layout.addView(input, new LinearLayout.LayoutParams(-1, dp(56)));
254
+
255
+ status = new TextView(this);
256
+ status.setId(R.id.demo_status);
257
+ status.setText("Ready");
258
+ status.setTextSize(18);
259
+ status.setGravity(Gravity.CENTER);
260
+ layout.addView(status, new LinearLayout.LayoutParams(-1, -2));
261
+
262
+ button.setOnClickListener(new View.OnClickListener() {
263
+ @Override
264
+ public void onClick(View view) {
265
+ status.setText("Continue tapped");
266
+ input.requestFocus();
267
+ InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
268
+ if (imm != null) {
269
+ imm.showSoftInput(input, InputMethodManager.SHOW_IMPLICIT);
270
+ }
271
+ }
272
+ });
273
+
274
+ Uri data = getIntent().getData();
275
+ if (data != null) {
276
+ status.setText("Deep link opened");
277
+ }
278
+
279
+ setContentView(layout);
280
+ }
281
+
282
+ private int dp(int value) {
283
+ return (int) (value * getResources().getDisplayMetrics().density + 0.5f);
284
+ }
285
+ }
286
+ EOF
287
+ )"
288
+
289
+ write_file "$OUT/.zmr/android-smoke.json" "$(cat <<EOF
290
+ {
291
+ "name": "ZMR Android demo smoke",
292
+ "appId": "$APP_ID",
293
+ "steps": [
294
+ { "action": "launch" },
295
+ { "action": "waitVisible", "selector": { "text": "ZMR Android Demo" }, "timeoutMs": 30000 },
296
+ { "action": "tap", "selector": { "resourceId": "$APP_ID:id/continue_button" } },
297
+ { "action": "waitVisible", "selector": { "text": "Continue tapped" }, "timeoutMs": 10000 },
298
+ { "action": "typeText", "selector": { "resourceId": "$APP_ID:id/demo_input" }, "text": "hello from zmr" },
299
+ { "action": "snapshot" }
300
+ ]
301
+ }
302
+ EOF
303
+ )"
304
+
305
+ run "$AAPT2" compile --dir "$RES_DIR" -o "$COMPILED_RES"
306
+ run "$AAPT2" link -o "$UNSIGNED_APK" -I "$ANDROID_JAR" --manifest "$ANDROID_DIR/AndroidManifest.xml" -R "$COMPILED_RES" --java "$GEN_DIR" --custom-package dev.zmr.demo --auto-add-overlay
307
+ run javac -source 1.8 -target 1.8 -bootclasspath "$ANDROID_JAR" -d "$CLASSES_DIR" "$GEN_DIR/dev/zmr/demo/R.java" "$SRC_DIR/MainActivity.java"
308
+ if [[ "$DRY_RUN" -eq 1 ]]; then
309
+ CLASS_FILES=(
310
+ "$CLASSES_DIR/dev/zmr/demo/R.class"
311
+ "$CLASSES_DIR/dev/zmr/demo/MainActivity.class"
312
+ "$CLASSES_DIR/dev/zmr/demo/MainActivity\$1.class"
313
+ )
314
+ else
315
+ CLASS_FILES=()
316
+ while IFS= read -r class_file; do
317
+ CLASS_FILES+=("$class_file")
318
+ done < <(find "$CLASSES_DIR" -name '*.class' -print | sort)
319
+ [[ "${#CLASS_FILES[@]}" -gt 0 ]] || die "no compiled Java classes found in $CLASSES_DIR"
320
+ fi
321
+
322
+ run "$D8" --lib "$ANDROID_JAR" --min-api 23 --output "$DEX_DIR" "${CLASS_FILES[@]}"
323
+ run zip -j "$UNSIGNED_APK" "$DEX_DIR/classes.dex"
324
+ run keytool -genkeypair -keystore "$KEYSTORE" -storepass android -keypass android -alias zmrdebug -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=ZMR Android Demo,O=ZMR,C=US"
325
+ run "$APKSIGNER" sign --ks "$KEYSTORE" --ks-key-alias zmrdebug --ks-pass pass:android --key-pass pass:android --out "$SIGNED_APK" "$UNSIGNED_APK"
326
+ if [[ -z "$ZMR_BIN" ]]; then
327
+ if [[ -x "$ROOT/zig-out/bin/zmr" ]]; then
328
+ ZMR_BIN="$ROOT/zig-out/bin/zmr"
329
+ elif command -v zmr >/dev/null 2>&1; then
330
+ ZMR_BIN="$(command -v zmr)"
331
+ fi
332
+ fi
333
+
334
+ if [[ -n "$ZMR_BIN" ]]; then
335
+ run "$ZMR_BIN" validate "$OUT/.zmr/android-smoke.json"
336
+ else
337
+ echo "warning: skipped scenario validation because zmr was not found; run 'zmr validate $OUT/.zmr/android-smoke.json' after installation" >&2
338
+ fi
339
+
340
+ echo "created Android demo app at $OUT"
341
+ echo "apk: $SIGNED_APK"
342
+ echo "scenario: $OUT/.zmr/android-smoke.json"