zeno-mobile-runner 0.1.8 → 0.2.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 (62) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/FEATURES.md +1 -1
  3. package/README.md +167 -238
  4. package/clients/kotlin/README.md +1 -1
  5. package/clients/kotlin/build.gradle.kts +1 -1
  6. package/clients/python/pyproject.toml +1 -1
  7. package/clients/rust/Cargo.lock +1 -1
  8. package/clients/rust/Cargo.toml +1 -1
  9. package/clients/typescript/package.json +1 -1
  10. package/docs/agent-discovery.md +10 -0
  11. package/docs/ai-agents.md +18 -0
  12. package/docs/benchmarking.md +39 -0
  13. package/docs/benchmarks/2026-06-09-android-workflow.md +73 -0
  14. package/docs/benchmarks/2026-06-09-android-workflow.results.jsonl +20 -0
  15. package/docs/benchmarks/2026-06-09-framework-baseline-status.md +32 -0
  16. package/docs/benchmarks/2026-06-09-ios-appium-comparison.md +115 -0
  17. package/docs/benchmarks/2026-06-09-ios-appium-comparison.results.jsonl +40 -0
  18. package/docs/benchmarks/2026-06-09-ios-demo.md +90 -0
  19. package/docs/benchmarks/2026-06-09-ios-demo.results.jsonl +20 -0
  20. package/docs/benchmarks/2026-06-09-ios-maestro-comparison.md +128 -0
  21. package/docs/benchmarks/2026-06-09-ios-maestro-comparison.results.jsonl +40 -0
  22. package/docs/benchmarks/2026-06-09-ios-workflow-comparison.md +143 -0
  23. package/docs/benchmarks/2026-06-09-ios-workflow-comparison.results.jsonl +40 -0
  24. package/docs/benchmarks/2026-06-09-ios-xctest-floor.md +106 -0
  25. package/docs/benchmarks/2026-06-09-ios-xctest-floor.results.jsonl +40 -0
  26. package/docs/benchmarks/README.md +36 -0
  27. package/docs/benchmarks/benchmark-lab-v1.json +155 -0
  28. package/docs/benchmarks/benchmark-lab-v1.md +95 -0
  29. package/docs/clients.md +16 -0
  30. package/docs/demo.md +36 -1
  31. package/docs/frameworks.md +10 -0
  32. package/docs/npm.md +44 -2
  33. package/docs/protocol-fixtures/core-session.responses.jsonl +1 -1
  34. package/docs/protocol.md +10 -10
  35. package/docs/scenario-authoring.md +15 -0
  36. package/docs/trace-privacy.md +9 -0
  37. package/docs/troubleshooting.md +6 -0
  38. package/examples/android-workflow.json +79 -0
  39. package/examples/ios-shim-workflow.json +79 -0
  40. package/examples/react-native-expo-workflow.json +75 -0
  41. package/package.json +6 -1
  42. package/prebuilds/darwin-arm64/zmr +0 -0
  43. package/prebuilds/darwin-x64/zmr +0 -0
  44. package/prebuilds/linux-arm64/zmr +0 -0
  45. package/prebuilds/linux-x64/zmr +0 -0
  46. package/scripts/benchmark-lab.py +253 -0
  47. package/scripts/create-android-demo-app.sh +324 -29
  48. package/scripts/create-ios-demo-app.sh +174 -7
  49. package/scripts/create-react-native-expo-demo-app.sh +727 -0
  50. package/scripts/demo.sh +3 -0
  51. package/scripts/install-ios-shim.sh +2 -2
  52. package/shims/ios/ZMRShim.swift +10 -0
  53. package/shims/ios/ZMRShimUITestCase.swift +42 -0
  54. package/shims/ios/protocol.md +1 -0
  55. package/src/cli_import.zig +31 -15
  56. package/src/cli_trace.zig +38 -16
  57. package/src/cli_validate.zig +12 -6
  58. package/src/ios.zig +49 -12
  59. package/src/ios_shim.zig +36 -2
  60. package/src/main.zig +3 -0
  61. package/src/version.zig +1 -1
  62. package/viewer/app.js +23 -3
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "ZMR Android workflow demo",
3
+ "appId": "com.example.mobiletest",
4
+ "steps": [
5
+ { "action": "stop" },
6
+ { "action": "launch" },
7
+ {
8
+ "action": "waitVisible",
9
+ "selector": { "text": "ZMR Android Demo" },
10
+ "timeoutMs": 30000
11
+ },
12
+ {
13
+ "action": "tap",
14
+ "selector": { "resourceId": "com.example.mobiletest:id/continue_button" }
15
+ },
16
+ {
17
+ "action": "waitVisible",
18
+ "selector": { "text": "Profile" },
19
+ "timeoutMs": 10000
20
+ },
21
+ {
22
+ "action": "typeText",
23
+ "selector": { "resourceId": "com.example.mobiletest:id/profile_name_input" },
24
+ "text": "Riley"
25
+ },
26
+ {
27
+ "action": "typeText",
28
+ "selector": { "resourceId": "com.example.mobiletest:id/profile_email_input" },
29
+ "text": "riley@example.test"
30
+ },
31
+ { "action": "hideKeyboard" },
32
+ {
33
+ "action": "tap",
34
+ "selector": { "resourceId": "com.example.mobiletest:id/save_profile_button" }
35
+ },
36
+ {
37
+ "action": "waitVisible",
38
+ "selector": { "text": "Catalog" },
39
+ "timeoutMs": 10000
40
+ },
41
+ {
42
+ "action": "scrollUntilVisible",
43
+ "selector": { "resourceId": "com.example.mobiletest:id/catalog_item_north_ridge_pack" },
44
+ "direction": "down",
45
+ "timeoutMs": 10000
46
+ },
47
+ {
48
+ "action": "tap",
49
+ "selector": { "resourceId": "com.example.mobiletest:id/catalog_item_north_ridge_pack" }
50
+ },
51
+ {
52
+ "action": "waitVisible",
53
+ "selector": { "text": "North Ridge Pack" },
54
+ "timeoutMs": 10000
55
+ },
56
+ {
57
+ "action": "tap",
58
+ "selector": { "resourceId": "com.example.mobiletest:id/detail_save_button" }
59
+ },
60
+ {
61
+ "action": "waitVisible",
62
+ "selector": { "text": "Saved North Ridge Pack" },
63
+ "timeoutMs": 10000
64
+ },
65
+ {
66
+ "action": "tap",
67
+ "selector": { "resourceId": "com.example.mobiletest:id/review_button" }
68
+ },
69
+ {
70
+ "action": "assertVisible",
71
+ "selector": {
72
+ "resourceId": "com.example.mobiletest:id/workflow_status",
73
+ "text": "Workflow complete"
74
+ },
75
+ "timeoutMs": 10000
76
+ },
77
+ { "action": "snapshot" }
78
+ ]
79
+ }
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "ZMR iOS shim workflow demo",
3
+ "appId": "com.example.mobiletest",
4
+ "steps": [
5
+ { "action": "stop" },
6
+ { "action": "launch" },
7
+ {
8
+ "action": "waitVisible",
9
+ "selector": { "text": "ZMR iOS Demo" },
10
+ "timeoutMs": 30000
11
+ },
12
+ {
13
+ "action": "tap",
14
+ "selector": { "resourceId": "continue_button" }
15
+ },
16
+ {
17
+ "action": "waitVisible",
18
+ "selector": { "text": "Profile" },
19
+ "timeoutMs": 10000
20
+ },
21
+ {
22
+ "action": "typeText",
23
+ "selector": { "resourceId": "profile_name_input" },
24
+ "text": "Riley"
25
+ },
26
+ {
27
+ "action": "typeText",
28
+ "selector": { "resourceId": "profile_email_input" },
29
+ "text": "riley@example.test"
30
+ },
31
+ { "action": "hideKeyboard" },
32
+ {
33
+ "action": "tap",
34
+ "selector": { "resourceId": "save_profile_button" }
35
+ },
36
+ {
37
+ "action": "waitVisible",
38
+ "selector": { "text": "Catalog" },
39
+ "timeoutMs": 10000
40
+ },
41
+ {
42
+ "action": "scrollUntilVisible",
43
+ "selector": { "resourceId": "catalog_item_north_ridge_pack" },
44
+ "direction": "down",
45
+ "timeoutMs": 10000
46
+ },
47
+ {
48
+ "action": "tap",
49
+ "selector": { "resourceId": "catalog_item_north_ridge_pack" }
50
+ },
51
+ {
52
+ "action": "waitVisible",
53
+ "selector": { "text": "North Ridge Pack" },
54
+ "timeoutMs": 10000
55
+ },
56
+ {
57
+ "action": "tap",
58
+ "selector": { "resourceId": "detail_save_button" }
59
+ },
60
+ {
61
+ "action": "waitVisible",
62
+ "selector": { "text": "Saved North Ridge Pack" },
63
+ "timeoutMs": 10000
64
+ },
65
+ {
66
+ "action": "tap",
67
+ "selector": { "resourceId": "review_button" }
68
+ },
69
+ {
70
+ "action": "assertVisible",
71
+ "selector": {
72
+ "resourceId": "workflow_status",
73
+ "text": "Workflow complete"
74
+ },
75
+ "timeoutMs": 10000
76
+ },
77
+ { "action": "snapshot" }
78
+ ]
79
+ }
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "ZMR React Native Expo workflow demo",
3
+ "appId": "com.example.mobiletest",
4
+ "steps": [
5
+ { "action": "openLink", "url": "zenoexpodemo://benchmark" },
6
+ {
7
+ "action": "waitVisible",
8
+ "selector": { "text": "Zeno Expo Demo" },
9
+ "timeoutMs": 30000
10
+ },
11
+ {
12
+ "action": "tap",
13
+ "selector": { "contentDesc": "continue_button" }
14
+ },
15
+ {
16
+ "action": "waitVisible",
17
+ "selector": { "text": "Profile" },
18
+ "timeoutMs": 10000
19
+ },
20
+ {
21
+ "action": "typeText",
22
+ "selector": { "contentDesc": "profile_name_input" },
23
+ "text": "Riley"
24
+ },
25
+ {
26
+ "action": "typeText",
27
+ "selector": { "contentDesc": "profile_email_input" },
28
+ "text": "riley@example.test"
29
+ },
30
+ { "action": "hideKeyboard" },
31
+ {
32
+ "action": "tap",
33
+ "selector": { "contentDesc": "save_profile_button" }
34
+ },
35
+ {
36
+ "action": "waitVisible",
37
+ "selector": { "text": "Catalog" },
38
+ "timeoutMs": 10000
39
+ },
40
+ {
41
+ "action": "scrollUntilVisible",
42
+ "selector": { "contentDesc": "catalog_item_north_ridge_pack" },
43
+ "direction": "down",
44
+ "timeoutMs": 10000
45
+ },
46
+ {
47
+ "action": "tap",
48
+ "selector": { "contentDesc": "catalog_item_north_ridge_pack" }
49
+ },
50
+ {
51
+ "action": "waitVisible",
52
+ "selector": { "text": "North Ridge Pack" },
53
+ "timeoutMs": 10000
54
+ },
55
+ {
56
+ "action": "tap",
57
+ "selector": { "contentDesc": "detail_save_button" }
58
+ },
59
+ {
60
+ "action": "waitVisible",
61
+ "selector": { "text": "Saved North Ridge Pack" },
62
+ "timeoutMs": 10000
63
+ },
64
+ {
65
+ "action": "tap",
66
+ "selector": { "contentDesc": "review_button" }
67
+ },
68
+ {
69
+ "action": "assertVisible",
70
+ "selector": { "text": "Workflow complete" },
71
+ "timeoutMs": 10000
72
+ },
73
+ { "action": "snapshot" }
74
+ ]
75
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zeno-mobile-runner",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
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": {
@@ -16,6 +16,7 @@
16
16
  "zmr-init": "npm/init-app.mjs",
17
17
  "zmr-wizard": "npm/wizard.mjs",
18
18
  "zmr-benchmark": "scripts/benchmark.sh",
19
+ "zmr-benchmark-lab": "scripts/benchmark-lab.py",
19
20
  "zmr-benchmark-command": "scripts/benchmark-command.sh",
20
21
  "zmr-compare-benchmarks": "scripts/compare-benchmarks.py",
21
22
  "zmr-device-matrix": "scripts/device-matrix.sh",
@@ -26,6 +27,7 @@
26
27
  "zmr-install-ios-shim": "scripts/install-ios-shim.sh",
27
28
  "zmr-create-android-demo-app": "scripts/create-android-demo-app.sh",
28
29
  "zmr-create-ios-demo-app": "scripts/create-ios-demo-app.sh",
30
+ "zmr-create-react-native-expo-demo-app": "scripts/create-react-native-expo-demo-app.sh",
29
31
  "zmr-demo-android": "scripts/demo-android-real.sh",
30
32
  "zmr-demo-ios": "scripts/demo-ios-real.sh"
31
33
  },
@@ -63,9 +65,12 @@
63
65
  "shims/",
64
66
  "viewer/",
65
67
  "docs/",
68
+ "!docs/assets/",
66
69
  "scripts/",
67
70
  "!scripts/build-npm-package.sh",
68
71
  "!scripts/build-release.sh",
72
+ "!scripts/capture-screenshots.sh",
73
+ "!scripts/capture-viewer-shots.mjs",
69
74
  "!scripts/ci-gate.sh",
70
75
  "!scripts/coverage.sh",
71
76
  "!scripts/generate-homebrew-formula.mjs",
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,253 @@
1
+ #!/usr/bin/env python3
2
+ import argparse
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+
7
+
8
+ FIXTURE_STATUSES = {"planned", "fixture-available", "evidence-committed"}
9
+ ADAPTER_STATUSES = {"planned", "partial", "available", "evidence-committed"}
10
+ SLICE_STATUSES = {"next", "later", "done"}
11
+
12
+
13
+ def parse_args():
14
+ parser = argparse.ArgumentParser(
15
+ description="Validate and render a ZMR benchmark lab manifest.",
16
+ )
17
+ parser.add_argument(
18
+ "--manifest",
19
+ default="docs/benchmarks/benchmark-lab-v1.json",
20
+ help="Benchmark lab manifest path.",
21
+ )
22
+ parser.add_argument(
23
+ "--format",
24
+ choices=("json", "markdown"),
25
+ default="markdown",
26
+ help="Output format.",
27
+ )
28
+ parser.add_argument("--out", help="Optional output file. Defaults to stdout.")
29
+ return parser.parse_args()
30
+
31
+
32
+ def require_object(value, path):
33
+ if not isinstance(value, dict):
34
+ raise ValueError(f"{path} must be an object")
35
+
36
+
37
+ def require_array(value, path):
38
+ if not isinstance(value, list):
39
+ raise ValueError(f"{path} must be an array")
40
+
41
+
42
+ def require_string(value, path):
43
+ if not isinstance(value, str) or not value.strip():
44
+ raise ValueError(f"{path} must be a non-empty string")
45
+ return value.strip()
46
+
47
+
48
+ def validate_unique_ids(items, path):
49
+ seen = set()
50
+ for index, item in enumerate(items):
51
+ require_object(item, f"{path}[{index}]")
52
+ item_id = require_string(item.get("id"), f"{path}[{index}].id")
53
+ if item_id in seen:
54
+ raise ValueError(f"{path}[{index}].id is duplicated: {item_id}")
55
+ seen.add(item_id)
56
+
57
+
58
+ def validate_status(value, allowed, path):
59
+ actual = require_string(value, path)
60
+ if actual not in allowed:
61
+ raise ValueError(f"{path} must be one of: {', '.join(sorted(allowed))}")
62
+
63
+
64
+ def validate_manifest(data):
65
+ require_object(data, "$")
66
+ if data.get("schemaVersion") != 1:
67
+ raise ValueError("$.schemaVersion must be 1")
68
+ require_string(data.get("name"), "$.name")
69
+ require_string(data.get("purpose"), "$.purpose")
70
+ require_object(data.get("claimPolicy"), "$.claimPolicy")
71
+
72
+ policy = data["claimPolicy"]
73
+ if int(policy.get("minimumRuns", 0)) < 1:
74
+ raise ValueError("$.claimPolicy.minimumRuns must be positive")
75
+ if float(policy.get("candidatePassRate", -1)) < 0:
76
+ raise ValueError("$.claimPolicy.candidatePassRate must be non-negative")
77
+ if int(policy.get("candidateFailures", -1)) < 0:
78
+ raise ValueError("$.claimPolicy.candidateFailures must be non-negative")
79
+ if policy.get("requiresSameContext") is not True:
80
+ raise ValueError("$.claimPolicy.requiresSameContext must be true")
81
+
82
+ modes = data.get("modes")
83
+ fixtures = data.get("fixtures")
84
+ adapters = data.get("runnerAdapters")
85
+ slices = data.get("nextSlices")
86
+ require_array(modes, "$.modes")
87
+ require_array(fixtures, "$.fixtures")
88
+ require_array(adapters, "$.runnerAdapters")
89
+ require_array(slices, "$.nextSlices")
90
+ validate_unique_ids(modes, "$.modes")
91
+ validate_unique_ids(fixtures, "$.fixtures")
92
+ validate_unique_ids(adapters, "$.runnerAdapters")
93
+ validate_unique_ids(slices, "$.nextSlices")
94
+
95
+ mode_ids = {mode["id"] for mode in modes}
96
+ for index, mode in enumerate(modes):
97
+ require_string(mode.get("label"), f"$.modes[{index}].label")
98
+ require_string(mode.get("description"), f"$.modes[{index}].description")
99
+
100
+ for index, fixture in enumerate(fixtures):
101
+ require_string(fixture.get("label"), f"$.fixtures[{index}].label")
102
+ require_string(fixture.get("framework"), f"$.fixtures[{index}].framework")
103
+ validate_status(fixture.get("status"), FIXTURE_STATUSES, f"$.fixtures[{index}].status")
104
+ require_array(fixture.get("platforms"), f"$.fixtures[{index}].platforms")
105
+ require_array(fixture.get("workflow"), f"$.fixtures[{index}].workflow")
106
+ if fixture["status"] != "planned" and not fixture.get("scenario"):
107
+ raise ValueError(f"$.fixtures[{index}].scenario is required unless status is planned")
108
+
109
+ for index, adapter in enumerate(adapters):
110
+ require_string(adapter.get("label"), f"$.runnerAdapters[{index}].label")
111
+ validate_status(adapter.get("status"), ADAPTER_STATUSES, f"$.runnerAdapters[{index}].status")
112
+ require_string(adapter.get("collector"), f"$.runnerAdapters[{index}].collector")
113
+ require_array(adapter.get("modes"), f"$.runnerAdapters[{index}].modes")
114
+ for mode in adapter["modes"]:
115
+ if mode not in mode_ids:
116
+ raise ValueError(f"$.runnerAdapters[{index}].modes references unknown mode: {mode}")
117
+
118
+ for index, next_slice in enumerate(slices):
119
+ validate_status(next_slice.get("status"), SLICE_STATUSES, f"$.nextSlices[{index}].status")
120
+ require_string(next_slice.get("description"), f"$.nextSlices[{index}].description")
121
+
122
+
123
+ def read_manifest(path):
124
+ try:
125
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
126
+ except json.JSONDecodeError as exc:
127
+ raise ValueError(f"{path}:{exc.lineno}:{exc.colno}: invalid json: {exc.msg}") from exc
128
+ validate_manifest(data)
129
+ return data
130
+
131
+
132
+ def summary(data):
133
+ fixtures = data["fixtures"]
134
+ adapters = data["runnerAdapters"]
135
+ next_slices = data["nextSlices"]
136
+ return {
137
+ "ok": True,
138
+ "name": data["name"],
139
+ "schemaVersion": data["schemaVersion"],
140
+ "fixtureCount": len(fixtures),
141
+ "adapterCount": len(adapters),
142
+ "modeCount": len(data["modes"]),
143
+ "evidenceFixtures": [fixture["id"] for fixture in fixtures if fixture["status"] == "evidence-committed"],
144
+ "availableFixtures": [fixture["id"] for fixture in fixtures if fixture["status"] in ("fixture-available", "evidence-committed")],
145
+ "plannedFixtures": [fixture["id"] for fixture in fixtures if fixture["status"] == "planned"],
146
+ "nextSlices": [item["id"] for item in next_slices if item["status"] == "next"],
147
+ "minimumRuns": data["claimPolicy"]["minimumRuns"],
148
+ "candidatePassRate": data["claimPolicy"]["candidatePassRate"],
149
+ "candidateFailures": data["claimPolicy"]["candidateFailures"],
150
+ }
151
+
152
+
153
+ def markdown_table(headers, rows):
154
+ lines = [
155
+ "| " + " | ".join(headers) + " |",
156
+ "| " + " | ".join("---" for _ in headers) + " |",
157
+ ]
158
+ for row in rows:
159
+ lines.append("| " + " | ".join(row) + " |")
160
+ return "\n".join(lines)
161
+
162
+
163
+ def render_markdown(data):
164
+ fixtures = [
165
+ [
166
+ fixture["id"],
167
+ fixture["framework"],
168
+ ", ".join(fixture["platforms"]),
169
+ fixture["status"],
170
+ fixture.get("scenario", "pending"),
171
+ ]
172
+ for fixture in data["fixtures"]
173
+ ]
174
+ adapters = [
175
+ [
176
+ adapter["id"],
177
+ adapter["status"],
178
+ adapter["collector"],
179
+ ", ".join(adapter["modes"]),
180
+ ]
181
+ for adapter in data["runnerAdapters"]
182
+ ]
183
+ modes = [
184
+ [mode["id"], mode["label"], mode["description"]]
185
+ for mode in data["modes"]
186
+ ]
187
+ next_slices = [
188
+ [item["id"], item["status"], item["description"]]
189
+ for item in data["nextSlices"]
190
+ ]
191
+ policy = data["claimPolicy"]
192
+ return "\n".join(
193
+ [
194
+ f"# {data['name']}",
195
+ "",
196
+ data["purpose"],
197
+ "",
198
+ "## Claim Policy",
199
+ "",
200
+ f"- Minimum runs: {policy['minimumRuns']}",
201
+ f"- Candidate pass rate: {policy['candidatePassRate']}%",
202
+ f"- Candidate failures: {policy['candidateFailures']}",
203
+ f"- Requires same context: {'yes' if policy['requiresSameContext'] else 'no'}",
204
+ f"- Requires committed rows: {'yes' if policy['requiresCommittedRows'] else 'no'}",
205
+ f"- Forbidden claim: {policy['forbiddenClaim']}",
206
+ "",
207
+ "## Fixtures",
208
+ "",
209
+ markdown_table(["Fixture", "Framework", "Platforms", "Status", "Scenario"], fixtures),
210
+ "",
211
+ "## Runner Adapters",
212
+ "",
213
+ markdown_table(["Adapter", "Status", "Collector", "Modes"], adapters),
214
+ "",
215
+ "## Modes",
216
+ "",
217
+ markdown_table(["Mode", "Label", "Description"], modes),
218
+ "",
219
+ "## Next Slices",
220
+ "",
221
+ markdown_table(["Slice", "Status", "Description"], next_slices),
222
+ "",
223
+ ]
224
+ )
225
+
226
+
227
+ def write_output(content, path):
228
+ if path:
229
+ out = Path(path)
230
+ out.parent.mkdir(parents=True, exist_ok=True)
231
+ out.write_text(content, encoding="utf-8")
232
+ else:
233
+ sys.stdout.write(content)
234
+
235
+
236
+ def main():
237
+ args = parse_args()
238
+ try:
239
+ data = read_manifest(args.manifest)
240
+ except ValueError as exc:
241
+ print(f"error: {exc}", file=sys.stderr)
242
+ return 2
243
+
244
+ if args.format == "json":
245
+ content = json.dumps(summary(data), sort_keys=True, separators=(",", ":")) + "\n"
246
+ else:
247
+ content = render_markdown(data)
248
+ write_output(content, args.out)
249
+ return 0
250
+
251
+
252
+ if __name__ == "__main__":
253
+ raise SystemExit(main())