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
sim_mirror/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """SimMirror: mirror and drive the iOS Simulator from AI agents and the browser."""
3
+
4
+ from sim_mirror._version import __version__
5
+
6
+ __all__ = ["__version__"]
sim_mirror/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """``python -m sim_mirror``: the same as the ``sim-mirror`` command."""
3
+
4
+ from sim_mirror.cli.main import main
5
+
6
+ if __name__ == "__main__": # pragma: no cover - run as a module
7
+ raise SystemExit(main())
sim_mirror/_version.py ADDED
@@ -0,0 +1,4 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """The package version, read by the build backend and by `sim-mirror version`."""
3
+
4
+ __version__ = "0.2.0"
sim_mirror/api.py ADDED
@@ -0,0 +1,98 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """The surface a host application embeds SimMirror through.
3
+
4
+ A host builds one `Runtime` from its seams -- where settings come from, where state lives, which device a scope
5
+ remembers, what a scope may do, who is asking, and the words its messages use -- mounts the router factories under its
6
+ own prefixes, and gives its agents the relay's command line. Everything a host needs is named here; anything else in
7
+ the package may change between minor versions.
8
+
9
+ What that promise is worth, and when it starts binding, is `docs/stability.md`: while SimMirror is 0.x these names
10
+ can still change in a minor release, and from 1.0 they cannot. `tests/unit/test_api.py` writes the list down so one
11
+ cannot leave it by accident.
12
+
13
+ Three of these are here because a real host needed them and had to reach past this surface to get them:
14
+ `InvalidScope`, which `Scope` raises; `JsonDeviceMemory`, so a host need not write a `DeviceMemory` of its own; and
15
+ `claims_dir`, which is how every host on one Mac sees the same device claims and so refuses each other's devices
16
+ rather than fighting over one.
17
+
18
+ A host that lets a page change its settings mounts `create_settings_router` with a `SettingsStore`, a
19
+ `SettingsAuthenticator` and, for the settings a page may not change alone, `Confirmations`. One that keeps its own
20
+ settings screens mounts none of it.
21
+
22
+ A host can also run no SimMirror of its own and share the local daemon with other hosts instead: `DaemonHost`, with a
23
+ host token for its namespaces, answering `AgentAccess` for its agents and raising `DaemonRefused` or
24
+ `DaemonUnavailable`.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from sim_mirror.config.model import SimConfig
30
+ from sim_mirror.config.provenance import SettingOrigin
31
+ from sim_mirror.core.devices import JsonDeviceMemory
32
+ from sim_mirror.core.manager import SimulatorUnavailable
33
+ from sim_mirror.core.runtime import Runtime
34
+ from sim_mirror.host_copy import HostCopy
35
+ from sim_mirror.hosting import AgentAccess, DaemonHost, DaemonRefused
36
+ from sim_mirror.mcp.launcher import DaemonUnavailable
37
+ from sim_mirror.mcp.relay import relay_command
38
+ from sim_mirror.scope import InvalidScope, Scope
39
+ from sim_mirror.seams import (
40
+ Admission,
41
+ Authenticator,
42
+ Caller,
43
+ ConfigSource,
44
+ Confirmations,
45
+ DeviceMemory,
46
+ HeldDevice,
47
+ Person,
48
+ Policy,
49
+ Refused,
50
+ SettingsAuthenticator,
51
+ SettingsEditor,
52
+ SettingsRefused,
53
+ SettingsStore,
54
+ StateStore,
55
+ UsageProbe,
56
+ )
57
+ from sim_mirror.server.agent_routes import create_agent_router
58
+ from sim_mirror.server.http_routes import create_http_router
59
+ from sim_mirror.server.settings_routes import create_settings_router
60
+ from sim_mirror.server.socket_routes import create_socket_router
61
+ from sim_mirror.storage.app_support import claims_dir
62
+
63
+ __all__ = [
64
+ "Admission",
65
+ "AgentAccess",
66
+ "Authenticator",
67
+ "Caller",
68
+ "ConfigSource",
69
+ "Confirmations",
70
+ "DaemonHost",
71
+ "DaemonRefused",
72
+ "DaemonUnavailable",
73
+ "DeviceMemory",
74
+ "HeldDevice",
75
+ "HostCopy",
76
+ "InvalidScope",
77
+ "JsonDeviceMemory",
78
+ "Person",
79
+ "Policy",
80
+ "Refused",
81
+ "Runtime",
82
+ "Scope",
83
+ "SettingOrigin",
84
+ "SettingsAuthenticator",
85
+ "SettingsEditor",
86
+ "SettingsRefused",
87
+ "SettingsStore",
88
+ "SimConfig",
89
+ "SimulatorUnavailable",
90
+ "StateStore",
91
+ "UsageProbe",
92
+ "claims_dir",
93
+ "create_agent_router",
94
+ "create_http_router",
95
+ "create_settings_router",
96
+ "create_socket_router",
97
+ "relay_command",
98
+ ]
@@ -0,0 +1,2 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Building and testing an app for a scope's device, off unless ``build.tools`` is on."""
@@ -0,0 +1,82 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Another simulator a test run may use: `sim_test`'s ``destination``.
3
+
4
+ A test run uses the scope's own device unless the call names another -- to run the tests on an older iOS, or on an
5
+ iPad, without changing which device the scope shows. It is named the way a person names it, ``{"name": "iPhone 17"}``
6
+ or ``{"udid": "…"}``, with ``"runtime": "iOS 26.5"`` to choose between simulators of one name, and must be one of the
7
+ iOS simulators the scope's Xcode lists (`DeviceManager.devices`). A name that matches none, or several, is refused
8
+ with the ones there are, so an agent can pick again rather than guess.
9
+
10
+ Whether the device is someone else's to use is the provider's to ask (`build/provider.py`): this only reads the call.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Sequence
16
+ from typing import NoReturn
17
+
18
+ from sim_mirror.build.xcodebuild import BuildRefused
19
+ from sim_mirror.platform.simctl import is_udid
20
+ from sim_mirror.protocol import DeviceChoice
21
+
22
+ #: The keys a destination may have.
23
+ KEYS = frozenset({"name", "udid", "runtime"})
24
+ #: The longest name or runtime a destination may give.
25
+ TEXT_MAX = 100
26
+ #: The most simulators a refusal lists; the rest are counted.
27
+ LISTED_MAX = 20
28
+
29
+ USAGE = (
30
+ 'destination is {"name": "iPhone 17"} or {"udid": "…"}, with "runtime": "iOS 26.5" to choose between '
31
+ "simulators of one name"
32
+ )
33
+
34
+
35
+ def _text(value: object) -> str | None:
36
+ if value is None:
37
+ return None
38
+ if not isinstance(value, str) or not value.strip() or len(value) > TEXT_MAX or not value.isprintable():
39
+ raise BuildRefused(USAGE)
40
+ return value.strip()
41
+
42
+
43
+ def described(choice: DeviceChoice) -> str:
44
+ return f"{choice['name']} ({choice['runtime']})"
45
+
46
+
47
+ def _listed(choices: Sequence[DeviceChoice]) -> str:
48
+ shown = ", ".join(described(choice) for choice in choices[:LISTED_MAX])
49
+ more = len(choices) - LISTED_MAX
50
+ return shown + (f" and {more} more" if more > 0 else "") if shown else "none"
51
+
52
+
53
+ def choose_destination(choices: Sequence[DeviceChoice], value: object) -> DeviceChoice:
54
+ """The simulator a call's ``destination`` names, from the ones the scope could use. Raises `BuildRefused`."""
55
+ if not isinstance(value, dict) or not value.keys() <= KEYS or ("name" in value) == ("udid" in value):
56
+ raise BuildRefused(USAGE)
57
+ name, udid, runtime = _text(value.get("name")), _text(value.get("udid")), _text(value.get("runtime"))
58
+ if udid is not None and not is_udid(udid):
59
+ raise BuildRefused(f"{udid!r} is not a simulator's udid; {USAGE}")
60
+ found = [
61
+ choice
62
+ for choice in choices
63
+ if (udid is None or choice["udid"].upper() == udid.upper())
64
+ and (name is None or choice["name"] == name)
65
+ and (runtime is None or choice["runtime"] == runtime)
66
+ ]
67
+ if len(found) == 1:
68
+ return found[0]
69
+ if found:
70
+ _several(name or "", found)
71
+ wanted = udid or (f"{name} ({runtime})" if runtime else name)
72
+ raise BuildRefused(f"there is no simulator {wanted} on this Mac for this Xcode; there are {_listed(choices)}")
73
+
74
+
75
+ def _several(name: str, found: Sequence[DeviceChoice]) -> NoReturn:
76
+ runtimes = sorted({choice["runtime"] for choice in found})
77
+ if len(runtimes) == len(found):
78
+ raise BuildRefused(f"several simulators are named {name}; add a runtime: {', '.join(runtimes)}")
79
+ raise BuildRefused(
80
+ f"several simulators are named {name} on one runtime; name one by udid: "
81
+ + ", ".join(f"{choice['udid']} ({choice['runtime']})" for choice in found[:LISTED_MAX])
82
+ )
@@ -0,0 +1,176 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """The build tools -- `sim_build_run` and `sim_test` -- listed while a scope's ``build.tools`` is on.
3
+
4
+ A build runs xcodebuild, which is running commands: the tools are refused on every call unless the host also lets the
5
+ scope run commands (`Policy.shells_allowed`). A build that succeeded is installed and launched on the scope's device,
6
+ drawn for anyone watching; while tests run the device takes no agent gestures, and every screen watching is told why.
7
+
8
+ A test run may be sent to another simulator on this Mac instead (`destination`). Not to one that is somebody else's:
9
+ another scope's running device, one another scope's tests are running on, or one another process on the Mac has
10
+ claimed. xcodebuild boots it if it has to, and it is left running, as Xcode leaves it.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from sim_mirror.build.destination import choose_destination, described
19
+ from sim_mirror.build.xcodebuild import Build, BuildRefused, BuildRunner
20
+ from sim_mirror.config.model import SimConfig
21
+ from sim_mirror.connectors.base import Capability
22
+ from sim_mirror.core.actions import ActionError
23
+ from sim_mirror.core.instance import DeviceInstance
24
+ from sim_mirror.core.manager import DeviceManager, SimulatorUnavailable
25
+ from sim_mirror.platform.simctl import SimctlError
26
+ from sim_mirror.tools.context import Tool, ToolContext, flag_arg, make_tool, ready_device, whole_arg
27
+ from sim_mirror.tools.results import Result, ToolRefused, text
28
+ from sim_mirror.tools.schemas import (
29
+ BUILD_INSTRUCTIONS,
30
+ BUILD_WAIT_BOUNDS,
31
+ BUILD_WAIT_S,
32
+ SHELL_INSTRUCTIONS,
33
+ TEST_RETRIES_BOUNDS,
34
+ )
35
+
36
+ NEEDS = (Capability.LIFECYCLE, Capability.APP_INSTALL, Capability.APP_LAUNCH)
37
+
38
+
39
+ def _builds(ctx: ToolContext) -> tuple[BuildRunner, Path]:
40
+ """The build runner and the build folder -- or why this scope builds nothing now."""
41
+ if not ctx.config.build_tools:
42
+ raise ToolRefused(ctx.copy.build_tools_off())
43
+ if not ctx.shells_allowed:
44
+ raise ToolRefused(ctx.copy.shells_not_allowed())
45
+ if ctx.builds is None:
46
+ raise ToolRefused("The build runner is not running.")
47
+ if ctx.folder is None:
48
+ raise ToolRefused(ctx.copy.no_build_folder())
49
+ return ctx.builds, ctx.folder
50
+
51
+
52
+ async def _install_and_launch(build: Build, instance: DeviceInstance, ctx: ToolContext) -> list[str]:
53
+ """Put a build that succeeded on the device and open it, drawn for anyone watching."""
54
+ if build.app is None or build.bundle_id is None:
55
+ others = [name for name in build.schemes if name != build.scheme]
56
+ there = f"; the other schemes are {', '.join(others)}" if others else ""
57
+ raise BuildRefused(
58
+ f"{build.scheme} builds no app to install -- a framework or a test bundle, say -- so there is nothing to "
59
+ f"launch; name a scheme that builds an iOS app{there}"
60
+ )
61
+ simctl = ctx.manager.simctl(instance)
62
+ try:
63
+ installing = simctl.install(instance.udid, str(build.app))
64
+ await ctx.actions.announced(instance, ctx.caller, "app", f"install {build.app.name}", installing)
65
+ launching = simctl.launch(instance.udid, build.bundle_id)
66
+ pid = await ctx.actions.announced(instance, ctx.caller, "app", f"launch {build.bundle_id}", launching)
67
+ except (SimctlError, ActionError) as exc:
68
+ raise BuildRefused(str(exc)) from exc
69
+ if pid is not None:
70
+ instance.launched[build.bundle_id] = pid
71
+ return [f"installed {build.app.name}", f"launched {build.bundle_id}" + (f" (pid {pid})" if pid else "")]
72
+
73
+
74
+ def _keep_busy(manager: DeviceManager, instance: DeviceInstance, build: Build) -> None:
75
+ """Tests drive the device themselves: an agent's gestures would land in the middle of them.
76
+
77
+ Every screen watching is told what the device is busy with, so a person can see why it is not theirs to touch.
78
+ """
79
+ busy = f"running tests ({build.id})"
80
+ instance.busy = busy
81
+ manager.publish_status(instance)
82
+
83
+ def free(_: object) -> None:
84
+ if instance.busy == busy:
85
+ instance.busy = None
86
+ manager.publish_status(instance)
87
+
88
+ assert build.task is not None
89
+ build.task.add_done_callback(free)
90
+
91
+
92
+ async def _elsewhere(ctx: ToolContext, runner: BuildRunner, value: object) -> tuple[str, str] | None:
93
+ """The simulator a test run's ``destination`` names, and how its answer names it -- None for the scope's own
94
+ device -- or why it is not this scope's to use."""
95
+ try:
96
+ choices = await ctx.manager.devices(ctx.scope)
97
+ except SimulatorUnavailable as exc:
98
+ raise ToolRefused(str(exc)) from exc
99
+ choice = choose_destination(choices, value)
100
+ udid, named = choice["udid"], described(choice)
101
+ own = ctx.manager.instance(ctx.scope)
102
+ if own is not None and own.udid == udid:
103
+ return None
104
+ noun = ctx.copy.scope_noun
105
+ if any(instance.udid == udid for instance in ctx.manager.instances()):
106
+ raise BuildRefused(f"{named} is another {noun}'s simulator here; name one nobody is using")
107
+ if any(build.udid == udid and build.scope.id != ctx.scope.id for build in runner.runs()):
108
+ raise BuildRefused(f"another {noun}'s tests are running on {named}; name another simulator")
109
+ claim = await ctx.manager.holder(udid)
110
+ if claim is not None:
111
+ raise BuildRefused(ctx.copy.claimed(claim.owner, claim.pid))
112
+ return udid, named
113
+
114
+
115
+ async def _run(args: dict[str, Any], ctx: ToolContext, kind: str) -> Result:
116
+ runner, folder = _builds(ctx)
117
+ wait_s = whole_arg(args.get("wait_s"), BUILD_WAIT_S, BUILD_WAIT_BOUNDS, "wait_s")
118
+ if args.get("build_id") is not None:
119
+ return text(await runner.result(ctx.scope.id, args["build_id"], wait_s))
120
+ if kind == "build" and args.get("destination") is not None:
121
+ raise ToolRefused("destination is for sim_test: a build is installed and launched on your own simulator")
122
+ elsewhere = await _elsewhere(ctx, runner, args["destination"]) if args.get("destination") is not None else None
123
+ instance: DeviceInstance | None = None
124
+ if elsewhere is not None:
125
+ udid, device = elsewhere
126
+ else:
127
+ instance = await ready_device(ctx)
128
+ udid, device = instance.udid, ""
129
+
130
+ async def after(build: Build) -> list[str]:
131
+ assert instance is not None
132
+ return await _install_and_launch(build, instance, ctx)
133
+
134
+ build = await runner.start(
135
+ ctx.scope,
136
+ kind=kind,
137
+ folder=folder,
138
+ udid=udid,
139
+ developer_dir=instance.developer_dir if instance else ctx.config.developer_dir,
140
+ device=device,
141
+ configuration=args.get("configuration") or ctx.config.build_configuration,
142
+ timeout_s=ctx.config.build_timeout_minutes * 60,
143
+ scheme=args.get("scheme"),
144
+ project=args.get("project"),
145
+ workspace=args.get("workspace"),
146
+ only_testing=args.get("only_testing"),
147
+ skip_testing=args.get("skip_testing"),
148
+ test_plan=args.get("test_plan"),
149
+ retries=whole_arg(args.get("retries"), 0, TEST_RETRIES_BOUNDS, "retries"),
150
+ warnings=flag_arg(args.get("warnings"), "warnings"),
151
+ test_diagnostics=ctx.config.build_test_diagnostics,
152
+ after=after if kind == "build" else None,
153
+ )
154
+ if kind == "test" and instance is not None:
155
+ _keep_busy(ctx.manager, instance, build)
156
+ return text(await runner.result(ctx.scope.id, build.id, wait_s))
157
+
158
+
159
+ async def _build_run(args: dict[str, Any], ctx: ToolContext) -> Result:
160
+ return await _run(args, ctx, "build")
161
+
162
+
163
+ async def _test(args: dict[str, Any], ctx: ToolContext) -> Result:
164
+ return await _run(args, ctx, "test")
165
+
166
+
167
+ class BuildToolProvider:
168
+ """The build tools: listed while ``build.tools`` is on; otherwise the agent is told to build from its shell."""
169
+
170
+ tools: tuple[Tool, ...] = (make_tool("sim_build_run", NEEDS, _build_run), make_tool("sim_test", NEEDS, _test))
171
+
172
+ def offered(self, config: SimConfig) -> bool:
173
+ return config.build_tools
174
+
175
+ def instructions(self, config: SimConfig) -> str:
176
+ return BUILD_INSTRUCTIONS if config.build_tools else SHELL_INSTRUCTIONS