1 # ControlKit runners
2
3 ControlKit is the native XCTest runner used by the CLI's Apple-platform MCP
4 tools. The public runner project is
5 [`xcrs-controlkit`](https://github.com/ondeinference/xcrs-controlkit). It uses
6 one Xcode project generated by XcodeGen, with platform-specific targets and
7 schemes:
8
9 | Platform | Scheme | App product |
10 | --- | --- | --- |
11 | iOS / iPadOS | `ControlKit-iOS` | `ControlKit.app` |
12 | tvOS | `ControlKit-tvOS` | `ControlKit.app` |
13 | visionOS | `ControlKit-visionOS` | `ControlKit.app` |
14 | watchOS | `ControlKit-watchOS` | `ControlKit.app` |
15 | macOS | `ControlKit-macOS` | `ControlKit.app` |
16
17 The scheme and target names keep their platform suffixes so Xcode can select
18 the correct SDK, while each built app is named `ControlKit`.
19
20 ## Build a runner
21
22 Install XcodeGen once, clone the runner project, and generate the Xcode
23 project:
24
25 ```sh
26 brew install xcodegen
27 cd <path-to-xcrs-controlkit>/Runners
28 xcodegen generate
29 ```
30
31 Build the simulator runner for the platform you need:
32
33 ```sh
34 xcodebuild -project XCRSControlKitRunner.xcodeproj \
35 -scheme ControlKit-<platform> \
36 -destination 'generic/platform=<platform> Simulator' \
37 build-for-testing
38 ```
39
40 Replace `<platform>` with `iOS`, `tvOS`, `watchOS`, or `visionOS`. macOS is
41 built with `-destination 'platform=macOS'` and runs on the local Mac rather
42 than a simulator.
43
44 ## Start the runner
45
46 Boot a compatible simulator and run the UI-test host:
47
48 ```sh
49 xcodebuild -project XCRSControlKitRunner.xcodeproj \
50 -scheme ControlKit-<platform> \
51 -destination 'id=<simulator-udid>' \
52 test-without-building
53 ```
54
55 The shared XCTest runner starts a local ControlKit JSON-RPC server on
56 `127.0.0.1:12004`. Keep that test process running while calling the MCP tools.
57 The macOS UI-test target includes the network-server entitlement required to
58 bind the local port.
59
60 ## Run on a physical device
61
62 The same runner also works installed on a real iOS, tvOS, watchOS, or
63 visionOS device, not just a simulator. Build and start it against the
64 device's UDID (find it with `xcrun devicectl list devices`) instead of a
65 simulator destination:
66
67 ```sh
68 xcodebuild -project XCRSControlKitRunner.xcodeproj \
69 -scheme ControlKit-<platform> \
70 -destination 'id=<device-udid>' \
71 build-for-testing
72
73 TEST_RUNNER_CONTROLKIT_LISTEN_HOST=0.0.0.0 xcodebuild \
74 -project XCRSControlKitRunner.xcodeproj \
75 -scheme ControlKit-<platform> \
76 -destination 'id=<device-udid>' \
77 test-without-building
78 ```
79
80 The `CONTROLKIT_LISTEN_HOST` environment variable controls what the
81 in-process RPC server binds to (`Shared/ControlKitRPCServer.swift`) — it
82 defaults to `127.0.0.1`, which is loopback-only *on the device itself* and
83 unreachable from your Mac. `xcodebuild` forwards any `TEST_RUNNER_`-prefixed
84 environment variable into the test process with the prefix stripped, so
85 setting `TEST_RUNNER_CONTROLKIT_LISTEN_HOST=0.0.0.0` makes the server bind on
86 every interface, including the one reachable over your LAN. Find the device's
87 address with its Bonjour hostname (`<device-name>.local`, discoverable via
88 `dns-sd -B _airplay._tcp local` for an Apple TV) or its CoreDevice tunnel
89 address (`xcrun devicectl device info details --device <udid>`, under
90 `Tunnel IP Address`).
91
92 ## Connect through the automation MCP profile
93
94 Start either automation server:
95
96 ```sh
97 xcrs --mcp
98 # or
99 smb --mcp --scope automation
100 ```
101
102 The runner tools accept either `simulator_name` or `simulator_udid` for a
103 simulator, or `host` (plus `controlkit_port`, defaulting to `12004`) for a
104 physical device or any other remote runner. Call `device_select` once to
105 remember the target so later tools need no target arguments. Both commands
106 expose the same unprefixed tool names.
107
108 | Tool | Runner | Purpose |
109 | --- | --- | --- |
110 | `device_select` | All platforms | Remember the active simulator or device for later tools. |
111 | `device_capabilities` | All platforms | Read platform and capability information. |
112 | `input_click` | macOS | Click at screen coordinates. |
113 | `input_spatial_tap` | visionOS | Perform a spatial tap. |
114 | `input_tap` | iOS / tvOS / watchOS, simulator or physical | Perform a touch tap. |
115 | `input_text` | iOS / tvOS, simulator or physical | Type into the focused field. |
116 | `input_swipe` | iOS / tvOS, simulator or physical | Swipe between coordinates. |
117 | `input_button` | iOS / tvOS, simulator or physical | Press a Home or tvOS remote button. |
118 | `app_launch`/`app_terminate` | simulator or physical | Launch/terminate an app by bundle ID. On a physical device this calls the runner's `device.apps.launch`/`device.apps.terminate` RPC methods instead of `simctl`. |
119 | `screen_capture` | Simulator or physical | Capture a PNG screenshot. Uses `simctl` for a simulator, or `devicectl device capture screenshot` when a `device` identifier is given. |
120 | `ui_describe` | All UI-test runners | Read the accessibility hierarchy for the supplied `bundle_id`. |
121 | `ui_element_list` | All UI-test runners | Read actionable elements with tap coordinates for the supplied `bundle_id`. |
122 | `ui_tap` | All UI-test runners | Resolve and activate an accessibility element by label, identifier, or value. |
123
124 UI introspection requires a recent ControlKit runner that implements
125 `device.dump.ui`. The `ui_tap` action requires a runner that implements
126 `device.ui.tap`; it resolves and activates the requested element inside the
127 target app, so an MCP client does not need to turn accessibility coordinates
128 into a second action. The label, identifier, or value must match exactly one
129 element. On tvOS, that element must already have focus; use `input_button` to
130 navigate focus before calling `ui_tap`. All three UI tools require the target
131 app's `bundle_id`; this lets XCTest attach to an already-running app instead of
132 accidentally reading the ControlKit host app.
133
134 For a local macOS runner, omit simulator fields and pass `host` and
135 `controlkit_port` when needed:
136
137 ```json
138 {
139 "host": "127.0.0.1",
140 "controlkit_port": 12004,
141 "x": 400,
142 "y": 300
143 }
144 ```
145
146 For a physical device, pass its Bonjour hostname or tunnel address as `host`
147 instead:
148
149 ```json
150 {
151 "host": "<device-name>.local",
152 "controlkit_port": 12004,
153 "button": "select"
154 }
155 ```
156
157 Platform-specific operations intentionally reject unsupported input. For
158 example, visionOS rejects raw touch, watchOS rejects pointer and spatial input,
159 and macOS rejects touch and Home-button requests.
160
161 ## Inspect the macOS host
162
163 The `host` subcommands describe what is actually on the local Mac's screen.
164 They are for the cases where a UI test's own output is not enough: which
165 windows are up, what text a screenshot contains, or which colors run along one
166 line of pixels. Unlike the runner tools above they talk to no device and start
167 no XCTest — they run a small Swift helper,
168 [`xcrs-host-inspect`](https://github.com/ondeinference/xcrs-controlkit), in
169 process on the host. They need macOS 13 or later for the AppKit window server
170 and Vision's text recognizer, are CLI-only, and are not part of the `--mcp`
171 tool set.
172
173 ### Build the helper
174
175 Build it once with SwiftPM from the `xcrs-controlkit` checkout:
176
177 ```sh
178 cd <path-to-xcrs-controlkit>
179 swift build -c release
180 ```
181
182 The CLI uses the first `xcrs-host-inspect` it finds, in this order:
183
184 1. the path in the `XCRS_HOST_INSPECT` environment variable,
185 2. next to the `xcrs` binary itself, for a release that ships the helper,
186 3. `.build/release`, then `.build/debug`, relative to the current directory,
187 4. `~/bin`, `/usr/local/bin`, and `/opt/homebrew/bin`.
188
189 Because steps 3 are relative, running `xcrs` from inside the checkout finds a
190 `swift build` without any further setup. Set `XCRS_HOST_INSPECT` when you have
191 several builds and want to choose between them.
192
193 ### Read the screen
194
195 List the host's on-screen windows, ordered by layer and then window id:
196
197 ```sh
198 xcrs host windows
199 xcrs host windows --owner Mail --json
200 ```
201
202 Each window reports its `id`, the owning application name (`owner`), its
203 `title`, its `layer`, and its `bounds`. `--owner` keeps only windows owned by
204 one application name.
205
206 Read the text in an image with Vision and print each string with its
207 confidence:
208
209 ```sh
210 xcrs host ocr --image shot.png
211 xcrs host ocr --image shot.png --language en-US --language sv-SE --json
212 ```
213
214 `--language` is repeatable; omit it to use the platform's own default.
215
216 Crop a rect out of an image and write it to a new PNG:
217
218 ```sh
219 xcrs host crop --image shot.png --rect 20,40,320,180 --out header.png
220 ```
221
222 `--rect` takes `x,y,width,height`. The result reports the written path and its
223 pixel dimensions, and the input image is never modified.
224
225 Probe one row or column and describe it as a run of colors, so a solid surface
226 collapses to a single line instead of thousands of pixels:
227
228 ```sh
229 xcrs host probe --image shot.png --row --position 300
230 xcrs host probe --image shot.png --column --position 120 --from 40 --to 260
231 ```
232
233 `--position` is the row or column to read, and exactly one of `--row` or
234 `--column` is required. `--from` and `--to` clip the line to part of its
235 length. Runs report `start`, `end`, `width`, and a quantized `#rrggbb`
236 `color`, so low-order noise does not split one surface into several runs.
237
238 ### Coordinates and scale
239
240 Coordinates you pass are in caller units, and `--scale` says how many pixels
241 one unit covers: the default `1` reads them as pixels, and `2` reads them as
242 Retina points on a 2x display. `crop` and `probe` honor it for their rect,
243 position, and bounds. Output geometry follows the same convention — an image
244 reports `width`/`height` in caller units alongside `pixel_width`/
245 `pixel_height` — while window bounds are screen points regardless of
246 `--scale`.
247
248 ### Exit codes and errors
249
250 The helper separates a malformed command from failed work: it exits with
251 status 2 when it rejects the arguments, and 1 when the operation itself fails,
252 writing the reason to stderr. The CLI validates rects, the scale, and probe
253 bounds before it spawns the helper, so bad input usually never reaches it.
254 When the helper does fail, the CLI reports it with its status — for example
255 `xcrs-host-inspect crop exited with status 1: …` — and exits non-zero. A
256 helper that cannot be started at all, such as a missing binary, is reported
257 with a hint to run `swift build` in `xcrs-controlkit`.
258
259 ## See also
260
261 For the product-level overview of end-to-end testing with xcrs and
262 ControlKit, see [smbcloud.xyz/xcrs-end-to-end-testing](https://smbcloud.xyz/xcrs-end-to-end-testing).