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,709 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Building and testing a scope's app on the scope's device, with an answer an agent can read.
3
+
4
+ `sim_build_run` and `sim_test` (`build/provider.py`) come here. A build is
5
+
6
+ xcrun xcodebuild (-workspace W | -project P) -scheme S -configuration C
7
+ -destination platform=iOS Simulator,id=<udid>
8
+ -derivedDataPath <StateStore.derived_data(scope)>
9
+ -resultBundlePath <StateStore.builds_dir(scope)>/<stamp>-<id>.xcresult
10
+ build | test
11
+
12
+ with its output written to a log beside the bundle, and its answer read from the bundle (`xcresult.py`) -- never
13
+ scraped from the log, whose format is Xcode's to change. A built app's path and bundle id come from
14
+ ``-showBuildSettings``, so it can be installed and launched straight after.
15
+
16
+ **Which project**: the one the build folder holds -- a workspace before the project inside it -- or the one named,
17
+ which must be inside that folder. A folder with several asks the agent to name one; a project with several schemes
18
+ asks for a scheme.
19
+
20
+ **One at a time**: a scope runs one build or test run at a time. A run longer than its timeout is ended with its
21
+ process group, and so is one cancelled. A call waits up to `wait_s`; a build still going answers with its id, and a
22
+ later call with that id picks it up -- so no tool call has to outlive an MCP client's timeout.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import contextlib
29
+ import itertools
30
+ import json
31
+ import os
32
+ import re
33
+ import signal
34
+ import time
35
+ from collections.abc import Awaitable, Callable, Collection, Iterator, Sequence
36
+ from dataclasses import dataclass
37
+ from pathlib import Path
38
+ from typing import Any, Protocol
39
+
40
+ from sim_mirror.build import xcresult
41
+ from sim_mirror.host_copy import HostCopy
42
+ from sim_mirror.platform.process import signal_group as signal_process_group
43
+ from sim_mirror.platform.xcrun import CANNOT_RUN, XCRUN_MISSING, XcrunRunner, run_xcrun, start_xcrun
44
+ from sim_mirror.scope import Scope
45
+ from sim_mirror.seams import StateStore
46
+ from sim_mirror.validation import is_xcode_name
47
+
48
+ #: Xcode's own DerivedData, where an app built outside SimMirror usually is.
49
+ DERIVED_DATA = Path("~/Library/Developer/Xcode/DerivedData").expanduser()
50
+ KINDS = ("build", "test")
51
+ LIST_TIMEOUT_S = 60.0
52
+ SETTINGS_TIMEOUT_S = 120.0
53
+ RESULT_TIMEOUT_S = 60.0
54
+ STOP_GRACE_S = 5.0
55
+ TESTS_MAX = 50
56
+ TEST_ID_MAX = 300
57
+ #: The longest project or workspace name a call may give.
58
+ PATH_MAX = 500
59
+
60
+ #: A workspace's references to the projects it builds, relative to the folder the workspace is in.
61
+ _PROJECT_REF = re.compile(r'location\s*=\s*"(?:group|container):([^"]+\.xcodeproj)"')
62
+ #: Folders a project's own sources and settings are never in, left out when looking through the project folder.
63
+ _NOT_SOURCES = frozenset({"DerivedData", "build", "node_modules"})
64
+
65
+
66
+ class BuildRefused(Exception):
67
+ """A build that will not be started or read, said so the agent knows what to do instead."""
68
+
69
+
70
+ class Starter(Protocol):
71
+ """How a long xcrun call is started; `xcrun.start_xcrun`, or a fake."""
72
+
73
+ async def __call__(self, *args: str, log_path: Path, developer_dir: str = ..., cwd: Path | None = ...) -> Any:
74
+ """Start ``xcrun <args>`` in its own process group, its output appended to `log_path`."""
75
+ ...
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class Project:
80
+ flag: str
81
+ path: Path
82
+
83
+ @property
84
+ def args(self) -> tuple[str, str]:
85
+ return self.flag, str(self.path)
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class Listing:
90
+ """What ``xcodebuild -list`` says a project or a workspace has. A workspace lists no configurations of its own."""
91
+
92
+ schemes: tuple[str, ...] = ()
93
+ configurations: tuple[str, ...] = ()
94
+
95
+
96
+ def _names(values: object) -> tuple[str, ...]:
97
+ return tuple(value for value in values if isinstance(value, str)) if isinstance(values, list) else ()
98
+
99
+
100
+ def _named(value: object, what: str, known: Sequence[str] = ()) -> str:
101
+ """A scheme, build configuration or test plan as a call names it -- and every name a refusal lists passes here, or
102
+ an agent told to use one would be refused for it."""
103
+ if not is_xcode_name(value):
104
+ there = f"; it is one of {', '.join(known)}" if known else ""
105
+ raise BuildRefused(
106
+ f"{what} must be a name as Xcode shows it, like Debug or App (Staging), not {value!r}{there}"
107
+ )
108
+ return value
109
+
110
+
111
+ def _listed(names: Sequence[str]) -> str:
112
+ return ", ".join(names) or "none"
113
+
114
+
115
+ def _is_test_id(value: object) -> bool:
116
+ """A test as a failure names it (`xcresult.runnable_id`): ``ParsingTests/withArguments(value:)`` has a colon, and
117
+ a target may have spaces. Each reaches xcodebuild inside one argument, ``-only-testing:<id>``, so only a line break
118
+ or a stray space could make it name something else."""
119
+ return isinstance(value, str) and 0 < len(value) <= TEST_ID_MAX and value.isprintable() and value == value.strip()
120
+
121
+
122
+ def _test_ids(values: object, what: str) -> list[str]:
123
+ if values is None:
124
+ return []
125
+ if not (isinstance(values, list) and len(values) <= TESTS_MAX and all(_is_test_id(value) for value in values)):
126
+ raise BuildRefused(
127
+ f"{what} is a list of at most {TESTS_MAX} tests as a failure names them: a target, "
128
+ "AppTests/LoginTests, AppTests/LoginTests/testLogin or AppTests/ParsingTests/countsTrips()"
129
+ )
130
+ return values
131
+
132
+
133
+ def _stamp(path: Path) -> int:
134
+ try:
135
+ return path.stat().st_mtime_ns
136
+ except OSError:
137
+ return 0
138
+
139
+
140
+ def _schemes(folder: Path) -> list[tuple[str, int]]:
141
+ try:
142
+ return sorted((str(path), _stamp(path)) for path in folder.rglob("*.xcscheme"))
143
+ except OSError:
144
+ return []
145
+
146
+
147
+ def _project_files(folder: Path) -> Iterator[tuple[str, list[str]]]:
148
+ """Each folder under ``folder`` a project's own files can be in, and its files: build output, dependencies and
149
+ hidden folders are left out. A project's folder is small once those are."""
150
+ for root, dirs, files in os.walk(folder):
151
+ dirs[:] = sorted(name for name in dirs if name not in _NOT_SOURCES and not name.startswith("."))
152
+ yield root, sorted(files)
153
+
154
+
155
+ def _configs(folder: Path) -> list[tuple[str, int]]:
156
+ """Every `.xcconfig` under ``folder`` -- where a bundle id or product name can be set -- and when it changed."""
157
+ return [
158
+ (os.path.join(root, name), _stamp(Path(root) / name))
159
+ for root, files in _project_files(folder)
160
+ for name in files
161
+ if name.endswith(".xcconfig")
162
+ ]
163
+
164
+
165
+ def source_paths(folder: Path, names: Collection[str]) -> dict[str, str]:
166
+ """Where each bare file name a test failure gives is in the project, relative to its folder.
167
+
168
+ A failure on Xcode 26.6 says only ``TripTests.swift:6``, which an agent would then have to go and find (27.0 gives
169
+ the whole path). A name only one file in the project has is that file; a name several have is left as it came, since
170
+ picking one could send the agent to the wrong file.
171
+ """
172
+ found: dict[str, list[str]] = {name: [] for name in names if name and "/" not in name}
173
+ for root, files in _project_files(folder) if found else ():
174
+ for name in found.keys() & set(files):
175
+ found[name].append(os.path.relpath(os.path.join(root, name), folder))
176
+ return {name: paths[0] for name, paths in found.items() if len(paths) == 1}
177
+
178
+
179
+ def changed(project: Project) -> tuple[Any, ...]:
180
+ """How a project stands in the ways its schemes or its app could change.
181
+
182
+ Its file and every scheme in it: schemes live in `xcshareddata` or a person's own `xcuserdata`, levels below the
183
+ project, so each is looked at. A workspace holds none of that itself, so each project it names is looked at the
184
+ same way; and so is every `.xcconfig` in the folder the project or workspace is in, where build settings such as
185
+ a bundle id live. A project's folder is small (DerivedData is elsewhere, and build output is skipped).
186
+ """
187
+ stamps: list[Any] = [
188
+ _stamp(project.path / "project.pbxproj"),
189
+ _stamp(project.path / "contents.xcworkspacedata"),
190
+ *_schemes(project.path),
191
+ ]
192
+ if project.path.suffix == ".xcworkspace":
193
+ try:
194
+ contents = (project.path / "contents.xcworkspacedata").read_text(encoding="utf-8", errors="replace")
195
+ except OSError:
196
+ contents = ""
197
+ for location in _PROJECT_REF.findall(contents):
198
+ named = project.path.parent / location
199
+ stamps += [(str(named), _stamp(named / "project.pbxproj")), *_schemes(named)]
200
+ return (*stamps, *_configs(project.path.parent))
201
+
202
+
203
+ def find_project(folder: Path, *, project: object = None, workspace: object = None) -> Project:
204
+ """The project or workspace a build uses, from the build folder or as named."""
205
+ for value, what in ((project, "project"), (workspace, "workspace")):
206
+ if value is not None and (not isinstance(value, str) or "\x00" in value or len(value) > PATH_MAX):
207
+ raise BuildRefused(f"{what} is the name of an Xcode {what} in {folder}")
208
+ if project and workspace:
209
+ raise BuildRefused("name a project or a workspace, not both")
210
+ named = project or workspace
211
+ if isinstance(named, str) and named:
212
+ flag, suffix = ("-project", ".xcodeproj") if project else ("-workspace", ".xcworkspace")
213
+ path = (folder / named).resolve()
214
+ if path.suffix != suffix or not path.is_dir():
215
+ there = _listed([found.name for found in projects(folder)])
216
+ raise BuildRefused(f"{named} is not an Xcode {flag[1:]} in {folder}; it has {there}")
217
+ if not path.is_relative_to(folder.resolve()):
218
+ raise BuildRefused(f"the {flag[1:]} must be inside {folder}")
219
+ return Project(flag, path)
220
+ everything = projects(folder)
221
+ workspaces = [path for path in everything if path.suffix == ".xcworkspace"]
222
+ found = workspaces or everything
223
+ if len(found) == 1:
224
+ return Project("-workspace" if workspaces else "-project", found[0])
225
+ if not found:
226
+ raise BuildRefused(
227
+ f"there is no Xcode project or workspace in {folder}, the folder builds run in; "
228
+ "name one inside it with project or workspace"
229
+ )
230
+ raise BuildRefused(f"this folder has several; name one: {_listed([path.name for path in found])}")
231
+
232
+
233
+ def projects(folder: Path) -> list[Path]:
234
+ """The workspaces, then the projects, at the top of a folder."""
235
+ return [
236
+ path for suffix in (".xcworkspace", ".xcodeproj") for path in sorted(folder.glob(f"*{suffix}")) if path.is_dir()
237
+ ]
238
+
239
+
240
+ def _scheme(target: Project, listing: Listing, scheme: object) -> str:
241
+ """The scheme named, or the only one there is."""
242
+ schemes = listing.schemes
243
+ if scheme is not None:
244
+ name = _named(scheme, "scheme", schemes)
245
+ if name not in schemes:
246
+ raise BuildRefused(f"{target.path.name} has no scheme {name}; it has {_listed(schemes)}")
247
+ return name
248
+ if len(schemes) == 1:
249
+ return schemes[0]
250
+ if not schemes:
251
+ raise BuildRefused(
252
+ f"{target.path.name} has no schemes to build: make one in Xcode (Product > Scheme > New Scheme) and tick "
253
+ "Shared under Manage Schemes, so it is kept with the project"
254
+ )
255
+ raise BuildRefused(f"{target.path.name} has several schemes; name one: {_listed(schemes)}")
256
+
257
+
258
+ def _configuration(target: Project, listing: Listing, configuration: object) -> str:
259
+ """The build configuration named, when the project has it. A workspace's are its projects', which it does not
260
+ list, so there the name is xcodebuild's to find."""
261
+ known = listing.configurations
262
+ name = _named(configuration, "configuration", known)
263
+ if known and name not in known:
264
+ raise BuildRefused(
265
+ f"{target.path.name} has no build configuration {name}; it has {_listed(known)} "
266
+ "(name one with configuration, or change build.configuration)"
267
+ )
268
+ return name
269
+
270
+
271
+ @dataclass
272
+ class Build:
273
+ id: str
274
+ scope: Scope
275
+ kind: str
276
+ project: Project
277
+ scheme: str
278
+ configuration: str
279
+ udid: str
280
+ developer_dir: str
281
+ folder: Path
282
+ derived: Path
283
+ bundle: Path
284
+ log: Path
285
+ started: float
286
+ #: The scheme's test plan this run names, or "" when the scheme has none and Xcode uses its own test action.
287
+ test_plan: str = ""
288
+ #: Every scheme the project has, for a refusal that has to point at another.
289
+ schemes: tuple[str, ...] = ()
290
+ #: Whether the answer lists warnings as well as errors.
291
+ warnings: bool = False
292
+ #: The simulator a test run was sent to, when it is not the scope's own device; "" when it is.
293
+ device: str = ""
294
+ process: Any = None
295
+ state: str = "running"
296
+ answer: str = ""
297
+ app: Path | None = None
298
+ bundle_id: str | None = None
299
+ task: asyncio.Task[None] | None = None
300
+
301
+ @property
302
+ def label(self) -> str:
303
+ """What a person reads at the head of the answer. The plan is named only when there is one to name, so the
304
+ usual line is no longer for having the feature."""
305
+ plan = f" · {self.test_plan}" if self.test_plan else ""
306
+ device = f" · on {self.device}" if self.device else ""
307
+ return f"{self.scheme} ({self.configuration}){plan}{device}"
308
+
309
+
310
+ #: Run after a build that succeeded, with the build; answers with the lines to add (install, launch).
311
+ After = Callable[[Build], Awaitable[list[str]]]
312
+
313
+
314
+ #: How many finished runs a scope keeps readable by build_id; older ones are let go.
315
+ KEEP_FINISHED = 20
316
+ #: How many of a scope's runs a refusal for an unknown build_id names.
317
+ RECENT_LISTED = 5
318
+
319
+
320
+ class BuildRunner:
321
+ """Every scope's builds and test runs, for one process."""
322
+
323
+ def __init__(
324
+ self,
325
+ state: StateStore,
326
+ *,
327
+ xcrun: XcrunRunner = run_xcrun,
328
+ start: Starter = start_xcrun,
329
+ clock: Callable[[], float] = time.monotonic,
330
+ wall: Callable[[], float] = time.time,
331
+ signal_group: Callable[[int, int], None] = signal_process_group,
332
+ stop_grace_s: float = STOP_GRACE_S,
333
+ copy: HostCopy | None = None,
334
+ ) -> None:
335
+ self._state = state
336
+ self._copy = copy or HostCopy()
337
+ self._xcrun = xcrun
338
+ self._start = start
339
+ self._clock = clock
340
+ self._wall = wall
341
+ self._signal = signal_group
342
+ self._stop_grace_s = stop_grace_s
343
+ self._builds: dict[str, Build] = {}
344
+ self._running: dict[str, Build] = {}
345
+ #: What `-list` and `-showBuildSettings` answered, kept while the project is unchanged: each takes seconds,
346
+ #: and an up-to-date rebuild that spends ten of its eleven seconds asking them again is the agent waiting.
347
+ self._listings: dict[tuple[str, str], tuple[tuple[Any, ...], Listing]] = {}
348
+ #: A scheme's test plans, keyed by project, scheme and Xcode, and thrown away when the project changes.
349
+ self._plans: dict[tuple[str, str, str], tuple[tuple[Any, ...], list[str]]] = {}
350
+ self._apps: dict[tuple[str, ...], tuple[tuple[Any, ...], Path, str | None]] = {}
351
+ self._ids = itertools.count(1)
352
+ self._lock = asyncio.Lock()
353
+
354
+ def running(self, scope_id: str) -> Build | None:
355
+ return self._running.get(scope_id)
356
+
357
+ def runs(self) -> list[Build]:
358
+ """Every build or test run still going."""
359
+ return list(self._running.values())
360
+
361
+ async def start(
362
+ self,
363
+ scope: Scope,
364
+ *,
365
+ kind: str,
366
+ folder: Path,
367
+ udid: str,
368
+ developer_dir: str,
369
+ configuration: object,
370
+ timeout_s: float,
371
+ scheme: object = None,
372
+ project: object = None,
373
+ workspace: object = None,
374
+ only_testing: object = None,
375
+ skip_testing: object = None,
376
+ test_plan: object = None,
377
+ retries: int = 0,
378
+ warnings: bool = False,
379
+ test_diagnostics: bool = False,
380
+ device: str = "",
381
+ after: After | None = None,
382
+ ) -> Build:
383
+ """Start a build or a test run for a scope. Refuses with a reason, never runs two at once for one scope."""
384
+ if kind not in KINDS:
385
+ raise BuildRefused(f"a run is one of {', '.join(KINDS)}")
386
+ if test_plan is not None and kind != "test":
387
+ raise BuildRefused("a test plan says which tests to run; name one on a test run, not a build")
388
+ if retries and kind != "test":
389
+ raise BuildRefused("only a test run can be retried; a build either succeeds or it does not")
390
+ async with self._lock:
391
+ current = self._running.get(scope.id)
392
+ if current is not None:
393
+ raise BuildRefused(
394
+ f"{current.kind} {current.id} is still running here; wait for it with build_id {current.id}"
395
+ )
396
+ target = find_project(folder, project=project, workspace=workspace)
397
+ only, skip = _test_ids(only_testing, "only_testing"), _test_ids(skip_testing, "skip_testing")
398
+ listing = await self._listing(target, developer_dir, folder)
399
+ chosen = _scheme(target, listing, scheme)
400
+ chosen_configuration = _configuration(target, listing, configuration)
401
+ plan = await self._test_plan(target, chosen, test_plan, developer_dir, folder)
402
+ build_id = f"b{next(self._ids)}"
403
+ stamp = time.strftime("%Y%m%d-%H%M%S", time.localtime(self._wall()))
404
+ bundle = self._state.ensure_dir(self._state.builds_dir(scope)) / f"{stamp}-{build_id}.xcresult"
405
+ build = Build(
406
+ id=build_id,
407
+ scope=scope,
408
+ kind=kind,
409
+ project=target,
410
+ scheme=chosen,
411
+ test_plan=plan,
412
+ configuration=chosen_configuration,
413
+ udid=udid,
414
+ developer_dir=developer_dir,
415
+ folder=folder,
416
+ derived=self._state.derived_data(scope),
417
+ bundle=bundle,
418
+ log=bundle.with_suffix(".log"),
419
+ started=self._clock(),
420
+ schemes=listing.schemes,
421
+ warnings=warnings,
422
+ device=device,
423
+ )
424
+ argv = [
425
+ "xcodebuild",
426
+ *target.args,
427
+ "-scheme",
428
+ chosen,
429
+ "-configuration",
430
+ chosen_configuration,
431
+ "-destination",
432
+ f"platform=iOS Simulator,id={udid}",
433
+ "-derivedDataPath",
434
+ str(build.derived),
435
+ "-resultBundlePath",
436
+ str(bundle),
437
+ *(("-testPlan", plan) if plan else ()),
438
+ # Left to Xcode, a test run with a failure goes on to `simctl diagnose --timeout=600` before it ends
439
+ # (measured on Xcode 26.6), which the answer does not need: its failures are in the result bundle.
440
+ *(
441
+ ("-collect-test-diagnostics", "on-failure" if test_diagnostics else "never")
442
+ if kind == "test"
443
+ else ()
444
+ ),
445
+ # `-test-iterations` counts the first run too, and on its own it would re-run the tests that
446
+ # passed as well; `-retry-tests-on-failure` is what confines the repeats to the ones that failed.
447
+ *(("-retry-tests-on-failure", "-test-iterations", str(retries + 1)) if retries else ()),
448
+ *(f"-only-testing:{test}" for test in only),
449
+ *(f"-skip-testing:{test}" for test in skip),
450
+ kind,
451
+ ]
452
+ try:
453
+ build.process = await self._start(*argv, log_path=build.log, developer_dir=developer_dir, cwd=folder)
454
+ except OSError as exc:
455
+ raise BuildRefused(f"xcodebuild could not be started: {exc}. {self._copy.doctor_hint}") from exc
456
+ self._forget_finished(scope.id)
457
+ self._builds[build_id] = build
458
+ self._running[scope.id] = build
459
+ build.task = asyncio.get_running_loop().create_task(self._watch(build, timeout_s, after))
460
+ return build
461
+
462
+ def _forget_finished(self, scope_id: str) -> None:
463
+ """Keep a scope's last `KEEP_FINISHED` finished runs readable by id, and let go of the rest."""
464
+ finished = [
465
+ build
466
+ for build in self._builds.values()
467
+ if build.scope.id == scope_id and build.task is not None and build.task.done()
468
+ ]
469
+ if len(finished) > KEEP_FINISHED:
470
+ for build in finished[: len(finished) - KEEP_FINISHED]:
471
+ del self._builds[build.id]
472
+
473
+ async def _listing(self, target: Project, developer_dir: str, folder: Path) -> Listing:
474
+ """The project's schemes and configurations, asked for again only once the project has changed."""
475
+ stamp = changed(target)
476
+ known = self._listings.get((str(target.path), developer_dir))
477
+ if known is not None and known[0] == stamp:
478
+ return known[1]
479
+ listed = await self._json(
480
+ ("xcodebuild", "-list", "-json", *target.args),
481
+ developer_dir,
482
+ folder,
483
+ LIST_TIMEOUT_S,
484
+ refuse=f"xcodebuild could not list {target.path.name}",
485
+ )
486
+ body = (listed.get("workspace") or listed.get("project")) if isinstance(listed, dict) else None
487
+ found = body if isinstance(body, dict) else {}
488
+ listing = Listing(_names(found.get("schemes")), _names(found.get("configurations")))
489
+ self._listings[(str(target.path), developer_dir)] = (stamp, listing)
490
+ return listing
491
+
492
+ async def _test_plan(self, target: Project, scheme: str, wanted: object, developer_dir: str, folder: Path) -> str:
493
+ """The test plan to run, or "" for a scheme that has none.
494
+
495
+ A scheme either has test plans or has the older test action, and `xcodebuild -showTestPlans` answers
496
+ ``{"testPlans": null}`` for the second (measured on Xcode 26.6; both answers are fixtures). Naming a plan a
497
+ scheme does not have is refused with the ones it does, because an agent cannot see the scheme and guessing
498
+ again is the only other thing it can do. Naming none leaves the choice to Xcode, which runs the scheme's
499
+ default -- the same thing that happened before this argument existed.
500
+ """
501
+ if wanted is None:
502
+ return ""
503
+ stamp = changed(target)
504
+ known = self._plans.get((str(target.path), scheme, developer_dir))
505
+ if known is not None and known[0] == stamp:
506
+ plans = known[1]
507
+ else:
508
+ listed = await self._json(
509
+ ("xcodebuild", "-showTestPlans", "-scheme", scheme, "-json", *target.args),
510
+ developer_dir,
511
+ folder,
512
+ LIST_TIMEOUT_S,
513
+ refuse=f"xcodebuild could not list the test plans of {scheme}",
514
+ )
515
+ entries = listed.get("testPlans") if isinstance(listed, dict) else None
516
+ plans = [str(entry["name"]) for entry in entries or [] if isinstance(entry, dict) and entry.get("name")]
517
+ self._plans[(str(target.path), scheme, developer_dir)] = (stamp, plans)
518
+ name = _named(wanted, "test_plan", plans)
519
+ if name in plans:
520
+ return name
521
+ if not plans:
522
+ raise BuildRefused(
523
+ f"{scheme} has no test plans; it runs the tests its scheme names, so leave test_plan out"
524
+ )
525
+ raise BuildRefused(f"{scheme} has no test plan {name}; it has {', '.join(plans)}")
526
+
527
+ async def _json(
528
+ self, args: Sequence[str], developer_dir: str, folder: Path | None, timeout: float, *, refuse: str | None = None
529
+ ) -> Any:
530
+ """What an xcrun call answered as JSON -- or, when it did not, a refusal (or None when not refusing)."""
531
+ result = await self._xcrun(*args, timeout=timeout, developer_dir=developer_dir, cwd=folder)
532
+ try:
533
+ document = json.loads(result.out) if result.ok else None
534
+ except ValueError:
535
+ document = None
536
+ if document is None and refuse is not None:
537
+ hint = f" {self._copy.doctor_hint}" if result.rc in (XCRUN_MISSING, CANNOT_RUN) else ""
538
+ raise BuildRefused(f"{refuse}: {result.message}{hint}")
539
+ return document
540
+
541
+ async def _watch(self, build: Build, timeout_s: float, after: After | None) -> None:
542
+ try:
543
+ try:
544
+ await asyncio.wait_for(build.process.wait(), timeout=timeout_s)
545
+ except (asyncio.TimeoutError, TimeoutError):
546
+ await self._end(build)
547
+ build.state = "timed_out"
548
+ build.answer = (
549
+ f"{build.kind} {build.id} was stopped after {round(timeout_s)}s, the longest a run may take here "
550
+ f"(build.timeout_minutes, {self._copy.settings})\nlog {build.log}"
551
+ )
552
+ return
553
+ lines = await self._results(build)
554
+ if build.state == "succeeded" and build.kind == "build" and after is not None:
555
+ try:
556
+ lines += await after(build)
557
+ except BuildRefused as exc:
558
+ lines.append(f"not launched: {exc}")
559
+ build.answer = "\n".join([*lines, f"log {build.log}"])
560
+ except asyncio.CancelledError:
561
+ await self._end(build)
562
+ build.state = "cancelled"
563
+ build.answer = f"{build.kind} {build.id} was cancelled\nlog {build.log}"
564
+ raise
565
+ finally:
566
+ self._running.pop(build.scope.id, None)
567
+
568
+ async def _results(self, build: Build) -> list[str]:
569
+ bundle = str(build.bundle)
570
+ if build.kind == "build":
571
+ document = await self._build_results(build)
572
+ if not isinstance(document, dict):
573
+ build.state = "failed"
574
+ return [f"build {build.id} ended without a result to read ({build.label}); the log says why"]
575
+ summary = xcresult.build_summary(document)
576
+ build.state = "succeeded" if summary.succeeded else "failed"
577
+ if summary.succeeded:
578
+ build.app, build.bundle_id = await self._built_app(build)
579
+ return xcresult.render_build(summary, label=build.label, root=build.folder, warnings=build.warnings)
580
+ report = await self._json(
581
+ ("xcresulttool", "get", "test-results", "summary", "--path", bundle),
582
+ build.developer_dir,
583
+ None,
584
+ RESULT_TIMEOUT_S,
585
+ )
586
+ if not isinstance(report, dict):
587
+ build.state = "failed"
588
+ return [f"test {build.id} ended without a result to read ({build.label}); the log says why"]
589
+ if not xcresult.any_test_ran(report):
590
+ document = await self._build_results(build)
591
+ compiled = xcresult.build_summary(document) if isinstance(document, dict) else None
592
+ if compiled is not None and not compiled.succeeded:
593
+ build.state = "failed"
594
+ return xcresult.render_build(
595
+ compiled, label=build.label, root=build.folder, warnings=build.warnings, kind="test"
596
+ )
597
+ tests = await self._json(
598
+ ("xcresulttool", "get", "test-results", "tests", "--path", bundle),
599
+ build.developer_dir,
600
+ None,
601
+ RESULT_TIMEOUT_S,
602
+ )
603
+ summary_of_tests = xcresult.suite_summary(report, tests if isinstance(tests, dict) else None)
604
+ places = source_paths(build.folder, [failure.file for failure in summary_of_tests.failures if failure.file])
605
+ summary_of_tests = xcresult.placed(summary_of_tests, places)
606
+ build.state = "succeeded" if summary_of_tests.passed else "failed"
607
+ return xcresult.render_tests(summary_of_tests, label=build.label, root=build.folder)
608
+
609
+ async def _build_results(self, build: Build) -> Any:
610
+ return await self._json(
611
+ ("xcresulttool", "get", "build-results", "--path", str(build.bundle)),
612
+ build.developer_dir,
613
+ None,
614
+ RESULT_TIMEOUT_S,
615
+ )
616
+
617
+ async def _built_app(self, build: Build) -> tuple[Path | None, str | None]:
618
+ """Where the built app is and what it is called, from the build settings of its application target.
619
+
620
+ Kept for the next build of the same scheme and configuration while the project is unchanged and the app is
621
+ still where they said.
622
+ """
623
+ key = (str(build.project.path), build.developer_dir, build.scheme, build.configuration, str(build.derived))
624
+ stamp = changed(build.project)
625
+ known = self._apps.get(key)
626
+ if known is not None and known[0] == stamp and known[1].exists():
627
+ return known[1], known[2]
628
+ app, bundle_id = await self._read_built_app(build)
629
+ if app is not None:
630
+ self._apps[key] = (stamp, app, bundle_id)
631
+ return app, bundle_id
632
+
633
+ async def _read_built_app(self, build: Build) -> tuple[Path | None, str | None]:
634
+ settings = await self._json(
635
+ (
636
+ "xcodebuild",
637
+ "-showBuildSettings",
638
+ "-json",
639
+ *build.project.args,
640
+ "-scheme",
641
+ build.scheme,
642
+ "-configuration",
643
+ build.configuration,
644
+ "-destination",
645
+ f"platform=iOS Simulator,id={build.udid}",
646
+ "-derivedDataPath",
647
+ str(build.derived),
648
+ ),
649
+ build.developer_dir,
650
+ build.folder,
651
+ SETTINGS_TIMEOUT_S,
652
+ )
653
+ for entry in settings if isinstance(settings, list) else []:
654
+ values = entry.get("buildSettings") if isinstance(entry, dict) else None
655
+ if not isinstance(values, dict):
656
+ continue
657
+ wrapper = values.get("WRAPPER_NAME")
658
+ if isinstance(wrapper, str) and wrapper.endswith(".app") and values.get("TARGET_BUILD_DIR"):
659
+ return Path(values["TARGET_BUILD_DIR"]) / wrapper, values.get("PRODUCT_BUNDLE_IDENTIFIER")
660
+ return None, None
661
+
662
+ async def _end(self, build: Build) -> None:
663
+ """End a build and everything it started: TERM to its group, then KILL if it does not go."""
664
+ process = build.process
665
+ if process is None or process.returncode is not None:
666
+ return
667
+ self._signal(process.pid, signal.SIGTERM)
668
+ try:
669
+ await asyncio.wait_for(process.wait(), timeout=self._stop_grace_s)
670
+ except (asyncio.TimeoutError, TimeoutError):
671
+ self._signal(process.pid, signal.SIGKILL)
672
+
673
+ async def result(self, scope_id: str, build_id: object, wait_s: float) -> str:
674
+ """A scope's build's answer, waiting up to `wait_s` for it -- or how long it has been running.
675
+
676
+ Another scope's build is not there to be read: its errors name that scope's files.
677
+ """
678
+ build = self._builds.get(build_id) if isinstance(build_id, str) else None
679
+ if build is None or build.scope.id != scope_id:
680
+ recent = [known.id for known in self._builds.values() if known.scope.id == scope_id][-RECENT_LISTED:]
681
+ there = f"; recent runs here are {', '.join(recent)}" if recent else "; leave build_id out to start a run"
682
+ raise BuildRefused(f"there is no build {build_id!r}{there}")
683
+ if build.task is not None and not build.task.done():
684
+ await asyncio.wait({build.task}, timeout=wait_s)
685
+ if build.task is not None and not build.task.done():
686
+ return (
687
+ f"{build.kind} {build.id} still running ({round(self._clock() - build.started)}s) · "
688
+ f"call again with build_id {build.id}"
689
+ )
690
+ return build.answer
691
+
692
+ async def cancel(self, scope_id: str) -> bool:
693
+ """End a scope's running build, if it has one."""
694
+ build = self._running.get(scope_id)
695
+ if build is None or build.task is None:
696
+ return False
697
+ build.task.cancel()
698
+ with contextlib.suppress(asyncio.CancelledError):
699
+ await build.task
700
+ return True
701
+
702
+ async def cancel_group(self, group: str) -> int:
703
+ """End every run a group of scopes has going -- its simulator was switched off. Answers how many."""
704
+ scope_ids = [scope_id for scope_id, build in list(self._running.items()) if build.scope.group == group]
705
+ return sum([await self.cancel(scope_id) for scope_id in scope_ids])
706
+
707
+ async def shutdown(self) -> None:
708
+ for scope_id in list(self._running):
709
+ await self.cancel(scope_id)