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.
- sim_mirror/__init__.py +6 -0
- sim_mirror/__main__.py +7 -0
- sim_mirror/_version.py +4 -0
- sim_mirror/api.py +98 -0
- sim_mirror/build/__init__.py +2 -0
- sim_mirror/build/destination.py +82 -0
- sim_mirror/build/provider.py +176 -0
- sim_mirror/build/xcodebuild.py +709 -0
- sim_mirror/build/xcresult.py +365 -0
- sim_mirror/cli/__init__.py +2 -0
- sim_mirror/cli/config_command.py +100 -0
- sim_mirror/cli/context.py +128 -0
- sim_mirror/cli/devices.py +43 -0
- sim_mirror/cli/doctor.py +75 -0
- sim_mirror/cli/main.py +61 -0
- sim_mirror/cli/mcp.py +34 -0
- sim_mirror/cli/open_viewer.py +36 -0
- sim_mirror/cli/serve.py +73 -0
- sim_mirror/cli/settings_command.py +38 -0
- sim_mirror/cli/tokens.py +65 -0
- sim_mirror/cli/tools.py +32 -0
- sim_mirror/cli/version.py +31 -0
- sim_mirror/cli/xcode.py +90 -0
- sim_mirror/config/__init__.py +3 -0
- sim_mirror/config/discovery.py +19 -0
- sim_mirror/config/env.py +34 -0
- sim_mirror/config/model.py +79 -0
- sim_mirror/config/provenance.py +39 -0
- sim_mirror/config/schema.py +482 -0
- sim_mirror/config/settings_store.py +34 -0
- sim_mirror/config/toml_source.py +139 -0
- sim_mirror/config/writer.py +218 -0
- sim_mirror/connectors/__init__.py +7 -0
- sim_mirror/connectors/base.py +228 -0
- sim_mirror/connectors/idb/__init__.py +2 -0
- sim_mirror/connectors/idb/companion.py +351 -0
- sim_mirror/connectors/idb/connector.py +91 -0
- sim_mirror/connectors/idb/engine.py +149 -0
- sim_mirror/connectors/idb/frameworks.py +51 -0
- sim_mirror/connectors/idb/proto/__init__.py +8 -0
- sim_mirror/connectors/idb/proto/idb.proto +1301 -0
- sim_mirror/connectors/idb/proto/idb_grpc.py +776 -0
- sim_mirror/connectors/idb/proto/idb_pb2.py +452 -0
- sim_mirror/connectors/mcpbridge/__init__.py +3 -0
- sim_mirror/connectors/mcpbridge/client.py +264 -0
- sim_mirror/connectors/mcpbridge/connector.py +84 -0
- sim_mirror/connectors/mcpbridge/hierarchy.py +225 -0
- sim_mirror/connectors/mcpbridge/merge.py +68 -0
- sim_mirror/connectors/mcpbridge/reader.py +220 -0
- sim_mirror/connectors/registry.py +136 -0
- sim_mirror/connectors/simctl/__init__.py +2 -0
- sim_mirror/connectors/simctl/capture.py +79 -0
- sim_mirror/connectors/simctl/connector.py +74 -0
- sim_mirror/core/__init__.py +3 -0
- sim_mirror/core/actions.py +541 -0
- sim_mirror/core/availability.py +61 -0
- sim_mirror/core/devices.py +163 -0
- sim_mirror/core/events.py +46 -0
- sim_mirror/core/frames.py +308 -0
- sim_mirror/core/gestures.py +164 -0
- sim_mirror/core/instance.py +126 -0
- sim_mirror/core/manager.py +541 -0
- sim_mirror/core/reaper.py +64 -0
- sim_mirror/core/runtime.py +214 -0
- sim_mirror/core/screen_input.py +170 -0
- sim_mirror/core/screen_relay.py +275 -0
- sim_mirror/core/status.py +58 -0
- sim_mirror/core/text_entry.py +64 -0
- sim_mirror/core/tickets.py +43 -0
- sim_mirror/daemon/__init__.py +2 -0
- sim_mirror/daemon/app.py +332 -0
- sim_mirror/daemon/auth.py +164 -0
- sim_mirror/daemon/confirmations.py +110 -0
- sim_mirror/daemon/health.py +47 -0
- sim_mirror/daemon/lease.py +41 -0
- sim_mirror/daemon/lifecycle.py +77 -0
- sim_mirror/daemon/passes.py +83 -0
- sim_mirror/daemon/policy.py +37 -0
- sim_mirror/daemon/tokens.py +302 -0
- sim_mirror/doctor/__init__.py +2 -0
- sim_mirror/doctor/checks.py +303 -0
- sim_mirror/doctor/macos.py +67 -0
- sim_mirror/doctor/report.py +59 -0
- sim_mirror/doctor/tap.py +139 -0
- sim_mirror/host_copy.py +136 -0
- sim_mirror/hosting.py +190 -0
- sim_mirror/mcp/__init__.py +2 -0
- sim_mirror/mcp/launcher.py +252 -0
- sim_mirror/mcp/relay.py +316 -0
- sim_mirror/perception/__init__.py +9 -0
- sim_mirror/perception/budget.py +66 -0
- sim_mirror/perception/model.py +65 -0
- sim_mirror/perception/readers.py +204 -0
- sim_mirror/perception/settle.py +93 -0
- sim_mirror/perception/snapshot.py +252 -0
- sim_mirror/perception/wait.py +73 -0
- sim_mirror/platform/__init__.py +3 -0
- sim_mirror/platform/developer_dir.py +72 -0
- sim_mirror/platform/images.py +51 -0
- sim_mirror/platform/keyboard.py +29 -0
- sim_mirror/platform/process.py +105 -0
- sim_mirror/platform/simctl.py +275 -0
- sim_mirror/platform/xcode.py +20 -0
- sim_mirror/platform/xcrun.py +129 -0
- sim_mirror/protocol/__init__.py +39 -0
- sim_mirror/protocol/_generated.py +605 -0
- sim_mirror/protocol/messages.py +168 -0
- sim_mirror/py.typed +0 -0
- sim_mirror/scope.py +67 -0
- sim_mirror/seams.py +236 -0
- sim_mirror/server/__init__.py +2 -0
- sim_mirror/server/agent_routes.py +54 -0
- sim_mirror/server/envelope.py +28 -0
- sim_mirror/server/http_routes.py +93 -0
- sim_mirror/server/log_redaction.py +45 -0
- sim_mirror/server/pages.py +59 -0
- sim_mirror/server/security.py +161 -0
- sim_mirror/server/settings_routes.py +188 -0
- sim_mirror/server/settings_view.py +82 -0
- sim_mirror/server/socket_routes.py +61 -0
- sim_mirror/server/static/viewer/embed.html +19 -0
- sim_mirror/server/static/viewer/index.html +19 -0
- sim_mirror/server/static/viewer/start.js +25 -0
- sim_mirror/storage/__init__.py +3 -0
- sim_mirror/storage/app_support.py +100 -0
- sim_mirror/storage/claims.py +146 -0
- sim_mirror/storage/private.py +135 -0
- sim_mirror/testing/__init__.py +2 -0
- sim_mirror/testing/asgi.py +96 -0
- sim_mirror/testing/contract.py +73 -0
- sim_mirror/testing/fakes.py +732 -0
- sim_mirror/testing/fixtures/ax-alert-ios27.json +162 -0
- sim_mirror/testing/fixtures/ax-home-complete.json +1 -0
- sim_mirror/testing/fixtures/ax-home-ios27.json +447 -0
- sim_mirror/testing/fixtures/ax-keyboard-ios27.json +1261 -0
- sim_mirror/testing/fixtures/ax-probe-ios27.json +444 -0
- sim_mirror/testing/fixtures/ax-safari-ios27.json +188 -0
- sim_mirror/testing/fixtures/ax-safari-typed.json +1 -0
- sim_mirror/testing/fixtures/ax-settings-interactable.json +1 -0
- sim_mirror/testing/fixtures/ax-settings-ios27.json +425 -0
- sim_mirror/testing/fixtures/mcpbridge-answers.json +183 -0
- sim_mirror/testing/fixtures/mcpbridge-hierarchy-alert.txt +46 -0
- sim_mirror/testing/fixtures/mcpbridge-hierarchy-home.txt +257 -0
- sim_mirror/testing/fixtures/mcpbridge-hierarchy-keyboard.txt +92 -0
- sim_mirror/testing/fixtures/mcpbridge-hierarchy-probe.txt +39 -0
- sim_mirror/testing/fixtures/mcpbridge-hierarchy-safari.txt +77 -0
- sim_mirror/testing/fixtures/mcpbridge-hierarchy-settings.txt +169 -0
- sim_mirror/testing/fixtures/simctl-devices.json +73 -0
- sim_mirror/testing/fixtures/simctl-runtimes.json +114 -0
- sim_mirror/testing/fixtures/xcodebuild-list-names.json +20 -0
- sim_mirror/testing/fixtures/xcodebuild-list.json +17 -0
- sim_mirror/testing/fixtures/xcodebuild-settings.json +13 -0
- sim_mirror/testing/fixtures/xcodebuild-test-plans-none.json +3 -0
- sim_mirror/testing/fixtures/xcodebuild-test-plans.json +10 -0
- sim_mirror/testing/fixtures/xcresult-build-broken.json +32 -0
- sim_mirror/testing/fixtures/xcresult-build-ok.json +27 -0
- sim_mirror/testing/fixtures/xcresult-build-results-unbuilt.json +36 -0
- sim_mirror/testing/fixtures/xcresult-test-summary-retries.json +56 -0
- sim_mirror/testing/fixtures/xcresult-test-summary-swift-testing-xcode27.json +63 -0
- sim_mirror/testing/fixtures/xcresult-test-summary-swift-testing.json +60 -0
- sim_mirror/testing/fixtures/xcresult-test-summary-unbuilt.json +24 -0
- sim_mirror/testing/fixtures/xcresult-test-summary.json +49 -0
- sim_mirror/testing/fixtures/xcresult-test-tests-retries.json +165 -0
- sim_mirror/testing/fixtures/xcresult-test-tests-swift-testing-xcode27.json +135 -0
- sim_mirror/testing/fixtures/xcresult-test-tests-swift-testing.json +125 -0
- sim_mirror/testing/fixtures/xcresult-test-tests.json +93 -0
- sim_mirror/testing/guards.py +129 -0
- sim_mirror/testing/rig.py +163 -0
- sim_mirror/tools/__init__.py +2 -0
- sim_mirror/tools/act.py +31 -0
- sim_mirror/tools/app.py +127 -0
- sim_mirror/tools/context.py +120 -0
- sim_mirror/tools/device.py +51 -0
- sim_mirror/tools/registry.py +82 -0
- sim_mirror/tools/results.py +30 -0
- sim_mirror/tools/schemas.py +170 -0
- sim_mirror/tools/screenshot.py +28 -0
- sim_mirror/tools/snapshot.py +23 -0
- sim_mirror/validation.py +46 -0
- sim_mirror-0.2.0.dist-info/METADATA +210 -0
- sim_mirror-0.2.0.dist-info/RECORD +186 -0
- sim_mirror-0.2.0.dist-info/WHEEL +4 -0
- sim_mirror-0.2.0.dist-info/entry_points.txt +2 -0
- sim_mirror-0.2.0.dist-info/licenses/LICENSE +202 -0
- sim_mirror-0.2.0.dist-info/licenses/NOTICE +14 -0
- 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)
|