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,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,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
|