sim-mirror 0.2.0__py3-none-any.whl

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 (186) hide show
  1. sim_mirror/__init__.py +6 -0
  2. sim_mirror/__main__.py +7 -0
  3. sim_mirror/_version.py +4 -0
  4. sim_mirror/api.py +98 -0
  5. sim_mirror/build/__init__.py +2 -0
  6. sim_mirror/build/destination.py +82 -0
  7. sim_mirror/build/provider.py +176 -0
  8. sim_mirror/build/xcodebuild.py +709 -0
  9. sim_mirror/build/xcresult.py +365 -0
  10. sim_mirror/cli/__init__.py +2 -0
  11. sim_mirror/cli/config_command.py +100 -0
  12. sim_mirror/cli/context.py +128 -0
  13. sim_mirror/cli/devices.py +43 -0
  14. sim_mirror/cli/doctor.py +75 -0
  15. sim_mirror/cli/main.py +61 -0
  16. sim_mirror/cli/mcp.py +34 -0
  17. sim_mirror/cli/open_viewer.py +36 -0
  18. sim_mirror/cli/serve.py +73 -0
  19. sim_mirror/cli/settings_command.py +38 -0
  20. sim_mirror/cli/tokens.py +65 -0
  21. sim_mirror/cli/tools.py +32 -0
  22. sim_mirror/cli/version.py +31 -0
  23. sim_mirror/cli/xcode.py +90 -0
  24. sim_mirror/config/__init__.py +3 -0
  25. sim_mirror/config/discovery.py +19 -0
  26. sim_mirror/config/env.py +34 -0
  27. sim_mirror/config/model.py +79 -0
  28. sim_mirror/config/provenance.py +39 -0
  29. sim_mirror/config/schema.py +482 -0
  30. sim_mirror/config/settings_store.py +34 -0
  31. sim_mirror/config/toml_source.py +139 -0
  32. sim_mirror/config/writer.py +218 -0
  33. sim_mirror/connectors/__init__.py +7 -0
  34. sim_mirror/connectors/base.py +228 -0
  35. sim_mirror/connectors/idb/__init__.py +2 -0
  36. sim_mirror/connectors/idb/companion.py +351 -0
  37. sim_mirror/connectors/idb/connector.py +91 -0
  38. sim_mirror/connectors/idb/engine.py +149 -0
  39. sim_mirror/connectors/idb/frameworks.py +51 -0
  40. sim_mirror/connectors/idb/proto/__init__.py +8 -0
  41. sim_mirror/connectors/idb/proto/idb.proto +1301 -0
  42. sim_mirror/connectors/idb/proto/idb_grpc.py +776 -0
  43. sim_mirror/connectors/idb/proto/idb_pb2.py +452 -0
  44. sim_mirror/connectors/mcpbridge/__init__.py +3 -0
  45. sim_mirror/connectors/mcpbridge/client.py +264 -0
  46. sim_mirror/connectors/mcpbridge/connector.py +84 -0
  47. sim_mirror/connectors/mcpbridge/hierarchy.py +225 -0
  48. sim_mirror/connectors/mcpbridge/merge.py +68 -0
  49. sim_mirror/connectors/mcpbridge/reader.py +220 -0
  50. sim_mirror/connectors/registry.py +136 -0
  51. sim_mirror/connectors/simctl/__init__.py +2 -0
  52. sim_mirror/connectors/simctl/capture.py +79 -0
  53. sim_mirror/connectors/simctl/connector.py +74 -0
  54. sim_mirror/core/__init__.py +3 -0
  55. sim_mirror/core/actions.py +541 -0
  56. sim_mirror/core/availability.py +61 -0
  57. sim_mirror/core/devices.py +163 -0
  58. sim_mirror/core/events.py +46 -0
  59. sim_mirror/core/frames.py +308 -0
  60. sim_mirror/core/gestures.py +164 -0
  61. sim_mirror/core/instance.py +126 -0
  62. sim_mirror/core/manager.py +541 -0
  63. sim_mirror/core/reaper.py +64 -0
  64. sim_mirror/core/runtime.py +214 -0
  65. sim_mirror/core/screen_input.py +170 -0
  66. sim_mirror/core/screen_relay.py +275 -0
  67. sim_mirror/core/status.py +58 -0
  68. sim_mirror/core/text_entry.py +64 -0
  69. sim_mirror/core/tickets.py +43 -0
  70. sim_mirror/daemon/__init__.py +2 -0
  71. sim_mirror/daemon/app.py +332 -0
  72. sim_mirror/daemon/auth.py +164 -0
  73. sim_mirror/daemon/confirmations.py +110 -0
  74. sim_mirror/daemon/health.py +47 -0
  75. sim_mirror/daemon/lease.py +41 -0
  76. sim_mirror/daemon/lifecycle.py +77 -0
  77. sim_mirror/daemon/passes.py +83 -0
  78. sim_mirror/daemon/policy.py +37 -0
  79. sim_mirror/daemon/tokens.py +302 -0
  80. sim_mirror/doctor/__init__.py +2 -0
  81. sim_mirror/doctor/checks.py +303 -0
  82. sim_mirror/doctor/macos.py +67 -0
  83. sim_mirror/doctor/report.py +59 -0
  84. sim_mirror/doctor/tap.py +139 -0
  85. sim_mirror/host_copy.py +136 -0
  86. sim_mirror/hosting.py +190 -0
  87. sim_mirror/mcp/__init__.py +2 -0
  88. sim_mirror/mcp/launcher.py +252 -0
  89. sim_mirror/mcp/relay.py +316 -0
  90. sim_mirror/perception/__init__.py +9 -0
  91. sim_mirror/perception/budget.py +66 -0
  92. sim_mirror/perception/model.py +65 -0
  93. sim_mirror/perception/readers.py +204 -0
  94. sim_mirror/perception/settle.py +93 -0
  95. sim_mirror/perception/snapshot.py +252 -0
  96. sim_mirror/perception/wait.py +73 -0
  97. sim_mirror/platform/__init__.py +3 -0
  98. sim_mirror/platform/developer_dir.py +72 -0
  99. sim_mirror/platform/images.py +51 -0
  100. sim_mirror/platform/keyboard.py +29 -0
  101. sim_mirror/platform/process.py +105 -0
  102. sim_mirror/platform/simctl.py +275 -0
  103. sim_mirror/platform/xcode.py +20 -0
  104. sim_mirror/platform/xcrun.py +129 -0
  105. sim_mirror/protocol/__init__.py +39 -0
  106. sim_mirror/protocol/_generated.py +605 -0
  107. sim_mirror/protocol/messages.py +168 -0
  108. sim_mirror/py.typed +0 -0
  109. sim_mirror/scope.py +67 -0
  110. sim_mirror/seams.py +236 -0
  111. sim_mirror/server/__init__.py +2 -0
  112. sim_mirror/server/agent_routes.py +54 -0
  113. sim_mirror/server/envelope.py +28 -0
  114. sim_mirror/server/http_routes.py +93 -0
  115. sim_mirror/server/log_redaction.py +45 -0
  116. sim_mirror/server/pages.py +59 -0
  117. sim_mirror/server/security.py +161 -0
  118. sim_mirror/server/settings_routes.py +188 -0
  119. sim_mirror/server/settings_view.py +82 -0
  120. sim_mirror/server/socket_routes.py +61 -0
  121. sim_mirror/server/static/viewer/embed.html +19 -0
  122. sim_mirror/server/static/viewer/index.html +19 -0
  123. sim_mirror/server/static/viewer/start.js +25 -0
  124. sim_mirror/storage/__init__.py +3 -0
  125. sim_mirror/storage/app_support.py +100 -0
  126. sim_mirror/storage/claims.py +146 -0
  127. sim_mirror/storage/private.py +135 -0
  128. sim_mirror/testing/__init__.py +2 -0
  129. sim_mirror/testing/asgi.py +96 -0
  130. sim_mirror/testing/contract.py +73 -0
  131. sim_mirror/testing/fakes.py +732 -0
  132. sim_mirror/testing/fixtures/ax-alert-ios27.json +162 -0
  133. sim_mirror/testing/fixtures/ax-home-complete.json +1 -0
  134. sim_mirror/testing/fixtures/ax-home-ios27.json +447 -0
  135. sim_mirror/testing/fixtures/ax-keyboard-ios27.json +1261 -0
  136. sim_mirror/testing/fixtures/ax-probe-ios27.json +444 -0
  137. sim_mirror/testing/fixtures/ax-safari-ios27.json +188 -0
  138. sim_mirror/testing/fixtures/ax-safari-typed.json +1 -0
  139. sim_mirror/testing/fixtures/ax-settings-interactable.json +1 -0
  140. sim_mirror/testing/fixtures/ax-settings-ios27.json +425 -0
  141. sim_mirror/testing/fixtures/mcpbridge-answers.json +183 -0
  142. sim_mirror/testing/fixtures/mcpbridge-hierarchy-alert.txt +46 -0
  143. sim_mirror/testing/fixtures/mcpbridge-hierarchy-home.txt +257 -0
  144. sim_mirror/testing/fixtures/mcpbridge-hierarchy-keyboard.txt +92 -0
  145. sim_mirror/testing/fixtures/mcpbridge-hierarchy-probe.txt +39 -0
  146. sim_mirror/testing/fixtures/mcpbridge-hierarchy-safari.txt +77 -0
  147. sim_mirror/testing/fixtures/mcpbridge-hierarchy-settings.txt +169 -0
  148. sim_mirror/testing/fixtures/simctl-devices.json +73 -0
  149. sim_mirror/testing/fixtures/simctl-runtimes.json +114 -0
  150. sim_mirror/testing/fixtures/xcodebuild-list-names.json +20 -0
  151. sim_mirror/testing/fixtures/xcodebuild-list.json +17 -0
  152. sim_mirror/testing/fixtures/xcodebuild-settings.json +13 -0
  153. sim_mirror/testing/fixtures/xcodebuild-test-plans-none.json +3 -0
  154. sim_mirror/testing/fixtures/xcodebuild-test-plans.json +10 -0
  155. sim_mirror/testing/fixtures/xcresult-build-broken.json +32 -0
  156. sim_mirror/testing/fixtures/xcresult-build-ok.json +27 -0
  157. sim_mirror/testing/fixtures/xcresult-build-results-unbuilt.json +36 -0
  158. sim_mirror/testing/fixtures/xcresult-test-summary-retries.json +56 -0
  159. sim_mirror/testing/fixtures/xcresult-test-summary-swift-testing-xcode27.json +63 -0
  160. sim_mirror/testing/fixtures/xcresult-test-summary-swift-testing.json +60 -0
  161. sim_mirror/testing/fixtures/xcresult-test-summary-unbuilt.json +24 -0
  162. sim_mirror/testing/fixtures/xcresult-test-summary.json +49 -0
  163. sim_mirror/testing/fixtures/xcresult-test-tests-retries.json +165 -0
  164. sim_mirror/testing/fixtures/xcresult-test-tests-swift-testing-xcode27.json +135 -0
  165. sim_mirror/testing/fixtures/xcresult-test-tests-swift-testing.json +125 -0
  166. sim_mirror/testing/fixtures/xcresult-test-tests.json +93 -0
  167. sim_mirror/testing/guards.py +129 -0
  168. sim_mirror/testing/rig.py +163 -0
  169. sim_mirror/tools/__init__.py +2 -0
  170. sim_mirror/tools/act.py +31 -0
  171. sim_mirror/tools/app.py +127 -0
  172. sim_mirror/tools/context.py +120 -0
  173. sim_mirror/tools/device.py +51 -0
  174. sim_mirror/tools/registry.py +82 -0
  175. sim_mirror/tools/results.py +30 -0
  176. sim_mirror/tools/schemas.py +170 -0
  177. sim_mirror/tools/screenshot.py +28 -0
  178. sim_mirror/tools/snapshot.py +23 -0
  179. sim_mirror/validation.py +46 -0
  180. sim_mirror-0.2.0.dist-info/METADATA +210 -0
  181. sim_mirror-0.2.0.dist-info/RECORD +186 -0
  182. sim_mirror-0.2.0.dist-info/WHEEL +4 -0
  183. sim_mirror-0.2.0.dist-info/entry_points.txt +2 -0
  184. sim_mirror-0.2.0.dist-info/licenses/LICENSE +202 -0
  185. sim_mirror-0.2.0.dist-info/licenses/NOTICE +14 -0
  186. sim_mirror-0.2.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +61 -0
@@ -0,0 +1,365 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """What a build or a test run said, read from its result bundle, as a few lines an agent can act on.
3
+
4
+ The result bundle tool's ``get build-results`` and ``get test-results summary|tests`` answer in JSON (measured on Xcode
5
+ 26.6 and 27.0; fixtures in `sim_mirror/testing/fixtures/`). This turns that into what an agent needs next, and
6
+ nothing it would have to wade through:
7
+
8
+ build FAILED · NotesProbe (Debug) · 1.5s · 1 error, 0 warnings
9
+ error Sources/NotesApp.swift:39:19 Cannot convert value of type 'String' to specified type 'Int'
10
+
11
+ test FAILED · NotesProbe · 2 passed, 1 failed, 0 skipped · 34.3s
12
+ fail NotesProbeTests/NotesTests/testDeliberatelyFails Tests/NotesTests.swift:10 XCTAssertEqual failed: ("Trip") …
13
+
14
+ JSON in, text out. A result bundle's source locations count lines and columns from zero; shown here as an editor shows
15
+ them, from one. A path inside the project folder is shown relative to it -- compared with symlinks resolved, since a
16
+ result bundle names `/tmp/…` where the folder is `/private/tmp/…`.
17
+
18
+ A compile that fails also fails the steps copying what it would have produced, each an error with no location ('The
19
+ file "App.swiftmodule" could not be opened because there is no such file', in Xcode's curly quotes). Beside a located
20
+ error those say nothing an agent can act on, so they are counted in one line rather than listed.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import re
26
+ from collections.abc import Mapping
27
+ from dataclasses import dataclass, replace
28
+ from pathlib import Path
29
+ from typing import Any
30
+ from urllib.parse import parse_qs, unquote, urlsplit
31
+
32
+ #: The most errors, warnings or failures listed; the counts still say how many there were.
33
+ LISTED_MAX = 20
34
+ MESSAGE_MAX = 300
35
+
36
+ _FAILURE_AT = re.compile(r"\A(?P<file>[^:\s]+\.[A-Za-z]+):(?P<line>\d+): ")
37
+ #: Where a result bundle's test URLs start: ``test://com.apple.xcode/<container>/<target>/<suite>/<test>``.
38
+ _TEST_URL = "test://com.apple.xcode/"
39
+ #: What a test run's build adds when its own failure stopped the tests; the answer's first line says that instead.
40
+ _TESTING_CANCELLED = re.compile(r"\ATesting cancelled because the build failed")
41
+ #: The error a copy step gives for a file a failed compile never produced.
42
+ _NOT_PRODUCED = re.compile(r"couldn.t be opened because there is no such file")
43
+
44
+
45
+ def _resolved(path: Path) -> Path:
46
+ try:
47
+ return path.resolve()
48
+ except (OSError, RuntimeError):
49
+ return path
50
+
51
+
52
+ def shown_path(file: str, root: Path | None) -> str:
53
+ """A path inside the project folder relative to it, compared with symlinks resolved; any other as it came."""
54
+ path = Path(file)
55
+ if root is not None and path.is_absolute():
56
+ resolved, base = _resolved(path), _resolved(root)
57
+ if resolved.is_relative_to(base):
58
+ return str(resolved.relative_to(base))
59
+ return file
60
+
61
+
62
+ def _short(text: object) -> str:
63
+ value = " ".join(str(text or "").split())
64
+ return value if len(value) <= MESSAGE_MAX else value[: MESSAGE_MAX - 1] + "…"
65
+
66
+
67
+ def _seconds(start: object, end: object) -> float | None:
68
+ if isinstance(start, (int, float)) and isinstance(end, (int, float)) and end >= start:
69
+ return round(end - start, 1)
70
+ return None
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class Issue:
75
+ kind: str
76
+ message: str
77
+ file: str | None = None
78
+ line: int | None = None
79
+ column: int | None = None
80
+
81
+ def where(self, root: Path | None) -> str:
82
+ if self.file is None:
83
+ return ""
84
+ path = shown_path(self.file, root)
85
+ return path + (f":{self.line}" if self.line else "") + (f":{self.column}" if self.column else "")
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class BuildSummary:
90
+ succeeded: bool
91
+ errors: tuple[Issue, ...]
92
+ warnings: tuple[Issue, ...]
93
+ error_count: int
94
+ warning_count: int
95
+ seconds: float | None
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class Failure:
100
+ test: str
101
+ message: str
102
+ file: str | None = None
103
+ line: int | None = None
104
+ #: How many times it was run, when `-retry-tests-on-failure` ran it more than once. 1 when it was run once.
105
+ attempts: int = 1
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class Flaky:
110
+ """A test that passed, but only after failing.
111
+
112
+ The summary counts it among the passed and leaves it out of `testFailures` altogether -- measured on Xcode 26.6,
113
+ where a test that failed once and passed on retry is simply "passed". So an agent is told a suite passed and
114
+ never learns that a test needed two goes, which is exactly the thing worth knowing: a fix that looks confirmed
115
+ may only have been lucky.
116
+ """
117
+
118
+ test: str
119
+ attempts: int
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class SuiteSummary:
124
+ passed: bool
125
+ passed_count: int
126
+ failed_count: int
127
+ skipped_count: int
128
+ failures: tuple[Failure, ...]
129
+ seconds: float | None
130
+ #: Tests that failed on purpose (`XCTExpectFailure`). Counted apart, or the other three would not add up to the
131
+ #: tests there were, and an agent would read a passing run as having lost a test.
132
+ expected_failures: int = 0
133
+ #: Tests that passed only after failing. Empty unless the run retried, which needs `-retry-tests-on-failure`.
134
+ flaky: tuple[Flaky, ...] = ()
135
+
136
+
137
+ def runnable_id(url: object, identifier: str, target: object = None) -> str:
138
+ """A test as `-only-testing` names it -- target, suite and test: ``ProbeTests/TripTests/testSplitsTrips`` or
139
+ ``ProbeTests/ParsingTests/countsTrips()`` -- so an agent can run again exactly what failed.
140
+
141
+ The test's URL is the one place a result bundle says all three (measured on Xcode 26.6 and 27.0): its
142
+ ``testIdentifierString`` leaves the target out, and so does the tree's ``nodeIdentifier``. The URL gives an XCTest
143
+ method without ``()`` and a Swift Testing function with them, which is how xcodebuild takes each back. Without
144
+ a URL, the target is put in front of the identifier when it is known.
145
+ """
146
+ if isinstance(url, str) and url.startswith(_TEST_URL):
147
+ parts = [unquote(part) for part in url[len(_TEST_URL) :].split("/")]
148
+ if len(parts) >= 3 and all(parts):
149
+ return "/".join(parts[1:])
150
+ return f"{target}/{identifier}" if isinstance(target, str) and target else identifier
151
+
152
+
153
+ def issue_location(source_url: object) -> tuple[str | None, int | None, int | None]:
154
+ """A result bundle's ``file:///…#StartingLineNumber=…`` as a path, a line and a column counted from one."""
155
+ if not isinstance(source_url, str) or not source_url.startswith("file://"):
156
+ return None, None, None
157
+ parts = urlsplit(source_url)
158
+ fragment = parse_qs(parts.fragment)
159
+
160
+ def number(key: str) -> int | None:
161
+ raw = (fragment.get(key) or [""])[0]
162
+ return int(raw) + 1 if raw.isdigit() else None
163
+
164
+ return unquote(parts.path) or None, number("StartingLineNumber"), number("StartingColumnNumber")
165
+
166
+
167
+ def _issues(entries: object, kind: str) -> tuple[Issue, ...]:
168
+ issues = []
169
+ for entry in (entries if isinstance(entries, list) else [])[:LISTED_MAX]:
170
+ if isinstance(entry, dict):
171
+ file, line, column = issue_location(entry.get("sourceURL"))
172
+ issues.append(Issue(kind, _short(entry.get("message")), file, line, column))
173
+ return tuple(issues)
174
+
175
+
176
+ def _count(value: object, fallback: int) -> int:
177
+ return value if isinstance(value, int) and not isinstance(value, bool) else fallback
178
+
179
+
180
+ def build_summary(document: dict[str, Any]) -> BuildSummary:
181
+ errors = _issues(document.get("errors"), "error")
182
+ warnings = _issues(document.get("warnings"), "warning")
183
+ return BuildSummary(
184
+ succeeded=document.get("status") == "succeeded",
185
+ errors=errors,
186
+ warnings=warnings,
187
+ error_count=_count(document.get("errorCount"), len(errors)),
188
+ warning_count=_count(document.get("warningCount"), len(warnings)),
189
+ seconds=_seconds(document.get("startTime"), document.get("endTime")),
190
+ )
191
+
192
+
193
+ def _failure_at(message: dict[str, Any]) -> tuple[str, int] | None:
194
+ """Where one failure message says it happened.
195
+
196
+ Xcode 27.0 gives a ``sourceLocation`` -- the file's whole path and a line counted from one -- and leaves the place
197
+ out of the message; Xcode 26.6 starts the message with it, ``TripTests.swift:6: …``, the file named bare.
198
+ """
199
+ location = message.get("sourceLocation")
200
+ if isinstance(location, dict):
201
+ path, line = location.get("filePath"), location.get("lineNumber")
202
+ if isinstance(path, str) and path and isinstance(line, int) and not isinstance(line, bool) and line > 0:
203
+ return path, line
204
+ match = _FAILURE_AT.match(str(message.get("name") or ""))
205
+ return (match.group("file"), int(match.group("line"))) if match else None
206
+
207
+
208
+ def _first_failure(children: object) -> tuple[str, int] | None:
209
+ """Where a test failed, from its own children or a repetition's: the first failure message that says."""
210
+ for child in children if isinstance(children, list) else []:
211
+ if not isinstance(child, dict):
212
+ continue
213
+ if child.get("nodeType") == "Failure Message":
214
+ found = _failure_at(child)
215
+ if found is not None:
216
+ return found
217
+ if child.get("nodeType") == "Repetition":
218
+ found = _first_failure(child.get("children"))
219
+ if found is not None:
220
+ return found
221
+ return None
222
+
223
+
224
+ def _cases(tests: object) -> dict[str, dict[str, Any]]:
225
+ """Every test case in the tree, by identifier. A tree is small; the first of a repeated id wins."""
226
+ found: dict[str, dict[str, Any]] = {}
227
+ stack = list(tests.get("testNodes") or []) if isinstance(tests, dict) else []
228
+ while stack:
229
+ node = stack.pop()
230
+ if not isinstance(node, dict):
231
+ continue
232
+ identifier = node.get("nodeIdentifier")
233
+ if node.get("nodeType") == "Test Case" and isinstance(identifier, str) and identifier not in found:
234
+ found[identifier] = node
235
+ stack.extend(node.get("children") or [])
236
+ return found
237
+
238
+
239
+ def _repetitions(case: dict[str, Any]) -> list[dict[str, Any]]:
240
+ """A test's runs, when `-retry-tests-on-failure` gave it more than one; empty when it was run once.
241
+
242
+ Measured on Xcode 26.6: a retried test gains `Repetition` children named "First Run", "Retry 1", … each with
243
+ its own `result`, and a test run once gains none at all.
244
+ """
245
+ return [
246
+ child
247
+ for child in case.get("children") or []
248
+ if isinstance(child, dict) and child.get("nodeType") == "Repetition"
249
+ ]
250
+
251
+
252
+ def _flaky(cases: dict[str, dict[str, Any]]) -> tuple[Flaky, ...]:
253
+ """Tests that passed with a failed run behind them -- which the summary reports as simply passed."""
254
+ found = [
255
+ Flaky(runnable_id(case.get("nodeIdentifierURL"), identifier), len(reps))
256
+ for identifier, case in sorted(cases.items())
257
+ if (reps := _repetitions(case))
258
+ and case.get("result") == "Passed"
259
+ and any(rep.get("result") not in (None, "Passed", "Expected Failure") for rep in reps)
260
+ ]
261
+ return tuple(found[:LISTED_MAX])
262
+
263
+
264
+ def suite_summary(summary: dict[str, Any], tests: dict[str, Any] | None = None) -> SuiteSummary:
265
+ cases = _cases(tests)
266
+ failures = []
267
+ listed = summary.get("testFailures")
268
+ for entry in (listed if isinstance(listed, list) else [])[:LISTED_MAX]:
269
+ if isinstance(entry, dict):
270
+ name = str(entry.get("testIdentifierString") or entry.get("testName") or "a test")
271
+ message = _FAILURE_AT.sub("", str(entry.get("failureText") or ""))
272
+ case = cases.get(name, {})
273
+ where = _first_failure(case.get("children"))
274
+ file, line = where if where is not None else (None, None)
275
+ shown = runnable_id(entry.get("testIdentifierURL"), name, entry.get("targetName"))
276
+ failures.append(Failure(shown, _short(message), file, line, max(1, len(_repetitions(case)))))
277
+ return SuiteSummary(
278
+ passed=summary.get("result") == "Passed",
279
+ passed_count=_count(summary.get("passedTests"), 0),
280
+ failed_count=_count(summary.get("failedTests"), 0),
281
+ skipped_count=_count(summary.get("skippedTests"), 0),
282
+ failures=tuple(failures),
283
+ seconds=_seconds(summary.get("startTime"), summary.get("finishTime")),
284
+ expected_failures=_count(summary.get("expectedFailures"), 0),
285
+ flaky=_flaky(cases),
286
+ )
287
+
288
+
289
+ def placed(summary: SuiteSummary, places: Mapping[str, str]) -> SuiteSummary:
290
+ """The summary with each failure's bare file name where the project has it (`xcodebuild.source_paths`)."""
291
+ failures = tuple(
292
+ replace(failure, file=places.get(failure.file, failure.file)) if failure.file else failure
293
+ for failure in summary.failures
294
+ )
295
+ return replace(summary, failures=failures)
296
+
297
+
298
+ def _plural(count: int, word: str) -> str:
299
+ return f"{count} {word}{'' if count == 1 else 's'}"
300
+
301
+
302
+ def _took(seconds: float | None) -> str:
303
+ return f" · {seconds:g}s" if seconds is not None else ""
304
+
305
+
306
+ def any_test_ran(summary: dict[str, Any]) -> bool:
307
+ """Whether a test run got as far as running a test.
308
+
309
+ A run whose tests did not compile has a summary all the same -- measured on Xcode 26.6 and 27.0: ``result``
310
+ "unknown", no tests, no failures -- and its errors are only in the build's results.
311
+ """
312
+ return _count(summary.get("totalTestCount"), 0) > 0
313
+
314
+
315
+ def render_build(
316
+ summary: BuildSummary, *, label: str, root: Path | None, warnings: bool = False, kind: str = "build"
317
+ ) -> list[str]:
318
+ """The lines a build answers with: the verdict, then its errors -- and its warnings, when asked for.
319
+
320
+ A test run whose tests did not build answers the same way, as a test run: its errors are why no test ran.
321
+ """
322
+ verdict = "ok" if summary.succeeded else "FAILED"
323
+ cancelled = [error for error in summary.errors if kind == "test" and _TESTING_CANCELLED.match(error.message)]
324
+ reported = [error for error in summary.errors if error not in cancelled]
325
+ error_count = summary.error_count - len(cancelled)
326
+ unbuilt = " · the tests did not build" if kind == "test" else ""
327
+ lines = [
328
+ f"{kind} {verdict} · {label}{unbuilt}{_took(summary.seconds)} · {_plural(error_count, 'error')}, "
329
+ f"{_plural(summary.warning_count, 'warning')}"
330
+ ]
331
+ located = any(error.file for error in reported)
332
+ follow_ons = [error for error in reported if located and not error.file and _NOT_PRODUCED.search(error.message)]
333
+ errors = [error for error in reported if error not in follow_ons]
334
+ shown = [*errors, *(summary.warnings if warnings else ())]
335
+ lines += [" ".join(part for part in (issue.kind, issue.where(root), issue.message) if part) for issue in shown]
336
+ if follow_ons:
337
+ lines.append(f"… {_plural(len(follow_ons), 'follow-on error')} from that failure: files it did not produce")
338
+ listed = len(reported) + (len(summary.warnings) if warnings else 0)
339
+ hidden = error_count + (summary.warning_count if warnings else 0) - listed
340
+ if hidden > 0:
341
+ lines.append(f"… {hidden} more not listed")
342
+ return lines
343
+
344
+
345
+ def render_tests(summary: SuiteSummary, *, label: str, root: Path | None = None) -> list[str]:
346
+ """The lines a test run answers with: the verdict and counts, then each failure where it happened."""
347
+ verdict = "ok" if summary.passed else "FAILED"
348
+ # Said only when there are any: a run with none is every run most projects have, and the line is read every time.
349
+ expected = f", {summary.expected_failures} failed as expected" if summary.expected_failures else ""
350
+ lines = [
351
+ f"test {verdict} · {label} · {summary.passed_count} passed, {summary.failed_count} failed, "
352
+ f"{summary.skipped_count} skipped{expected}{_took(summary.seconds)}"
353
+ ]
354
+ for failure in summary.failures:
355
+ where = f"{shown_path(failure.file, root)}:{failure.line}" if failure.file else ""
356
+ # Said only when it was run more than once, so a failure that was retried is not read as a one-off.
357
+ tried = f"(failed {failure.attempts} times)" if failure.attempts > 1 else ""
358
+ lines.append(" ".join(part for part in ("fail", failure.test, where, failure.message, tried) if part))
359
+ hidden = summary.failed_count - len(summary.failures)
360
+ if hidden > 0:
361
+ lines.append(f"… {hidden} more failures not listed")
362
+ # A flaky test is counted among the passed and named nowhere else, so if it is not said here it is not said.
363
+ for flaky in summary.flaky:
364
+ lines.append(f"flaky {flaky.test} failed, then passed on attempt {flaky.attempts}")
365
+ return lines
@@ -0,0 +1,2 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """The ``sim-mirror`` command line: one module per command."""
@@ -0,0 +1,100 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """``sim-mirror config``: where the settings file is, what it sets, and changing it one value at a time.
3
+
4
+ ``set`` and ``unset`` edit config.toml in place (`config.writer`) -- comments survive -- refusing a value its rule does
5
+ not allow before anything is written. A running daemon is told to reload, so a switch turned off is off before the
6
+ command returns. ``--scope`` edits that scope's own table.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ from typing import Any
14
+
15
+ from sim_mirror.cli.context import CliContext
16
+ from sim_mirror.config import schema
17
+ from sim_mirror.config.writer import ConfigError, ConfigWriter
18
+ from sim_mirror.daemon.app import SERVER_SCOPE
19
+ from sim_mirror.daemon.lifecycle import read_info
20
+ from sim_mirror.mcp.launcher import DaemonUnavailable
21
+ from sim_mirror.scope import Scope
22
+
23
+
24
+ def register(commands: Any) -> None:
25
+ command = commands.add_parser("config", help="show or change SimMirror's settings")
26
+ actions = command.add_subparsers(dest="action", metavar="ACTION", required=True)
27
+ actions.add_parser("path", help="print where config.toml is")
28
+ actions.add_parser("validate", help="check config.toml and the SIM_MIRROR_ environment")
29
+ for name, helped in (("list", "every setting and its value"), ("get", "one setting's value")):
30
+ action = actions.add_parser(name, help=helped)
31
+ if name == "get":
32
+ action.add_argument("name", help="a setting, such as stream.fps")
33
+ action.add_argument("--scope", help="as this scope sees it")
34
+ changing = actions.add_parser("set", help="set a setting in config.toml")
35
+ changing.add_argument("name")
36
+ changing.add_argument("value")
37
+ changing.add_argument("--scope", help="in this scope's own table")
38
+ removing = actions.add_parser("unset", help="remove a setting from config.toml")
39
+ removing.add_argument("name")
40
+ removing.add_argument("--scope", help="from this scope's own table")
41
+ command.set_defaults(handler=run)
42
+
43
+
44
+ def shown(value: object) -> str:
45
+ return json.dumps(list(value) if isinstance(value, tuple) else value)
46
+
47
+
48
+ def _setting(name: str) -> schema.Setting:
49
+ setting = schema.find(name)
50
+ if setting is None:
51
+ raise ConfigError(f"{name} is not a setting; `sim-mirror config list` shows them all")
52
+ return setting
53
+
54
+
55
+ def run(args: argparse.Namespace, ctx: CliContext) -> int:
56
+ path = ctx.config_path()
57
+ if args.action == "path":
58
+ ctx.say(str(path))
59
+ return 0
60
+ source = ctx.config()
61
+ if args.action == "validate":
62
+ problems = source.problems()
63
+ for problem in problems:
64
+ ctx.complain(problem)
65
+ if not problems:
66
+ ctx.say(f"config ok: {path}")
67
+ return 1 if problems else 0
68
+ scope = Scope.named(args.scope) if args.scope else SERVER_SCOPE
69
+ writer = ConfigWriter(path)
70
+ if args.action == "list":
71
+ config, origins = source.get(scope), source.explain(scope)
72
+ for setting in schema.SETTINGS:
73
+ origin = origins[setting.key]
74
+ said = "" if origin.layer == "file" else f" # {origin.label()}"
75
+ ctx.say(f"{setting.path} = {shown(getattr(config, setting.key))}{said}")
76
+ return 0
77
+ if args.action == "get":
78
+ ctx.say(shown(getattr(source.get(scope), _setting(args.name).key)))
79
+ return 0
80
+ if args.action == "set":
81
+ value = writer.set(args.name, args.value, scope=args.scope)
82
+ ctx.say(f"{_setting(args.name).path} = {shown(value)}")
83
+ else:
84
+ removed = writer.unset(args.name, scope=args.scope)
85
+ ctx.say(f"{_setting(args.name).path} unset" if removed else f"{args.name} was not set in {path}")
86
+ _reload(ctx)
87
+ return 0
88
+
89
+
90
+ def _reload(ctx: CliContext) -> None:
91
+ """Have a running daemon apply the change now."""
92
+ info = read_info(ctx.state.run_dir())
93
+ if info is None:
94
+ return
95
+ try:
96
+ ctx.client(info.url).post("/api/v1/admin/reload", {})
97
+ except DaemonUnavailable as exc:
98
+ ctx.complain(f"the running daemon did not reload ({exc}); it applies the change when it next starts")
99
+ return
100
+ ctx.say(f"the daemon at {info.url} applied it")
@@ -0,0 +1,128 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """What every command runs with: the environment and the state folder and config file it names, the streams, and every
3
+ way a command reaches outside -- the daemon, a new process, a server, a browser, Xcode -- each replaceable, so a test
4
+ runs a command with none of them."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import copy
10
+ import os
11
+ import sys
12
+ import webbrowser
13
+ from collections.abc import Awaitable, Callable, Coroutine, Mapping, Sequence
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import IO, Any
17
+
18
+ import uvicorn
19
+ from fastapi import FastAPI
20
+
21
+ from sim_mirror.config.discovery import config_path
22
+ from sim_mirror.config.toml_source import TomlConfigSource
23
+ from sim_mirror.connectors.mcpbridge.client import BridgeClient
24
+ from sim_mirror.connectors.registry import ConnectorContext, ConnectorRegistry
25
+ from sim_mirror.daemon.app import SERVER_SCOPE
26
+ from sim_mirror.daemon.lifecycle import LOOPBACK, read_info, start_detached
27
+ from sim_mirror.daemon.tokens import TokenStore
28
+ from sim_mirror.doctor import checks
29
+ from sim_mirror.doctor.checks import DoctorContext
30
+ from sim_mirror.doctor.report import Report
31
+ from sim_mirror.mcp import relay
32
+ from sim_mirror.mcp.launcher import DaemonClient
33
+ from sim_mirror.platform import process
34
+ from sim_mirror.platform.process import Runner
35
+ from sim_mirror.platform.xcrun import XcrunRunner, run_xcrun
36
+ from sim_mirror.scope import Scope
37
+ from sim_mirror.storage.app_support import AppSupportStateStore
38
+
39
+ DAEMON_LOG = "daemon.log"
40
+ #: How long a stopping daemon waits on its open connections before it closes the runtime anyway. A connection to a page
41
+ #: the browser froze never drains, and uvicorn's own wait has no end.
42
+ SHUTDOWN_GRACE_S = 5
43
+
44
+ Serve = Callable[[FastAPI, str, int], Coroutine[Any, Any, None]]
45
+ Spawn = Callable[[Sequence[str], Path], Awaitable[Any]]
46
+
47
+
48
+ def daemon_log_config() -> dict[str, Any]:
49
+ """uvicorn's logging, with SimMirror's own loggers written the same way: to stderr, which a detached daemon
50
+ appends to its log."""
51
+ config = copy.deepcopy(uvicorn.config.LOGGING_CONFIG)
52
+ config["loggers"]["sim_mirror"] = {"handlers": ["default"], "level": "INFO"}
53
+ return config
54
+
55
+
56
+ async def serve_with_uvicorn(app: FastAPI, host: str, port: int) -> None:
57
+ """Serve until interrupted. Sockets use uvicorn's sans-I/O WebSockets, which let two sends wait on a full socket."""
58
+ config = uvicorn.Config(
59
+ app,
60
+ host=host,
61
+ port=port,
62
+ ws="websockets-sansio",
63
+ log_level="info",
64
+ log_config=daemon_log_config(),
65
+ timeout_graceful_shutdown=SHUTDOWN_GRACE_S,
66
+ )
67
+ await uvicorn.Server(config).serve()
68
+
69
+
70
+ @dataclass
71
+ class CliContext:
72
+ env: Mapping[str, str]
73
+ cwd: Path
74
+ stdout: IO[str]
75
+ stderr: IO[str]
76
+ stdin: IO[str]
77
+ home: Path | None = None
78
+ opener: relay.Opener | None = None
79
+ open_url: Callable[[str], bool] = webbrowser.open
80
+ spawn: Spawn = process.spawn
81
+ serve: Serve = serve_with_uvicorn
82
+ run: Runner = process.run
83
+ xcrun: XcrunRunner = run_xcrun
84
+ registry: Callable[[ConnectorContext], ConnectorRegistry] = ConnectorRegistry.discover
85
+ diagnose: Callable[[DoctorContext], Awaitable[Report]] = checks.diagnose
86
+ #: Xcode's tools, through mcpbridge, on the given Xcode.
87
+ bridge: Callable[[str], BridgeClient] = BridgeClient
88
+ python: str = sys.executable
89
+
90
+ @classmethod
91
+ def from_process(cls) -> CliContext:
92
+ return cls(env=dict(os.environ), cwd=Path.cwd(), stdout=sys.stdout, stderr=sys.stderr, stdin=sys.stdin)
93
+
94
+ @property
95
+ def state(self) -> AppSupportStateStore:
96
+ return AppSupportStateStore(self.env, self.home)
97
+
98
+ def config_path(self, explicit: str | None = None) -> Path:
99
+ return Path(explicit).expanduser() if explicit else config_path(self.env, self.home)
100
+
101
+ def config(self, explicit: str | None = None, overrides: Mapping[str, Any] | None = None) -> TomlConfigSource:
102
+ return TomlConfigSource(self.config_path(explicit), env=self.env, overrides=overrides)
103
+
104
+ def tokens(self) -> TokenStore:
105
+ return TokenStore(self.state.state_dir)
106
+
107
+ def scope(self, named: str | None) -> Scope:
108
+ """The scope a command is for: the one named, else this folder's project."""
109
+ return Scope.named(named) if named else Scope.for_folder(self.cwd)
110
+
111
+ def daemon_url(self) -> str:
112
+ info = read_info(self.state.run_dir())
113
+ port = info.port if info is not None else self.config().get(SERVER_SCOPE).server_port
114
+ return f"http://{LOOPBACK}:{port}"
115
+
116
+ def client(self, url: str | None = None) -> DaemonClient:
117
+ return DaemonClient(url or self.daemon_url(), self.tokens().admin_token(), opener=self.opener)
118
+
119
+ def start_daemon(self, *arguments: str) -> int:
120
+ """Start ``sim-mirror serve`` detached, its output in the log folder. Answers its pid."""
121
+ argv = [self.python, "-m", "sim_mirror", "serve", "--foreground", *arguments]
122
+ return asyncio.run(start_detached(argv, self.state.log_dir() / DAEMON_LOG, spawn=self.spawn))
123
+
124
+ def say(self, text: str) -> None:
125
+ self.stdout.write(text + "\n")
126
+
127
+ def complain(self, text: str) -> None:
128
+ self.stderr.write(text + "\n")
@@ -0,0 +1,43 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """``sim-mirror devices``: this Mac's iOS simulators, and which one a project uses."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ from typing import Any
8
+
9
+ from sim_mirror.cli.context import CliContext
10
+ from sim_mirror.mcp.launcher import ensure_daemon
11
+
12
+ SCOPE_HELP = "for this scope, instead of this folder's project"
13
+
14
+
15
+ def register(commands: Any) -> None:
16
+ command = commands.add_parser("devices", help="list this Mac's iOS simulators, or choose one for a project")
17
+ command.add_argument("--scope", help=SCOPE_HELP)
18
+ actions = command.add_subparsers(dest="action", metavar="ACTION")
19
+ listing = actions.add_parser("list", help="list the simulators a project could use (the default)")
20
+ choosing = actions.add_parser("choose", help="use this simulator for a project from now on")
21
+ choosing.add_argument("udid")
22
+ for action in (listing, choosing):
23
+ # Also taken after the action; left unset when it is not given there, so one given before the action stands.
24
+ action.add_argument("--scope", default=argparse.SUPPRESS, help=SCOPE_HELP)
25
+ command.set_defaults(handler=run, action="list")
26
+
27
+
28
+ def run(args: argparse.Namespace, ctx: CliContext) -> int:
29
+ scope = ctx.scope(args.scope)
30
+ client = ctx.client()
31
+ ensure_daemon(client, ctx.start_daemon)
32
+ base = f"/api/v1/scopes/{scope.id}"
33
+ if args.action == "choose":
34
+ client.put(f"{base}/device", {"udid": args.udid})
35
+ ctx.say(f"{scope.label} uses {args.udid} from now on")
36
+ return 0
37
+ listed = client.get(f"{base}/devices")["devices"]
38
+ if not listed:
39
+ ctx.say("no iOS simulators are available on this Mac")
40
+ for device in listed:
41
+ made = " (made by SimMirror)" if device["created"] else ""
42
+ ctx.say(f"{device['udid']} {device['runtime']} {device['name']} {device['state']}{made}")
43
+ return 0