1 use anyhow::{anyhow, Context, Result};
2 use serde::{Deserialize, Serialize};
3 use std::collections::BTreeMap;
4 use std::env;
5 use std::ffi::OsStr;
6 use std::path::{Path, PathBuf};
7 use std::process::{Child, Command, Output, Stdio};
8 use std::time::Duration;
9
10 pub mod mcp;
11
12 const IOS_SIMULATOR_DESTINATION_PREFIX: &str = "platform=iOS Simulator,id=";
13 const CONTROLKIT_METHOD_NOT_FOUND: i64 = -32601;
14 const CONTROLKIT_RUNNER_INFO_TIMEOUT: Duration = Duration::from_secs(2);
15 const ANDROIDKIT_CALL_TIMEOUT: Duration = Duration::from_secs(10);
16 const ANDROIDKIT_START_TIMEOUT: Duration = Duration::from_secs(10);
17 const ANDROIDKIT_START_PROBE_TIMEOUT: Duration = Duration::from_millis(500);
18 const ANDROIDKIT_START_RETRY_DELAY: Duration = Duration::from_millis(100);
19
20 /// `ETXTBSY`, for an executable that another process still holds open for
21 /// writing. It has the same value on Linux and the BSDs, and `ErrorKind` has no
22 /// variant for it.
23 #[cfg(unix)]
24 const TEXT_FILE_BUSY: i32 = 26;
25 const TEXT_FILE_BUSY_RETRY_DELAY: Duration = Duration::from_millis(20);
26 const TEXT_FILE_BUSY_RETRY_ATTEMPTS: usize = 5;
27
28 pub fn encode_base64(data: impl AsRef<[u8]>) -> String {
29 use base64::{engine::general_purpose::STANDARD, Engine};
30
31 STANDARD.encode(data)
32 }
33
34 pub fn extract_controlkit_elements(root: &serde_json::Value) -> Vec<serde_json::Value> {
35 let mut elements = Vec::new();
36 collect_controlkit_elements(root, &mut elements);
37 elements
38 }
39
40 /// Normalize actionable AndroidKit nodes into the public element-list shape.
41 pub fn extract_androidkit_elements(root: &serde_json::Value) -> Vec<serde_json::Value> {
42 let mut elements = Vec::new();
43 if let Some(hierarchy) = root.get("hierarchy").and_then(serde_json::Value::as_array) {
44 for element in hierarchy {
45 collect_androidkit_elements(element, &mut elements);
46 }
47 } else {
48 collect_androidkit_elements(root, &mut elements);
49 }
50 elements
51 }
52
53 fn collect_androidkit_elements(element: &serde_json::Value, elements: &mut Vec<serde_json::Value>) {
54 let Some(object) = element.as_object() else {
55 return;
56 };
57
58 let rect = object.get("rect").and_then(serde_json::Value::as_object);
59 let has_visible_rect = object
60 .get("visible")
61 .and_then(serde_json::Value::as_bool)
62 .unwrap_or(false)
63 && rect
64 .and_then(|rect| {
65 Some((
66 rect.get("x")?.as_f64()?,
67 rect.get("y")?.as_f64()?,
68 rect.get("width")?.as_f64()?,
69 rect.get("height")?.as_f64()?,
70 ))
71 })
72 .is_some_and(|(_, _, width, height)| width > 0.0 && height > 0.0);
73 let text = object
74 .get("text")
75 .and_then(serde_json::Value::as_str)
76 .unwrap_or_default();
77 let description = object
78 .get("contentDescription")
79 .and_then(serde_json::Value::as_str)
80 .unwrap_or_default();
81 let resource_id = object
82 .get("resourceId")
83 .and_then(serde_json::Value::as_str)
84 .unwrap_or_default();
85 let hint = object
86 .get("hint")
87 .and_then(serde_json::Value::as_str)
88 .unwrap_or_default();
89
90 if has_visible_rect
91 && [text, description, resource_id, hint]
92 .iter()
93 .any(|value| !value.is_empty())
94 {
95 elements.push(serde_json::json!({
96 "type": object.get("role").cloned().unwrap_or(serde_json::Value::Null),
97 "label": if !text.is_empty() { text } else { description },
98 "name": resource_id,
99 "value": text,
100 "placeholderValue": hint,
101 "rawIdentifier": resource_id,
102 "rect": object.get("rect").cloned().unwrap_or(serde_json::Value::Null),
103 "enabled": object.get("enabled").cloned().unwrap_or(serde_json::Value::Bool(false)),
104 "selected": object.get("selected").cloned().unwrap_or(serde_json::Value::Bool(false)),
105 "hittable": object.get("clickable").cloned().unwrap_or(serde_json::Value::Bool(false)),
106 }));
107 }
108
109 if let Some(children) = object.get("children").and_then(serde_json::Value::as_array) {
110 for child in children {
111 collect_androidkit_elements(child, elements);
112 }
113 }
114 }
115
116 fn collect_controlkit_elements(element: &serde_json::Value, elements: &mut Vec<serde_json::Value>) {
117 let object = match element.as_object() {
118 Some(object) => object,
119 None => return,
120 };
121
122 let rect = object.get("rect").and_then(serde_json::Value::as_object);
123 let has_visible_rect = rect
124 .and_then(|rect| {
125 Some((
126 rect.get("x")?.as_f64()?,
127 rect.get("y")?.as_f64()?,
128 rect.get("width")?.as_f64()?,
129 rect.get("height")?.as_f64()?,
130 ))
131 })
132 .is_some_and(|(x, y, width, height)| x >= 0.0 && y >= 0.0 && width > 0.0 && height > 0.0);
133 let has_identity = ["label", "name", "value", "rawIdentifier"]
134 .iter()
135 .any(|key| {
136 object
137 .get(*key)
138 .and_then(serde_json::Value::as_str)
139 .is_some_and(|value| !value.is_empty())
140 });
141
142 if has_visible_rect && has_identity {
143 let keys = [
144 "type",
145 "label",
146 "name",
147 "value",
148 "placeholderValue",
149 "rawIdentifier",
150 "rect",
151 "depth",
152 "enabled",
153 "selected",
154 "hittable",
155 ];
156 let element = keys
157 .iter()
158 .filter_map(|key| {
159 object
160 .get(*key)
161 .map(|value| ((*key).to_string(), value.clone()))
162 })
163 .collect();
164 elements.push(serde_json::Value::Object(element));
165 }
166
167 if let Some(children) = object.get("children").and_then(serde_json::Value::as_array) {
168 for child in children {
169 collect_controlkit_elements(child, elements);
170 }
171 }
172 }
173
174 #[derive(Debug, Clone)]
175 pub struct ControlKit {
176 client: reqwest::Client,
177 base_url: String,
178 }
179
180 impl Default for ControlKit {
181 fn default() -> Self {
182 Self::new(12004)
183 }
184 }
185
186 impl ControlKit {
187 pub fn new(port: u16) -> Self {
188 Self::with_host("127.0.0.1", port)
189 }
190
191 pub fn with_host(host: &str, port: u16) -> Self {
192 let host = if host.contains(':') && !host.starts_with('[') {
193 format!("[{host}]")
194 } else {
195 host.to_string()
196 };
197 Self {
198 client: reqwest::Client::new(),
199 base_url: format!("http://{host}:{port}"),
200 }
201 }
202
203 pub async fn health(&self) -> Result<()> {
204 let response = self
205 .client
206 .get(format!("{}/health", self.base_url))
207 .timeout(Duration::from_secs(5))
208 .send()
209 .await
210 .with_context(|| format!("failed to connect to ControlKit at {}", self.base_url))?;
211
212 if !response.status().is_success() {
213 return Err(anyhow!(
214 "ControlKit health check returned HTTP {}",
215 response.status()
216 ));
217 }
218
219 Ok(())
220 }
221
222 pub async fn call(&self, method: &str, params: serde_json::Value) -> Result<serde_json::Value> {
223 let body = self.send(method, params).await?;
224
225 if let Some(error) = body.get("error") {
226 // `send` cannot recurse; this guard only avoids repeating an unsupported probe.
227 let should_probe_runner =
228 is_controlkit_method_not_found(error) && method != "device.info";
229 let (runner_info, runner_info_error) = if should_probe_runner {
230 match tokio::time::timeout(
231 CONTROLKIT_RUNNER_INFO_TIMEOUT,
232 self.send("device.info", serde_json::json!({})),
233 )
234 .await
235 {
236 Ok(Ok(body)) => match body.get("result") {
237 Some(result) => (Some(result.clone()), None),
238 None => {
239 let reason = body
240 .get("error")
241 .map(|error| format!("device.info returned {error}"))
242 .unwrap_or_else(|| {
243 "device.info response did not contain a result".to_string()
244 });
245 (None, Some(reason))
246 }
247 },
248 Ok(Err(error)) => (None, Some(error.to_string())),
249 Err(_) => (
250 None,
251 Some(format!(
252 "device.info timed out after {} seconds",
253 CONTROLKIT_RUNNER_INFO_TIMEOUT.as_secs()
254 )),
255 ),
256 }
257 } else {
258 (None, None)
259 };
260 return Err(anyhow!(controlkit_rpc_error(
261 method,
262 error,
263 runner_info.as_ref(),
264 runner_info_error.as_deref()
265 )));
266 }
267
268 body.get("result")
269 .cloned()
270 .ok_or_else(|| anyhow!("ControlKit response for '{method}' did not contain a result"))
271 }
272
273 async fn send(&self, method: &str, params: serde_json::Value) -> Result<serde_json::Value> {
274 let response = self
275 .client
276 .post(format!("{}/rpc", self.base_url))
277 .timeout(Duration::from_secs(30))
278 .json(&serde_json::json!({
279 "jsonrpc": "2.0",
280 "method": method,
281 "params": params,
282 "id": 1,
283 }))
284 .send()
285 .await
286 .with_context(|| format!("failed to connect to ControlKit at {}", self.base_url))?;
287
288 let status = response.status();
289 let body: serde_json::Value = response
290 .json()
291 .await
292 .context("failed to decode ControlKit JSON-RPC response")?;
293
294 if !status.is_success() {
295 return Err(anyhow!("ControlKit returned HTTP {}: {}", status, body));
296 }
297
298 Ok(body)
299 }
300 }
301
302 fn is_controlkit_method_not_found(error: &serde_json::Value) -> bool {
303 let Some(code) = error.get("code") else {
304 return false;
305 };
306
307 // Some runners serialise the JSON-RPC code as an integral float (e.g. `-32601.0`),
308 // which `as_i64` rejects, so fall back to an exact `as_f64` comparison for that case.
309 code.as_i64() == Some(CONTROLKIT_METHOD_NOT_FOUND)
310 || code.as_f64() == Some(CONTROLKIT_METHOD_NOT_FOUND as f64)
311 }
312
313 fn controlkit_rpc_error(
314 method: &str,
315 error: &serde_json::Value,
316 runner_info: Option<&serde_json::Value>,
317 runner_info_error: Option<&str>,
318 ) -> String {
319 let code = error
320 .get("code")
321 .and_then(serde_json::Value::as_i64)
322 .unwrap_or_default();
323 let message = error
324 .get("message")
325 .and_then(serde_json::Value::as_str)
326 .unwrap_or("unknown JSON-RPC error");
327
328 if !is_controlkit_method_not_found(error) {
329 return format!("ControlKit method '{method}' failed ({code}): {message}");
330 }
331
332 let runner = runner_info
333 .and_then(|info| info.get("runner"))
334 .and_then(serde_json::Value::as_str)
335 .unwrap_or("The connected ControlKit runner");
336 let protocol = runner_info
337 .and_then(|info| info.get("protocolVersion"))
338 .and_then(serde_json::Value::as_u64)
339 .map(|version| format!(" protocol {version}"))
340 .unwrap_or_default();
341
342 let runner_info_note = runner_info_error
343 .map(|error| format!(" Runner metadata could not be read: {error}."))
344 .unwrap_or_default();
345
346 format!(
347 "{runner}{protocol} does not implement ControlKit method '{method}'.{runner_info_note} Rebuild or upgrade xcrs-controlkit, restart its runner, and retry."
348 )
349 }
350
351 #[derive(Debug, Clone)]
352 pub struct XcodeCommandLineTools {
353 xcrun_path: PathBuf,
354 xcodebuild_path: PathBuf,
355 }
356
357 impl Default for XcodeCommandLineTools {
358 fn default() -> Self {
359 Self {
360 xcrun_path: PathBuf::from("xcrun"),
361 xcodebuild_path: PathBuf::from("xcodebuild"),
362 }
363 }
364 }
365
366 impl XcodeCommandLineTools {
367 pub fn new() -> Self {
368 Self::default()
369 }
370
371 pub fn with_paths(xcrun_path: impl Into<PathBuf>, xcodebuild_path: impl Into<PathBuf>) -> Self {
372 Self {
373 xcrun_path: xcrun_path.into(),
374 xcodebuild_path: xcodebuild_path.into(),
375 }
376 }
377
378 pub fn simctl(&self) -> Simctl<'_> {
379 Simctl { tools: self }
380 }
381
382 pub fn xcodebuild(&self) -> Xcodebuild<'_> {
383 Xcodebuild { tools: self }
384 }
385
386 pub fn devicectl(&self) -> Devicectl<'_> {
387 Devicectl { tools: self }
388 }
389
390 fn run_xcrun<I, S>(&self, args: I) -> Result<String>
391 where
392 I: IntoIterator<Item = S>,
393 S: AsRef<OsStr>,
394 {
395 run_command(&self.xcrun_path, args)
396 }
397
398 fn run_xcodebuild<I, S>(&self, args: I) -> Result<String>
399 where
400 I: IntoIterator<Item = S>,
401 S: AsRef<OsStr>,
402 {
403 run_command(&self.xcodebuild_path, args)
404 }
405 }
406
407 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
408 pub enum ApplePlatform {
409 #[serde(rename = "iOS")]
410 Ios,
411 #[serde(rename = "tvOS")]
412 Tvos,
413 #[serde(rename = "macOS")]
414 Macos,
415 #[serde(rename = "watchOS")]
416 Watchos,
417 #[serde(rename = "visionOS")]
418 Visionos,
419 #[serde(rename = "unknown")]
420 Unknown,
421 }
422
423 impl ApplePlatform {
424 fn from_runtime_identifier(runtime_identifier: &str) -> Self {
425 if runtime_identifier.contains(".iOS-") {
426 Self::Ios
427 } else if runtime_identifier.contains(".tvOS-") {
428 Self::Tvos
429 } else if runtime_identifier.contains(".macOS-") {
430 Self::Macos
431 } else if runtime_identifier.contains(".watchOS-") {
432 Self::Watchos
433 } else if runtime_identifier.contains(".visionOS-") || runtime_identifier.contains(".xrOS-")
434 {
435 Self::Visionos
436 } else {
437 Self::Unknown
438 }
439 }
440 }
441
442 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
443 pub struct Simulator {
444 pub platform: ApplePlatform,
445 pub runtime_identifier: String,
446 pub name: String,
447 pub udid: String,
448 pub state: String,
449 pub is_available: bool,
450 }
451
452 impl Simulator {
453 pub fn is_booted(&self) -> bool {
454 self.state == "Booted"
455 }
456 }
457
458 #[derive(Debug, Deserialize)]
459 struct SimctlDeviceList {
460 devices: BTreeMap<String, Vec<SimctlDevice>>,
461 }
462
463 #[derive(Debug, Deserialize)]
464 struct SimctlDevice {
465 name: String,
466 udid: String,
467 state: String,
468 #[serde(rename = "isAvailable")]
469 is_available: Option<bool>,
470 }
471
472 pub struct Simctl<'a> {
473 tools: &'a XcodeCommandLineTools,
474 }
475
476 impl Simctl<'_> {
477 pub fn list_simulators(&self) -> Result<Vec<Simulator>> {
478 let output = self
479 .tools
480 .run_xcrun(["simctl", "list", "devices", "--json"])?;
481 parse_simctl_devices(&output)
482 }
483
484 pub fn find_simulator_by_name(&self, name: &str) -> Result<Simulator> {
485 self.list_simulators()?
486 .into_iter()
487 .find(|simulator| simulator.name == name)
488 .ok_or_else(|| anyhow!("Apple simulator named '{name}' was not found"))
489 }
490
491 pub fn find_simulator_by_udid(&self, udid: &str) -> Result<Simulator> {
492 self.list_simulators()?
493 .into_iter()
494 .find(|simulator| simulator.udid == udid)
495 .ok_or_else(|| anyhow!("Apple simulator with UDID '{udid}' was not found"))
496 }
497
498 pub fn boot(&self, udid: &str) -> Result<()> {
499 if self.find_simulator_by_udid(udid)?.is_booted() {
500 return Ok(());
501 }
502
503 self.tools.run_xcrun(["simctl", "boot", udid])?;
504 Ok(())
505 }
506
507 pub fn shutdown(&self, udid: &str) -> Result<()> {
508 if !self.find_simulator_by_udid(udid)?.is_booted() {
509 return Ok(());
510 }
511
512 self.tools.run_xcrun(["simctl", "shutdown", udid])?;
513 Ok(())
514 }
515
516 pub fn install_app(&self, udid: &str, app_path: impl AsRef<Path>) -> Result<()> {
517 let app_path = app_path.as_ref();
518 ensure_path_exists(app_path)?;
519
520 let path = app_path
521 .to_str()
522 .ok_or_else(|| anyhow!("app path is not valid UTF-8: {}", app_path.display()))?;
523
524 self.tools.run_xcrun(["simctl", "install", udid, path])?;
525 Ok(())
526 }
527
528 pub fn uninstall_app(&self, udid: &str, bundle_id: &str) -> Result<()> {
529 self.tools
530 .run_xcrun(["simctl", "uninstall", udid, bundle_id])?;
531 Ok(())
532 }
533
534 pub fn launch_app(&self, udid: &str, bundle_id: &str) -> Result<()> {
535 self.tools
536 .run_xcrun(["simctl", "launch", udid, bundle_id])?;
537 Ok(())
538 }
539
540 pub fn terminate_app(&self, udid: &str, bundle_id: &str) -> Result<()> {
541 self.tools
542 .run_xcrun(["simctl", "terminate", udid, bundle_id])?;
543 Ok(())
544 }
545
546 pub fn open_url(&self, udid: &str, url: &str) -> Result<()> {
547 self.tools.run_xcrun(["simctl", "openurl", udid, url])?;
548 Ok(())
549 }
550
551 pub fn screenshot(&self, udid: &str) -> Result<Vec<u8>> {
552 let temporary_directory = tempfile::tempdir()?;
553 let screenshot_path = temporary_directory.path().join("screenshot.png");
554 let screenshot_path_string = screenshot_path
555 .to_str()
556 .ok_or_else(|| anyhow!("screenshot path is not valid UTF-8"))?;
557
558 self.tools
559 .run_xcrun(["simctl", "io", udid, "screenshot", screenshot_path_string])?;
560
561 std::fs::read(&screenshot_path)
562 .with_context(|| format!("failed to read {}", screenshot_path.display()))
563 }
564
565 pub fn app_container_path(&self, udid: &str, bundle_id: &str) -> Result<PathBuf> {
566 let output =
567 self.tools
568 .run_xcrun(["simctl", "get_app_container", udid, bundle_id, "app"])?;
569 let path = output.trim();
570 if path.is_empty() {
571 return Err(anyhow!(
572 "simctl returned an empty app container path for '{bundle_id}'"
573 ));
574 }
575
576 Ok(PathBuf::from(path))
577 }
578 }
579
580 pub struct Devicectl<'a> {
581 tools: &'a XcodeCommandLineTools,
582 }
583
584 impl Devicectl<'_> {
585 /// Captures a PNG screenshot from a physical device known to CoreDevice.
586 /// `device` accepts anything `devicectl --device` does: UDID, ECID,
587 /// serial number, user-provided name, or DNS name.
588 pub fn screenshot(&self, device: &str) -> Result<Vec<u8>> {
589 let temporary_directory = tempfile::tempdir()?;
590 let screenshot_path = temporary_directory.path().join("screenshot.png");
591 let screenshot_path_string = screenshot_path
592 .to_str()
593 .ok_or_else(|| anyhow!("screenshot path is not valid UTF-8"))?;
594
595 self.tools.run_xcrun([
596 "devicectl",
597 "device",
598 "capture",
599 "screenshot",
600 "--device",
601 device,
602 "--destination",
603 screenshot_path_string,
604 ])?;
605
606 std::fs::read(&screenshot_path)
607 .with_context(|| format!("failed to read {}", screenshot_path.display()))
608 }
609 }
610
611 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
612 pub struct AndroidDevice {
613 pub serial: String,
614 pub state: String,
615 pub product: Option<String>,
616 pub model: Option<String>,
617 pub device: Option<String>,
618 pub transport_id: Option<String>,
619 }
620
621 impl AndroidDevice {
622 pub fn is_connected(&self) -> bool {
623 self.state == "device"
624 }
625 }
626
627 /// The automation platform a normalized `device_list` entry belongs to.
628 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
629 #[serde(rename_all = "snake_case")]
630 pub enum DevicePlatform {
631 Apple,
632 Android,
633 }
634
635 /// The kind of device a normalized `device_list` entry represents. Apple physical
636 /// devices are not represented here: this crate has no reliable Apple
637 /// device-discovery command, so only Apple simulators are ever listed.
638 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
639 #[serde(rename_all = "snake_case")]
640 pub enum DeviceKind {
641 Simulator,
642 Emulator,
643 PhysicalDevice,
644 }
645
646 /// A device entry normalized across Apple simulators and Android devices/emulators
647 /// for the cross-platform `device_list` tool.
648 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
649 pub struct NormalizedDevice {
650 pub platform: DevicePlatform,
651 pub kind: DeviceKind,
652 /// Simulator UDID (Apple) or device serial (Android).
653 pub identifier: String,
654 pub name: String,
655 pub state: String,
656 /// The tool used to reach this device: "simctl" or "adb".
657 pub transport: String,
658 }
659
660 impl NormalizedDevice {
661 pub fn from_simulator(simulator: &Simulator) -> Self {
662 Self {
663 platform: DevicePlatform::Apple,
664 kind: DeviceKind::Simulator,
665 identifier: simulator.udid.clone(),
666 name: simulator.name.clone(),
667 state: simulator.state.clone(),
668 transport: "simctl".to_string(),
669 }
670 }
671
672 pub fn from_android_device(device: &AndroidDevice) -> Self {
673 let kind = if device.serial.starts_with("emulator-") {
674 DeviceKind::Emulator
675 } else {
676 DeviceKind::PhysicalDevice
677 };
678 let name = device
679 .model
680 .clone()
681 .or_else(|| device.device.clone())
682 .or_else(|| device.product.clone())
683 .unwrap_or_else(|| device.serial.clone());
684 Self {
685 platform: DevicePlatform::Android,
686 kind,
687 identifier: device.serial.clone(),
688 name,
689 state: device.state.clone(),
690 transport: "adb".to_string(),
691 }
692 }
693 }
694
695 #[derive(Debug, Clone)]
696 pub struct AndroidDebugBridge {
697 adb_path: PathBuf,
698 }
699
700 /// Client for an XCRS AndroidKit instrumentation runner forwarded over adb.
701 #[derive(Debug, Clone)]
702 pub struct AndroidKit {
703 client: reqwest::Client,
704 adb_path: PathBuf,
705 }
706
707 impl Default for AndroidKit {
708 fn default() -> Self {
709 Self {
710 client: reqwest::Client::new(),
711 adb_path: discover_adb_path(),
712 }
713 }
714 }
715
716 impl AndroidKit {
717 pub const PACKAGE_NAME: &'static str = "xyz.smbcloud.xcrs.androidkit";
718
719 pub fn new() -> Self {
720 Self::default()
721 }
722
723 pub async fn call(
724 &self,
725 serial: String,
726 method: &str,
727 params: serde_json::Value,
728 ) -> Result<serde_json::Value> {
729 self.call_with_timeout(serial, method, params, ANDROIDKIT_CALL_TIMEOUT)
730 .await
731 }
732
733 fn with_adb_path(adb_path: impl Into<PathBuf>) -> Self {
734 Self {
735 client: reqwest::Client::new(),
736 adb_path: adb_path.into(),
737 }
738 }
739
740 async fn call_with_timeout(
741 &self,
742 serial: String,
743 method: &str,
744 params: serde_json::Value,
745 timeout: Duration,
746 ) -> Result<serde_json::Value> {
747 let bridge = AndroidDebugBridge::with_path(self.adb_path.clone());
748 let local_port = tokio::task::spawn_blocking({
749 let serial = serial.clone();
750 move || bridge.forward_androidkit(&serial)
751 })
752 .await
753 .context("AndroidKit forwarding task failed")??;
754
755 let response = self
756 .client
757 .post(format!("http://127.0.0.1:{local_port}/rpc"))
758 .timeout(timeout)
759 .json(&serde_json::json!({
760 "jsonrpc": "2.0",
761 "id": 1,
762 "method": method,
763 "params": params,
764 }))
765 .send()
766 .await
767 .with_context(|| format!("failed to connect to XCRS AndroidKit for {serial}"));
768
769 let cleanup_bridge = AndroidDebugBridge::with_path(self.adb_path.clone());
770 let cleanup_serial = serial.clone();
771 let cleanup = tokio::task::spawn_blocking(move || {
772 cleanup_bridge.remove_forward(&cleanup_serial, local_port)
773 })
774 .await
775 .context("AndroidKit forwarding cleanup task failed")?;
776 cleanup.context("failed to remove AndroidKit adb forward")?;
777
778 let response = response?;
779 let status = response.status();
780 let body: serde_json::Value = response
781 .json()
782 .await
783 .context("failed to decode AndroidKit JSON-RPC response")?;
784 if !status.is_success() {
785 return Err(anyhow!("AndroidKit returned HTTP {status}: {body}"));
786 }
787 if let Some(error) = body.get("error") {
788 let code = error
789 .get("code")
790 .and_then(serde_json::Value::as_i64)
791 .unwrap_or_default();
792 let message = error
793 .get("message")
794 .and_then(serde_json::Value::as_str)
795 .unwrap_or("unknown JSON-RPC error");
796 return Err(anyhow!(
797 "AndroidKit method '{method}' failed ({code}): {message}"
798 ));
799 }
800 body.get("result")
801 .cloned()
802 .ok_or_else(|| anyhow!("AndroidKit response for '{method}' did not contain a result"))
803 }
804
805 async fn wait_until_ready(&self, serial: &str) -> Result<()> {
806 let deadline = tokio::time::Instant::now() + ANDROIDKIT_START_TIMEOUT;
807 loop {
808 match self
809 .call_with_timeout(
810 serial.to_string(),
811 "device.info",
812 serde_json::json!({}),
813 ANDROIDKIT_START_PROBE_TIMEOUT,
814 )
815 .await
816 {
817 Ok(_) => return Ok(()),
818 Err(error) if tokio::time::Instant::now() >= deadline => {
819 return Err(error).with_context(|| {
820 format!(
821 "AndroidKit did not become ready on {serial} within {} seconds",
822 ANDROIDKIT_START_TIMEOUT.as_secs()
823 )
824 });
825 }
826 Err(_) => tokio::time::sleep(ANDROIDKIT_START_RETRY_DELAY).await,
827 }
828 }
829 }
830 }
831
832 impl Default for AndroidDebugBridge {
833 fn default() -> Self {
834 Self {
835 adb_path: discover_adb_path(),
836 }
837 }
838 }
839
840 impl AndroidDebugBridge {
841 pub fn new() -> Self {
842 Self::default()
843 }
844
845 pub fn with_path(adb_path: impl Into<PathBuf>) -> Self {
846 Self {
847 adb_path: adb_path.into(),
848 }
849 }
850
851 pub fn list_devices(&self) -> Result<Vec<AndroidDevice>> {
852 let output = run_command(&self.adb_path, ["devices", "-l"])?;
853 parse_adb_devices(&output)
854 }
855
856 pub fn resolve_device(&self, serial: Option<&str>) -> Result<AndroidDevice> {
857 let devices = self.list_devices()?;
858 if let Some(serial) = serial {
859 let device = devices
860 .into_iter()
861 .find(|device| device.serial == serial)
862 .ok_or_else(|| anyhow!("Android device '{serial}' was not found"))?;
863 if !device.is_connected() {
864 return Err(anyhow!(
865 "Android device '{}' is {}",
866 device.serial,
867 device.state
868 ));
869 }
870 return Ok(device);
871 }
872
873 let connected_devices = devices
874 .into_iter()
875 .filter(AndroidDevice::is_connected)
876 .collect::<Vec<_>>();
877 match connected_devices.as_slice() {
878 [device] => Ok(device.clone()),
879 [] => Err(anyhow!(
880 "no authorized Android device is connected; check `adb devices -l`"
881 )),
882 _ => Err(anyhow!(
883 "multiple Android devices are connected; provide a device serial"
884 )),
885 }
886 }
887
888 pub fn screenshot(&self, serial: &str) -> Result<Vec<u8>> {
889 self.run_for_device_bytes(serial, ["exec-out", "screencap", "-p"])
890 }
891
892 pub fn launch_app(&self, serial: &str, package_name: &str) -> Result<()> {
893 validate_android_package_name(package_name)?;
894 let component = [
895 "android.intent.category.LEANBACK_LAUNCHER",
896 "android.intent.category.LAUNCHER",
897 ]
898 .into_iter()
899 .find_map(|category| {
900 let output = self
901 .run_shell(
902 serial,
903 &format!(
904 "cmd package resolve-activity --brief -a android.intent.action.MAIN -c {category} {}",
905 shell_escape(package_name)
906 ),
907 )
908 .ok()?;
909 parse_resolved_android_activity(&output)
910 })
911 .ok_or_else(|| anyhow!("no launchable activity found for Android app '{package_name}'"))?;
912
913 let output = self.run_shell(
914 serial,
915 &format!("am start -W -n {}", shell_escape(&component)),
916 )?;
917 if output.lines().any(|line| line.starts_with("Error:")) {
918 return Err(anyhow!(
919 "failed to launch Android app '{package_name}': {}",
920 output.trim()
921 ));
922 }
923 Ok(())
924 }
925
926 pub fn terminate_app(&self, serial: &str, package_name: &str) -> Result<()> {
927 validate_android_package_name(package_name)?;
928 self.run_shell(
929 serial,
930 &format!("am force-stop {}", shell_escape(package_name)),
931 )?;
932 Ok(())
933 }
934
935 pub fn open_url(&self, serial: &str, url: &str) -> Result<()> {
936 if url.is_empty() || url.contains(['\0', '\n', '\r']) {
937 return Err(anyhow!("URL must be a non-empty single-line value"));
938 }
939 self.run_shell(
940 serial,
941 &format!(
942 "am start -a android.intent.action.VIEW -d {}",
943 shell_escape(url)
944 ),
945 )?;
946 Ok(())
947 }
948
949 pub fn tap(&self, serial: &str, x: i32, y: i32) -> Result<()> {
950 self.run_shell(serial, &format!("input tap {x} {y}"))?;
951 Ok(())
952 }
953
954 pub fn type_text(&self, serial: &str, text: &str) -> Result<()> {
955 let text = encode_adb_input_text(text)?;
956 self.run_shell(serial, &format!("input text {}", shell_escape(&text)))?;
957 Ok(())
958 }
959
960 pub fn swipe(
961 &self,
962 serial: &str,
963 x1: i32,
964 y1: i32,
965 x2: i32,
966 y2: i32,
967 duration_ms: u32,
968 ) -> Result<()> {
969 if duration_ms > 10_000 {
970 return Err(anyhow!("swipe duration must not exceed 10000 milliseconds"));
971 }
972 self.run_shell(
973 serial,
974 &format!("input swipe {x1} {y1} {x2} {y2} {duration_ms}"),
975 )?;
976 Ok(())
977 }
978
979 pub fn press_button(&self, serial: &str, button: &str) -> Result<()> {
980 let keycode = match button {
981 "home" => 3,
982 "back" => 4,
983 "enter" => 66,
984 "recents" => 187,
985 _ => {
986 return Err(anyhow!(
987 "button must be one of home, back, enter, or recents"
988 ))
989 }
990 };
991 self.run_shell(serial, &format!("input keyevent {keycode}"))?;
992 Ok(())
993 }
994
995 pub fn install_androidkit(&self, serial: &str, apk_path: &Path) -> Result<()> {
996 if !apk_path.is_file() {
997 return Err(anyhow!(
998 "AndroidKit APK does not exist: {}",
999 apk_path.display()
1000 ));
1001 }
1002 run_command(
1003 &self.adb_path,
1004 [
1005 "-s",
1006 serial,
1007 "install",
1008 "-r",
1009 apk_path
1010 .to_str()
1011 .ok_or_else(|| anyhow!("AndroidKit APK path is not valid UTF-8"))?,
1012 ],
1013 )?;
1014 Ok(())
1015 }
1016
1017 pub async fn start_androidkit(&self, serial: &str) -> Result<()> {
1018 self.run_shell(
1019 serial,
1020 "nohup am instrument -w -r xyz.smbcloud.xcrs.androidkit/.AndroidKitInstrumentation >/dev/null 2>&1 &",
1021 )
1022 .with_context(|| format!("failed to start AndroidKit on {serial}"))?;
1023 AndroidKit::with_adb_path(self.adb_path.clone())
1024 .wait_until_ready(serial)
1025 .await
1026 .with_context(|| format!("failed to start AndroidKit on {serial}"))
1027 }
1028
1029 pub fn stop_androidkit(&self, serial: &str) -> Result<()> {
1030 self.run_shell(
1031 serial,
1032 &format!("am force-stop {}", AndroidKit::PACKAGE_NAME),
1033 )?;
1034 Ok(())
1035 }
1036
1037 fn forward_androidkit(&self, serial: &str) -> Result<u16> {
1038 let output = run_command(
1039 &self.adb_path,
1040 [
1041 "-s",
1042 serial,
1043 "forward",
1044 "tcp:0",
1045 "localabstract:xcrs-androidkit",
1046 ],
1047 )?;
1048 output
1049 .trim()
1050 .parse::<u16>()
1051 .with_context(|| format!("adb returned an invalid AndroidKit forward port: {output:?}"))
1052 }
1053
1054 fn remove_forward(&self, serial: &str, local_port: u16) -> Result<()> {
1055 run_command(
1056 &self.adb_path,
1057 [
1058 "-s",
1059 serial,
1060 "forward",
1061 "--remove",
1062 &format!("tcp:{local_port}"),
1063 ],
1064 )?;
1065 Ok(())
1066 }
1067
1068 fn run_shell(&self, serial: &str, command: &str) -> Result<String> {
1069 run_command(&self.adb_path, ["-s", serial, "shell", command])
1070 }
1071
1072 fn run_for_device_bytes<I, S>(&self, serial: &str, args: I) -> Result<Vec<u8>>
1073 where
1074 I: IntoIterator<Item = S>,
1075 S: AsRef<OsStr>,
1076 {
1077 let output = Command::new(&self.adb_path)
1078 .arg("-s")
1079 .arg(serial)
1080 .args(args)
1081 .output()
1082 .with_context(|| format!("failed to run {}", self.adb_path.display()))?;
1083 command_output_to_bytes(&self.adb_path, output)
1084 }
1085 }
1086
1087 fn discover_adb_path() -> PathBuf {
1088 let executable = if cfg!(windows) { "adb.exe" } else { "adb" };
1089 let mut candidates = ["ANDROID_SDK_ROOT", "ANDROID_HOME"]
1090 .into_iter()
1091 .filter_map(env::var_os)
1092 .map(PathBuf::from)
1093 .map(|path| path.join("platform-tools").join(executable))
1094 .collect::<Vec<_>>();
1095
1096 if let Some(home) = env::var_os("HOME").map(PathBuf::from) {
1097 candidates.push(
1098 home.join("Library")
1099 .join("Android")
1100 .join("sdk")
1101 .join("platform-tools")
1102 .join(executable),
1103 );
1104 candidates.push(
1105 home.join("Android")
1106 .join("Sdk")
1107 .join("platform-tools")
1108 .join(executable),
1109 );
1110 }
1111 if let Some(local_app_data) = env::var_os("LOCALAPPDATA").map(PathBuf::from) {
1112 candidates.push(
1113 local_app_data
1114 .join("Android")
1115 .join("Sdk")
1116 .join("platform-tools")
1117 .join(executable),
1118 );
1119 }
1120
1121 candidates
1122 .into_iter()
1123 .find(|path| path.is_file())
1124 .unwrap_or_else(|| PathBuf::from(executable))
1125 }
1126
1127 fn parse_adb_devices(output: &str) -> Result<Vec<AndroidDevice>> {
1128 let mut devices = Vec::new();
1129 for line in output.lines().map(str::trim) {
1130 if line.is_empty() || line.starts_with("List of devices attached") || line.starts_with('*')
1131 {
1132 continue;
1133 }
1134
1135 let fields = line.split_whitespace().collect::<Vec<_>>();
1136 let serial = fields
1137 .first()
1138 .ok_or_else(|| anyhow!("adb device row did not contain a serial: {line}"))?;
1139 let state = fields
1140 .get(1)
1141 .ok_or_else(|| anyhow!("adb device row did not contain a state: {line}"))?;
1142 let (state, property_start) = if *state == "no" && fields.get(2) == Some(&"permissions") {
1143 ("no permissions", 3)
1144 } else {
1145 (*state, 2)
1146 };
1147 let properties = fields[property_start..]
1148 .iter()
1149 .filter_map(|field| field.split_once(':'))
1150 .collect::<BTreeMap<_, _>>();
1151
1152 devices.push(AndroidDevice {
1153 serial: (*serial).to_string(),
1154 state: state.to_string(),
1155 product: properties.get("product").map(|value| (*value).to_string()),
1156 model: properties.get("model").map(|value| (*value).to_string()),
1157 device: properties.get("device").map(|value| (*value).to_string()),
1158 transport_id: properties
1159 .get("transport_id")
1160 .map(|value| (*value).to_string()),
1161 });
1162 }
1163 Ok(devices)
1164 }
1165
1166 fn validate_android_package_name(package_name: &str) -> Result<()> {
1167 if package_name.is_empty()
1168 || !package_name
1169 .chars()
1170 .all(|character| character.is_ascii_alphanumeric() || matches!(character, '.' | '_'))
1171 {
1172 return Err(anyhow!(
1173 "Android package name must contain only ASCII letters, digits, dots, and underscores"
1174 ));
1175 }
1176 Ok(())
1177 }
1178
1179 fn encode_adb_input_text(text: &str) -> Result<String> {
1180 if text.is_empty() || text.contains(['\0', '\n', '\r']) {
1181 return Err(anyhow!("text must be a non-empty single-line value"));
1182 }
1183 if text.contains("%s") {
1184 return Err(anyhow!(
1185 "text containing the literal sequence '%s' is not supported by adb input"
1186 ));
1187 }
1188 Ok(text.replace(' ', "%s"))
1189 }
1190
1191 fn shell_escape(value: &str) -> String {
1192 format!("'{}'", value.replace('\'', "'\\''"))
1193 }
1194
1195 /// The name of the Swift helper that reads the host machine.
1196 const HOST_INSPECT_HELPER: &str = "xcrs-host-inspect";
1197
1198 /// What one `xcrs-host-inspect` run wrote, whether or not it succeeded.
1199 #[derive(Debug, Clone, PartialEq, Eq)]
1200 pub struct HostInspectInvocation {
1201 /// The helper's exit code, or `None` when a signal killed it.
1202 pub exit_code: Option<i32>,
1203 pub stdout: String,
1204 pub stderr: String,
1205 }
1206
1207 impl HostInspectInvocation {
1208 pub fn succeeded(&self) -> bool {
1209 self.exit_code == Some(0)
1210 }
1211
1212 /// Whether the helper rejected the invocation itself rather than failing
1213 /// the work it was asked to do. The helper reserves exit code 2 for a
1214 /// malformed command and 1 for a failed operation.
1215 pub fn is_usage_error(&self) -> bool {
1216 self.exit_code == Some(2)
1217 }
1218
1219 /// The failure text, preferring stderr and falling back to stdout.
1220 pub fn message(&self) -> String {
1221 [self.stderr.trim(), self.stdout.trim()]
1222 .into_iter()
1223 .filter(|part| !part.is_empty())
1224 .collect::<Vec<_>>()
1225 .join("\n")
1226 }
1227 }
1228
1229 /// Converts between the units a caller reasons about and image pixels.
1230 ///
1231 /// A Retina screenshot is two pixels per point, so a scale of `2` accepts and
1232 /// reports points; the default of `1` accepts and reports pixels.
1233 #[derive(Debug, Clone, Copy, PartialEq)]
1234 pub struct HostInspectScale(f64);
1235
1236 impl HostInspectScale {
1237 /// Pixels, which is the helper's default.
1238 pub const PIXELS: Self = Self(1.0);
1239
1240 pub fn new(pixels_per_unit: f64) -> Result<Self> {
1241 if !pixels_per_unit.is_finite() || pixels_per_unit <= 0.0 {
1242 return Err(anyhow!(
1243 "scale must be a positive number, got {pixels_per_unit}"
1244 ));
1245 }
1246
1247 Ok(Self(pixels_per_unit))
1248 }
1249
1250 pub fn pixels_per_unit(&self) -> f64 {
1251 self.0
1252 }
1253
1254 pub fn pixels(&self, units: f64) -> f64 {
1255 units * self.0
1256 }
1257
1258 pub fn units(&self, pixels: f64) -> f64 {
1259 pixels / self.0
1260 }
1261 }
1262
1263 impl Default for HostInspectScale {
1264 fn default() -> Self {
1265 Self::PIXELS
1266 }
1267 }
1268
1269 impl std::fmt::Display for HostInspectScale {
1270 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1271 write!(formatter, "{}", self.0)
1272 }
1273 }
1274
1275 impl std::str::FromStr for HostInspectScale {
1276 type Err = anyhow::Error;
1277
1278 fn from_str(value: &str) -> Result<Self> {
1279 let pixels_per_unit = value
1280 .trim()
1281 .parse::<f64>()
1282 .with_context(|| format!("scale expects a number, got {value}"))?;
1283
1284 Self::new(pixels_per_unit)
1285 }
1286 }
1287
1288 /// A rectangle in caller units. Rows run top down and columns left to right,
1289 /// matching the rest of XCRS.
1290 #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1291 pub struct HostInspectRect {
1292 pub x: f64,
1293 pub y: f64,
1294 pub width: f64,
1295 pub height: f64,
1296 }
1297
1298 impl HostInspectRect {
1299 pub fn new(x: f64, y: f64, width: f64, height: f64) -> Self {
1300 Self {
1301 x,
1302 y,
1303 width,
1304 height,
1305 }
1306 }
1307
1308 pub fn validate(&self) -> Result<()> {
1309 if [self.x, self.y, self.width, self.height]
1310 .into_iter()
1311 .any(|value| !value.is_finite())
1312 {
1313 return Err(anyhow!("rect values must be finite numbers, got {self}"));
1314 }
1315 if self.width <= 0.0 || self.height <= 0.0 {
1316 return Err(anyhow!(
1317 "rect width and height must be positive, got {self}"
1318 ));
1319 }
1320
1321 Ok(())
1322 }
1323 }
1324
1325 impl std::fmt::Display for HostInspectRect {
1326 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1327 write!(
1328 formatter,
1329 "{},{},{},{}",
1330 self.x, self.y, self.width, self.height
1331 )
1332 }
1333 }
1334
1335 impl std::str::FromStr for HostInspectRect {
1336 type Err = anyhow::Error;
1337
1338 fn from_str(value: &str) -> Result<Self> {
1339 let parts = value.split(',').collect::<Vec<_>>();
1340 if parts.len() != 4 {
1341 return Err(anyhow!("rect expects x,y,w,h, got {value}"));
1342 }
1343
1344 let mut numbers = [0.0_f64; 4];
1345 for (index, part) in parts.into_iter().enumerate() {
1346 numbers[index] = part
1347 .trim()
1348 .parse::<f64>()
1349 .with_context(|| format!("rect values must be numbers, got {value}"))?;
1350 }
1351
1352 let rect = Self::new(numbers[0], numbers[1], numbers[2], numbers[3]);
1353 rect.validate()?;
1354
1355 Ok(rect)
1356 }
1357 }
1358
1359 /// The image a result was read from, in both pixels and caller units.
1360 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1361 #[serde(rename_all = "camelCase")]
1362 pub struct HostInspectImage {
1363 pub path: String,
1364 pub pixel_width: i64,
1365 pub pixel_height: i64,
1366 pub scale: f64,
1367 pub width: f64,
1368 pub height: f64,
1369 }
1370
1371 /// An on-screen window in the global display space.
1372 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1373 pub struct HostWindow {
1374 pub id: i64,
1375 pub owner: String,
1376 pub title: String,
1377 pub layer: i64,
1378 /// Screen points, unaffected by the requested scale.
1379 pub bounds: HostInspectRect,
1380 }
1381
1382 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1383 pub struct HostWindowList {
1384 pub windows: Vec<HostWindow>,
1385 }
1386
1387 /// One recognized text box, ordered top to bottom and then left to right.
1388 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1389 pub struct HostRecognizedText {
1390 pub text: String,
1391 pub confidence: f64,
1392 pub x: f64,
1393 pub y: f64,
1394 pub width: f64,
1395 pub height: f64,
1396 }
1397
1398 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1399 pub struct HostOcrResult {
1400 pub image: HostInspectImage,
1401 /// The languages the request was configured with; empty means the
1402 /// platform's own default.
1403 pub languages: Vec<String>,
1404 pub results: Vec<HostRecognizedText>,
1405 }
1406
1407 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1408 #[serde(rename_all = "camelCase")]
1409 pub struct HostCropResult {
1410 pub image: HostInspectImage,
1411 /// The requested rect, in caller units.
1412 pub rect: HostInspectRect,
1413 pub out: String,
1414 pub pixel_width: i64,
1415 pub pixel_height: i64,
1416 }
1417
1418 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1419 #[serde(rename_all = "lowercase")]
1420 pub enum HostProbeAxis {
1421 Row,
1422 Column,
1423 }
1424
1425 impl HostProbeAxis {
1426 /// The flag the helper reads this axis from.
1427 pub fn flag(&self) -> &'static str {
1428 match self {
1429 Self::Row => "--row",
1430 Self::Column => "--column",
1431 }
1432 }
1433 }
1434
1435 impl std::fmt::Display for HostProbeAxis {
1436 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1437 formatter.write_str(match self {
1438 Self::Row => "row",
1439 Self::Column => "column",
1440 })
1441 }
1442 }
1443
1444 /// A stretch of one color along a probed line, half open in caller units.
1445 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1446 pub struct HostColorRun {
1447 pub start: f64,
1448 pub end: f64,
1449 pub width: f64,
1450 /// Quantized `#rrggbb`, so low-order noise does not split a surface.
1451 pub color: String,
1452 }
1453
1454 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1455 pub struct HostProbeResult {
1456 pub image: HostInspectImage,
1457 pub axis: HostProbeAxis,
1458 pub position: f64,
1459 pub from: f64,
1460 pub to: f64,
1461 pub runs: Vec<HostColorRun>,
1462 }
1463
1464 #[derive(Debug, Clone, PartialEq)]
1465 pub struct HostOcrRequest {
1466 pub image: PathBuf,
1467 pub scale: HostInspectScale,
1468 /// Empty leaves the platform on its own default.
1469 pub languages: Vec<String>,
1470 }
1471
1472 impl HostOcrRequest {
1473 pub fn new(image: impl Into<PathBuf>) -> Self {
1474 Self {
1475 image: image.into(),
1476 scale: HostInspectScale::PIXELS,
1477 languages: Vec::new(),
1478 }
1479 }
1480 }
1481
1482 #[derive(Debug, Clone, PartialEq)]
1483 pub struct HostCropRequest {
1484 pub image: PathBuf,
1485 pub rect: HostInspectRect,
1486 pub out: PathBuf,
1487 pub scale: HostInspectScale,
1488 }
1489
1490 impl HostCropRequest {
1491 pub fn new(image: impl Into<PathBuf>, rect: HostInspectRect, out: impl Into<PathBuf>) -> Self {
1492 Self {
1493 image: image.into(),
1494 rect,
1495 out: out.into(),
1496 scale: HostInspectScale::PIXELS,
1497 }
1498 }
1499 }
1500
1501 #[derive(Debug, Clone, PartialEq)]
1502 pub struct HostProbeRequest {
1503 pub image: PathBuf,
1504 pub axis: HostProbeAxis,
1505 pub position: f64,
1506 pub from: Option<f64>,
1507 pub to: Option<f64>,
1508 pub scale: HostInspectScale,
1509 }
1510
1511 impl HostProbeRequest {
1512 pub fn new(image: impl Into<PathBuf>, axis: HostProbeAxis, position: f64) -> Self {
1513 Self {
1514 image: image.into(),
1515 axis,
1516 position,
1517 from: None,
1518 to: None,
1519 scale: HostInspectScale::PIXELS,
1520 }
1521 }
1522 }
1523
1524 /// Client for the `xcrs-host-inspect` helper, which reads the macOS host on
1525 /// behalf of XCRS control runs: on-screen windows, image pixels, and text.
1526 #[derive(Debug, Clone)]
1527 pub struct HostInspect {
1528 helper_path: PathBuf,
1529 }
1530
1531 #[cfg(unix)]
1532 fn is_text_file_busy(result: &std::io::Result<Output>) -> bool {
1533 matches!(result, Err(error) if error.raw_os_error() == Some(TEXT_FILE_BUSY))
1534 }
1535
1536 #[cfg(not(unix))]
1537 fn is_text_file_busy(_result: &std::io::Result<Output>) -> bool {
1538 false
1539 }
1540
1541 impl Default for HostInspect {
1542 fn default() -> Self {
1543 Self {
1544 helper_path: discover_host_inspect_path(),
1545 }
1546 }
1547 }
1548
1549 impl HostInspect {
1550 pub fn new() -> Self {
1551 Self::default()
1552 }
1553
1554 pub fn with_path(helper_path: impl Into<PathBuf>) -> Self {
1555 Self {
1556 helper_path: helper_path.into(),
1557 }
1558 }
1559
1560 pub fn helper_path(&self) -> &Path {
1561 &self.helper_path
1562 }
1563
1564 /// Run the helper and report what it wrote.
1565 ///
1566 /// A non-zero exit is part of the helper's contract rather than an error
1567 /// here, so a caller can forward the code instead of re-deriving it. This
1568 /// only fails when the helper could not be started at all.
1569 pub fn invoke(&self, arguments: &[String]) -> Result<HostInspectInvocation> {
1570 let output = self.run_helper(arguments).with_context(|| {
1571 format!(
1572 "failed to run {} (build it with `swift build` in xcrs-controlkit)",
1573 self.helper_path.display()
1574 )
1575 })?;
1576
1577 Ok(HostInspectInvocation {
1578 exit_code: output.status.code(),
1579 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
1580 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
1581 })
1582 }
1583
1584 /// Start the helper, waiting out a file that is briefly unstartable.
1585 ///
1586 /// Unix refuses to `exec` an executable that any process still holds open
1587 /// for writing, and reports that as `ETXTBSY` rather than as a missing or
1588 /// broken binary. A `swift build` replacing the helper does it, and so does
1589 /// a sibling thread forking while a test writes a stand-in; both last only
1590 /// until the other side closes its descriptor, so a short retry is the
1591 /// difference between a transient condition and a reported failure.
1592 fn run_helper(&self, arguments: &[String]) -> std::io::Result<Output> {
1593 let mut attempt = 0;
1594 loop {
1595 let started = Command::new(&self.helper_path).args(arguments).output();
1596 if !is_text_file_busy(&started) || attempt >= TEXT_FILE_BUSY_RETRY_ATTEMPTS {
1597 return started;
1598 }
1599
1600 attempt += 1;
1601 std::thread::sleep(TEXT_FILE_BUSY_RETRY_DELAY);
1602 }
1603 }
1604
1605 /// List the host's on-screen windows, ordered by layer and then id.
1606 pub fn windows(&self, owner: Option<&str>) -> Result<HostWindowList> {
1607 let mut arguments = vec!["windows".to_string()];
1608 if let Some(owner) = owner {
1609 arguments.push("--owner".to_string());
1610 arguments.push(owner.to_string());
1611 }
1612
1613 self.decode(&arguments)
1614 }
1615
1616 /// Read the text in an image.
1617 pub fn recognize_text(&self, request: &HostOcrRequest) -> Result<HostOcrResult> {
1618 let mut arguments = vec![
1619 "ocr".to_string(),
1620 "--image".to_string(),
1621 path_to_string(&request.image)?,
1622 "--scale".to_string(),
1623 request.scale.to_string(),
1624 ];
1625 for language in &request.languages {
1626 arguments.push("--language".to_string());
1627 arguments.push(language.clone());
1628 }
1629
1630 self.decode(&arguments)
1631 }
1632
1633 /// Crop an image to a rect and write the result out as a PNG.
1634 pub fn crop(&self, request: &HostCropRequest) -> Result<HostCropResult> {
1635 request.rect.validate()?;
1636 let arguments = vec![
1637 "crop".to_string(),
1638 "--image".to_string(),
1639 path_to_string(&request.image)?,
1640 "--rect".to_string(),
1641 request.rect.to_string(),
1642 "--out".to_string(),
1643 path_to_string(&request.out)?,
1644 "--scale".to_string(),
1645 request.scale.to_string(),
1646 ];
1647
1648 self.decode(&arguments)
1649 }
1650
1651 /// Read one line of an image as a run of colors.
1652 pub fn probe(&self, request: &HostProbeRequest) -> Result<HostProbeResult> {
1653 if !request.position.is_finite() {
1654 return Err(anyhow!(
1655 "probe position must be a finite number, got {}",
1656 request.position
1657 ));
1658 }
1659
1660 let mut arguments = vec![
1661 "probe".to_string(),
1662 "--image".to_string(),
1663 path_to_string(&request.image)?,
1664 request.axis.flag().to_string(),
1665 request.position.to_string(),
1666 "--scale".to_string(),
1667 request.scale.to_string(),
1668 ];
1669 for (flag, bound) in [("--from", request.from), ("--to", request.to)] {
1670 let Some(bound) = bound else { continue };
1671 if !bound.is_finite() {
1672 return Err(anyhow!("probe {flag} must be a finite number, got {bound}"));
1673 }
1674 arguments.push(flag.to_string());
1675 arguments.push(bound.to_string());
1676 }
1677
1678 self.decode(&arguments)
1679 }
1680
1681 fn decode<T>(&self, arguments: &[String]) -> Result<T>
1682 where
1683 T: serde::de::DeserializeOwned,
1684 {
1685 let command = arguments
1686 .first()
1687 .map(String::as_str)
1688 .unwrap_or("invocation");
1689 let invocation = self.invoke(arguments)?;
1690 if !invocation.succeeded() {
1691 let status = match invocation.exit_code {
1692 Some(code) => format!("exited with status {code}"),
1693 None => "was killed by a signal".to_string(),
1694 };
1695 let message = invocation.message();
1696
1697 return Err(if message.is_empty() {
1698 anyhow!("xcrs-host-inspect {command} {status}")
1699 } else {
1700 anyhow!("xcrs-host-inspect {command} {status}: {message}")
1701 });
1702 }
1703
1704 serde_json::from_str(&invocation.stdout)
1705 .with_context(|| format!("failed to decode xcrs-host-inspect {command} JSON"))
1706 }
1707 }
1708
1709 fn discover_host_inspect_path() -> PathBuf {
1710 let executable = if cfg!(windows) {
1711 format!("{HOST_INSPECT_HELPER}.exe")
1712 } else {
1713 HOST_INSPECT_HELPER.to_string()
1714 };
1715
1716 let mut candidates = Vec::new();
1717 if let Some(path) = env::var_os("XCRS_HOST_INSPECT") {
1718 candidates.push(PathBuf::from(path));
1719 }
1720 // A release ships the helper next to the `xcrs` binary; a SwiftPM checkout
1721 // leaves it in the build directory.
1722 if let Some(directory) = env::current_exe()
1723 .ok()
1724 .and_then(|path| path.parent().map(Path::to_path_buf))
1725 {
1726 candidates.push(directory.join(&executable));
1727 }
1728 candidates.push(Path::new(".build").join("release").join(&executable));
1729 candidates.push(Path::new(".build").join("debug").join(&executable));
1730 if let Some(home) = env::var_os("HOME").map(PathBuf::from) {
1731 candidates.push(home.join("bin").join(&executable));
1732 }
1733 candidates.push(Path::new("/usr/local/bin").join(&executable));
1734 candidates.push(Path::new("/opt/homebrew/bin").join(&executable));
1735
1736 candidates
1737 .into_iter()
1738 .find(|path| path.is_file())
1739 .unwrap_or_else(|| PathBuf::from(executable))
1740 }
1741
1742 #[derive(Debug, Clone)]
1743 pub enum XcodeProject {
1744 Project(PathBuf),
1745 Workspace(PathBuf),
1746 }
1747
1748 #[derive(Debug, Clone)]
1749 pub struct BuildIosApp {
1750 pub project: XcodeProject,
1751 pub scheme: String,
1752 pub simulator_udid: String,
1753 pub derived_data_path: PathBuf,
1754 pub configuration: Option<String>,
1755 }
1756
1757 pub struct Xcodebuild<'a> {
1758 tools: &'a XcodeCommandLineTools,
1759 }
1760
1761 impl Xcodebuild<'_> {
1762 pub fn build_ios_app(&self, request: &BuildIosApp) -> Result<()> {
1763 let mut args = Vec::new();
1764 match &request.project {
1765 XcodeProject::Project(project_path) => {
1766 ensure_path_exists(project_path)?;
1767 args.push("-project".to_string());
1768 args.push(path_to_string(project_path)?);
1769 }
1770 XcodeProject::Workspace(workspace_path) => {
1771 ensure_path_exists(workspace_path)?;
1772 args.push("-workspace".to_string());
1773 args.push(path_to_string(workspace_path)?);
1774 }
1775 }
1776
1777 args.push("-scheme".to_string());
1778 args.push(request.scheme.clone());
1779 args.push("-destination".to_string());
1780 args.push(format!(
1781 "{IOS_SIMULATOR_DESTINATION_PREFIX}{}",
1782 request.simulator_udid
1783 ));
1784 args.push("-derivedDataPath".to_string());
1785 args.push(path_to_string(&request.derived_data_path)?);
1786
1787 if let Some(configuration) = &request.configuration {
1788 args.push("-configuration".to_string());
1789 args.push(configuration.clone());
1790 }
1791
1792 args.push("build".to_string());
1793
1794 self.tools.run_xcodebuild(args)?;
1795 Ok(())
1796 }
1797 }
1798
1799 #[derive(Debug, Clone, Serialize)]
1800 pub struct IosAppTestResult {
1801 pub simulator: Simulator,
1802 pub bundle_id: String,
1803 pub app_container_path: PathBuf,
1804 }
1805
1806 #[derive(Debug, Clone)]
1807 pub struct IosAppTest {
1808 pub simulator_name: Option<String>,
1809 pub simulator_udid: Option<String>,
1810 pub app_path: PathBuf,
1811 pub bundle_id: String,
1812 pub terminate_before_launch: bool,
1813 }
1814
1815 impl XcodeCommandLineTools {
1816 pub fn run_ios_app_test(&self, request: &IosAppTest) -> Result<IosAppTestResult> {
1817 if request.simulator_name.is_none() && request.simulator_udid.is_none() {
1818 return Err(anyhow!(
1819 "either simulator_name or simulator_udid must be provided"
1820 ));
1821 }
1822
1823 let simctl = self.simctl();
1824 let simulator = match (&request.simulator_udid, &request.simulator_name) {
1825 (Some(udid), _) => simctl.find_simulator_by_udid(udid)?,
1826 (None, Some(name)) => simctl.find_simulator_by_name(name)?,
1827 (None, None) => unreachable!("validated above"),
1828 };
1829
1830 simctl.boot(&simulator.udid)?;
1831 simctl.install_app(&simulator.udid, &request.app_path)?;
1832
1833 if request.terminate_before_launch {
1834 simctl.terminate_app(&simulator.udid, &request.bundle_id)?;
1835 }
1836
1837 simctl.launch_app(&simulator.udid, &request.bundle_id)?;
1838 let app_container_path = simctl.app_container_path(&simulator.udid, &request.bundle_id)?;
1839
1840 Ok(IosAppTestResult {
1841 simulator: simctl.find_simulator_by_udid(&simulator.udid)?,
1842 bundle_id: request.bundle_id.clone(),
1843 app_container_path,
1844 })
1845 }
1846
1847 pub fn device_tunnel_address(&self, device_udid: &str) -> Result<String> {
1848 let output = self.run_xcrun([
1849 "devicectl",
1850 "device",
1851 "info",
1852 "details",
1853 "--device",
1854 device_udid,
1855 ])?;
1856
1857 parse_device_tunnel_address(&output)
1858 .ok_or_else(|| anyhow!("device {device_udid} did not report a tunnel IP address"))
1859 }
1860
1861 pub fn build_controlkit_device_runner(
1862 &self,
1863 request: &BuildControlKitDeviceRunner,
1864 ) -> Result<PathBuf> {
1865 ensure_path_exists(&request.project_path)?;
1866 let args = [
1867 "build-for-testing".to_string(),
1868 "-project".to_string(),
1869 path_to_string(&request.project_path)?,
1870 "-scheme".to_string(),
1871 request.scheme.clone(),
1872 "-configuration".to_string(),
1873 request.configuration.clone(),
1874 "-destination".to_string(),
1875 format!("id={}", request.device_udid),
1876 "-derivedDataPath".to_string(),
1877 path_to_string(&request.derived_data_path)?,
1878 ];
1879 self.run_xcodebuild(args)?;
1880
1881 let products_path = request.derived_data_path.join("Build/Products");
1882 let entries = std::fs::read_dir(&products_path)
1883 .with_context(|| format!("failed to read {}", products_path.display()))?;
1884 let mut test_run_paths = entries
1885 .filter_map(|entry| entry.ok().map(|entry| entry.path()))
1886 .filter(|path| {
1887 path.extension()
1888 .is_some_and(|extension| extension == "xctestrun")
1889 })
1890 .collect::<Vec<_>>();
1891 test_run_paths.sort();
1892
1893 test_run_paths.into_iter().next().ok_or_else(|| {
1894 anyhow!(
1895 "Xcode did not create an .xctestrun file in {}",
1896 products_path.display()
1897 )
1898 })
1899 }
1900
1901 pub fn start_controlkit_device_runner(
1902 &self,
1903 request: &StartControlKitDeviceRunner,
1904 ) -> Result<RunningControlKitDeviceRunner> {
1905 ensure_path_exists(&request.xctestrun_path)?;
1906 set_xctestrun_environment(
1907 &request.xctestrun_path,
1908 "CONTROLKIT_LISTEN_HOST",
1909 &request.listen_host,
1910 )?;
1911 set_xctestrun_environment(
1912 &request.xctestrun_path,
1913 "CONTROLKIT_LISTEN_PORT",
1914 &request.listen_port.to_string(),
1915 )?;
1916
1917 let tunnel_host = self.device_tunnel_address(&request.device_udid)?;
1918 let log_file = std::fs::OpenOptions::new()
1919 .create(true)
1920 .truncate(true)
1921 .write(true)
1922 .open(&request.log_path)
1923 .with_context(|| format!("failed to open {}", request.log_path.display()))?;
1924 let error_log_file = log_file
1925 .try_clone()
1926 .with_context(|| format!("failed to clone {}", request.log_path.display()))?;
1927
1928 let child = Command::new(&self.xcodebuild_path)
1929 .arg("test-without-building")
1930 .arg("-xctestrun")
1931 .arg(&request.xctestrun_path)
1932 .arg("-destination")
1933 .arg(format!("id={}", request.device_udid))
1934 .stdin(Stdio::null())
1935 .stdout(Stdio::from(log_file))
1936 .stderr(Stdio::from(error_log_file))
1937 .spawn()
1938 .with_context(|| {
1939 format!(
1940 "failed to start ControlKit runner for device {}",
1941 request.device_udid
1942 )
1943 })?;
1944
1945 Ok(RunningControlKitDeviceRunner {
1946 process: child,
1947 tunnel_host,
1948 listen_port: request.listen_port,
1949 log_path: request.log_path.clone(),
1950 })
1951 }
1952 }
1953
1954 #[derive(Debug, Clone)]
1955 pub struct BuildControlKitDeviceRunner {
1956 pub project_path: PathBuf,
1957 pub scheme: String,
1958 pub configuration: String,
1959 pub device_udid: String,
1960 pub derived_data_path: PathBuf,
1961 }
1962
1963 #[derive(Debug, Clone)]
1964 pub struct StartControlKitDeviceRunner {
1965 pub device_udid: String,
1966 pub xctestrun_path: PathBuf,
1967 pub listen_host: String,
1968 pub listen_port: u16,
1969 pub log_path: PathBuf,
1970 }
1971
1972 #[derive(Debug)]
1973 pub struct RunningControlKitDeviceRunner {
1974 pub process: Child,
1975 pub tunnel_host: String,
1976 pub listen_port: u16,
1977 pub log_path: PathBuf,
1978 }
1979
1980 fn set_xctestrun_environment(path: &Path, name: &str, value: &str) -> Result<()> {
1981 let key = format!(":TestConfigurations:0:TestTargets:0:EnvironmentVariables:{name}");
1982 let set_status = Command::new("/usr/libexec/PlistBuddy")
1983 .arg("-c")
1984 .arg(format!("Set {key} {value}"))
1985 .arg(path)
1986 .status()
1987 .with_context(|| format!("failed to update {}", path.display()))?;
1988
1989 if set_status.success() {
1990 return Ok(());
1991 }
1992
1993 let add_status = Command::new("/usr/libexec/PlistBuddy")
1994 .arg("-c")
1995 .arg(format!("Add {key} string {value}"))
1996 .arg(path)
1997 .status()
1998 .with_context(|| format!("failed to update {}", path.display()))?;
1999
2000 if add_status.success() {
2001 return Ok(());
2002 }
2003
2004 Err(anyhow!(
2005 "failed to set {name} in XCTest plan {}",
2006 path.display()
2007 ))
2008 }
2009
2010 fn parse_device_tunnel_address(output: &str) -> Option<String> {
2011 output
2012 .lines()
2013 .find_map(|line| line.split_once("Tunnel IP Address:"))
2014 .map(|(_, address)| address.trim().to_string())
2015 .filter(|address| !address.is_empty())
2016 }
2017
2018 pub fn parse_simctl_devices(output: &str) -> Result<Vec<Simulator>> {
2019 let list: SimctlDeviceList =
2020 serde_json::from_str(output).context("failed to parse simctl device list JSON")?;
2021
2022 let mut simulators = Vec::new();
2023 for (runtime_identifier, devices) in list.devices {
2024 for device in devices {
2025 simulators.push(Simulator {
2026 platform: ApplePlatform::from_runtime_identifier(&runtime_identifier),
2027 runtime_identifier: runtime_identifier.clone(),
2028 name: device.name,
2029 udid: device.udid,
2030 state: device.state,
2031 is_available: device.is_available.unwrap_or(true),
2032 });
2033 }
2034 }
2035
2036 Ok(simulators)
2037 }
2038
2039 fn ensure_path_exists(path: &Path) -> Result<()> {
2040 if !path.exists() {
2041 return Err(anyhow!("path does not exist: {}", path.display()));
2042 }
2043
2044 Ok(())
2045 }
2046
2047 fn path_to_string(path: &Path) -> Result<String> {
2048 path.to_str()
2049 .map(ToOwned::to_owned)
2050 .ok_or_else(|| anyhow!("path is not valid UTF-8: {}", path.display()))
2051 }
2052
2053 fn run_command<I, S>(program: &Path, args: I) -> Result<String>
2054 where
2055 I: IntoIterator<Item = S>,
2056 S: AsRef<OsStr>,
2057 {
2058 let output = Command::new(program)
2059 .args(args)
2060 .output()
2061 .with_context(|| format!("failed to run {}", program.display()))?;
2062
2063 command_output_to_result(program, output)
2064 }
2065
2066 fn command_output_to_result(program: &Path, output: Output) -> Result<String> {
2067 if output.status.success() {
2068 return String::from_utf8(output.stdout)
2069 .with_context(|| format!("{} wrote invalid UTF-8 to stdout", program.display()));
2070 }
2071
2072 let stderr = String::from_utf8_lossy(&output.stderr);
2073 let stdout = String::from_utf8_lossy(&output.stdout);
2074 let message = [stderr.trim(), stdout.trim()]
2075 .into_iter()
2076 .filter(|part| !part.is_empty())
2077 .collect::<Vec<_>>()
2078 .join("\n");
2079
2080 if message.is_empty() {
2081 return Err(anyhow!(
2082 "{} exited with status {}",
2083 program.display(),
2084 output.status
2085 ));
2086 }
2087
2088 Err(anyhow!(
2089 "{} exited with status {}: {}",
2090 program.display(),
2091 output.status,
2092 message
2093 ))
2094 }
2095
2096 fn command_output_to_bytes(program: &Path, output: Output) -> Result<Vec<u8>> {
2097 if output.status.success() {
2098 return Ok(output.stdout);
2099 }
2100
2101 let stderr = String::from_utf8_lossy(&output.stderr);
2102 let stdout = String::from_utf8_lossy(&output.stdout);
2103 let message = [stderr.trim(), stdout.trim()]
2104 .into_iter()
2105 .filter(|part| !part.is_empty())
2106 .collect::<Vec<_>>()
2107 .join("\n");
2108
2109 if message.is_empty() {
2110 return Err(anyhow!(
2111 "{} exited with status {}",
2112 program.display(),
2113 output.status
2114 ));
2115 }
2116
2117 Err(anyhow!(
2118 "{} exited with status {}: {}",
2119 program.display(),
2120 output.status,
2121 message
2122 ))
2123 }
2124
2125 fn parse_resolved_android_activity(output: &str) -> Option<String> {
2126 output
2127 .lines()
2128 .map(str::trim)
2129 .rev()
2130 .find(|line| !line.is_empty() && line.contains('/') && !line.contains(char::is_whitespace))
2131 .map(str::to_string)
2132 }
2133
2134 #[cfg(test)]
2135 mod tests {
2136 use super::*;
2137
2138 #[test]
2139 fn parses_simctl_devices() {
2140 let output = r#"{
2141 "devices": {
2142 "com.apple.CoreSimulator.SimRuntime.iOS-26-0": [
2143 {
2144 "lastBootedAt": "2026-07-30T08:00:00Z",
2145 "dataPath": "/tmp/device",
2146 "dataPathSize": 1,
2147 "logPath": "/tmp/logs",
2148 "udid": "00000000-0000-0000-0000-000000000000",
2149 "isAvailable": true,
2150 "deviceTypeIdentifier": "com.apple.CoreSimulator.SimDeviceType.iPhone-16",
2151 "state": "Booted",
2152 "name": "Test-iOS-26"
2153 }
2154 ]
2155 }
2156 }"#;
2157
2158 let devices = parse_simctl_devices(output).expect("devices should parse");
2159
2160 assert_eq!(devices.len(), 1);
2161 assert_eq!(devices[0].platform, ApplePlatform::Ios);
2162 assert_eq!(devices[0].name, "Test-iOS-26");
2163 assert_eq!(devices[0].state, "Booted");
2164 assert!(devices[0].is_booted());
2165 }
2166
2167 #[test]
2168 fn parses_xros_simulator_runtime() {
2169 let output = r#"{
2170 "devices": {
2171 "com.apple.CoreSimulator.SimRuntime.xrOS-26-5": [
2172 {
2173 "udid": "00000000-0000-0000-0000-000000000001",
2174 "isAvailable": true,
2175 "state": "Shutdown",
2176 "name": "Apple Vision Pro"
2177 }
2178 ]
2179 }
2180 }"#;
2181
2182 let devices = parse_simctl_devices(output).expect("devices should parse");
2183
2184 assert_eq!(devices.len(), 1);
2185 assert_eq!(devices[0].platform, ApplePlatform::Visionos);
2186 assert_eq!(devices[0].name, "Apple Vision Pro");
2187 }
2188
2189 #[test]
2190 fn parses_macos_runtime() {
2191 let output = r#"{
2192 "devices": {
2193 "com.apple.CoreSimulator.SimRuntime.macOS-26-0": [
2194 {
2195 "udid": "00000000-0000-0000-0000-000000000002",
2196 "isAvailable": true,
2197 "state": "Booted",
2198 "name": "Mac"
2199 }
2200 ]
2201 }
2202 }"#;
2203
2204 let devices = parse_simctl_devices(output).expect("devices should parse");
2205
2206 assert_eq!(devices.len(), 1);
2207 assert_eq!(devices[0].platform, ApplePlatform::Macos);
2208 }
2209
2210 #[test]
2211 fn formats_ipv6_controlkit_url() {
2212 let controlkit = ControlKit::with_host("fdb4:e020:7377::1", 12006);
2213 assert_eq!(controlkit.base_url, "http://[fdb4:e020:7377::1]:12006");
2214 }
2215
2216 #[test]
2217 fn reports_actionable_controlkit_method_mismatch() {
2218 let error = serde_json::json!({
2219 "code": -32601,
2220 "message": "Method not found"
2221 });
2222 let runner_info = serde_json::json!({
2223 "runner": "XCRSControlKit",
2224 "protocolVersion": 1
2225 });
2226
2227 let message = controlkit_rpc_error("device.dump.ui", &error, Some(&runner_info), None);
2228
2229 assert!(message.contains("XCRSControlKit protocol 1"));
2230 assert!(message.contains("device.dump.ui"));
2231 assert!(message.contains("Rebuild or upgrade"));
2232 }
2233
2234 #[test]
2235 fn recognizes_integral_float_controlkit_method_not_found_code() {
2236 let error = serde_json::json!({
2237 "code": -32601.0,
2238 "message": "Method not found"
2239 });
2240
2241 assert!(is_controlkit_method_not_found(&error));
2242 let message = controlkit_rpc_error("device.dump.ui", &error, None, None);
2243 assert!(message.contains("does not implement ControlKit method 'device.dump.ui'"));
2244 }
2245
2246 #[test]
2247 fn reports_when_controlkit_runner_metadata_is_unavailable() {
2248 let error = serde_json::json!({
2249 "code": -32601,
2250 "message": "Method not found"
2251 });
2252
2253 let message = controlkit_rpc_error(
2254 "device.dump.ui",
2255 &error,
2256 None,
2257 Some("device.info returned a JSON-RPC error"),
2258 );
2259
2260 assert!(message.contains("The connected ControlKit runner"));
2261 assert!(message.contains("Runner metadata could not be read"));
2262 assert!(message.contains("device.info returned a JSON-RPC error"));
2263 assert!(!message.contains("unknown ControlKit runner"));
2264 }
2265
2266 #[test]
2267 fn extracts_identified_visible_controlkit_elements() {
2268 let hierarchy = serde_json::json!({
2269 "type": "Application",
2270 "rect": { "x": 0, "y": 0, "width": 1920, "height": 1080 },
2271 "children": [
2272 {
2273 "type": "Button",
2274 "label": "Play",
2275 "rawIdentifier": "play-button",
2276 "rect": { "x": 100, "y": 200, "width": 80, "height": 40 },
2277 "enabled": true,
2278 "selected": false,
2279 "hittable": true,
2280 "children": []
2281 },
2282 {
2283 "type": "Image",
2284 "label": "",
2285 "rect": { "x": 0, "y": 0, "width": 40, "height": 40 },
2286 "children": []
2287 }
2288 ]
2289 });
2290
2291 let elements = extract_controlkit_elements(&hierarchy);
2292
2293 assert_eq!(elements.len(), 1);
2294 assert_eq!(elements[0]["label"], serde_json::json!("Play"));
2295 assert_eq!(elements[0]["hittable"], serde_json::json!(true));
2296 }
2297
2298 #[test]
2299 fn normalizes_visible_androidkit_elements() {
2300 let hierarchy = serde_json::json!({
2301 "hierarchy": [{
2302 "role": "android.widget.Button",
2303 "text": "Continue",
2304 "hint": "",
2305 "contentDescription": "",
2306 "resourceId": "com.example:id/continue",
2307 "enabled": true,
2308 "clickable": true,
2309 "selected": false,
2310 "visible": true,
2311 "rect": { "x": 10, "y": 20, "width": 100, "height": 40 }
2312 }]
2313 });
2314
2315 let elements = extract_androidkit_elements(&hierarchy);
2316
2317 assert_eq!(elements.len(), 1);
2318 assert_eq!(elements[0]["label"], serde_json::json!("Continue"));
2319 assert_eq!(
2320 elements[0]["rawIdentifier"],
2321 serde_json::json!("com.example:id/continue")
2322 );
2323 }
2324
2325 #[test]
2326 fn parses_device_tunnel_address() {
2327 let output = "• Device Name: iPhone\n• Tunnel IP Address: fd55:33ce:ad87::1\n";
2328 assert_eq!(
2329 parse_device_tunnel_address(output).as_deref(),
2330 Some("fd55:33ce:ad87::1")
2331 );
2332 }
2333
2334 #[test]
2335 fn parses_adb_devices() {
2336 let output = r#"List of devices attached
2337 emulator-5554 device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:1
2338 SERIAL123 unauthorized usb:0-1 transport_id:2
2339 SERIAL456 no permissions (user denied) usb:0-2 transport_id:3
2340 "#;
2341
2342 let devices = parse_adb_devices(output).expect("devices should parse");
2343
2344 assert_eq!(devices.len(), 3);
2345 assert_eq!(devices[0].serial, "emulator-5554");
2346 assert_eq!(devices[0].state, "device");
2347 assert_eq!(devices[0].model.as_deref(), Some("sdk_gphone64_arm64"));
2348 assert_eq!(devices[1].serial, "SERIAL123");
2349 assert_eq!(devices[1].state, "unauthorized");
2350 assert_eq!(devices[2].serial, "SERIAL456");
2351 assert_eq!(devices[2].state, "no permissions");
2352 }
2353
2354 #[test]
2355 fn encodes_adb_input_spaces() {
2356 let text = encode_adb_input_text("hello android").expect("text should encode");
2357 assert_eq!(text, "hello%sandroid");
2358 }
2359
2360 #[test]
2361 fn rejects_literal_adb_space_escape() {
2362 let error = encode_adb_input_text("literal%svalue").expect_err("text should be rejected");
2363 assert!(error.to_string().contains("literal sequence"));
2364 }
2365
2366 #[test]
2367 fn parses_resolved_android_activity() {
2368 let output = "priority=0 preferredOrder=0 match=0x108000\n\
2369 ai.splitfire.KaroKowe/.MainActivity\n";
2370
2371 assert_eq!(
2372 parse_resolved_android_activity(output).as_deref(),
2373 Some("ai.splitfire.KaroKowe/.MainActivity")
2374 );
2375 }
2376
2377 #[test]
2378 fn escapes_android_shell_arguments() {
2379 assert_eq!(shell_escape("don't"), "'don'\\''t'");
2380 }
2381
2382 #[test]
2383 fn normalizes_apple_simulator_as_simulator_kind() {
2384 let simulator = Simulator {
2385 platform: ApplePlatform::Ios,
2386 runtime_identifier: "com.apple.CoreSimulator.SimRuntime.iOS-26-0".to_string(),
2387 name: "Test-iOS-26".to_string(),
2388 udid: "00000000-0000-0000-0000-000000000000".to_string(),
2389 state: "Booted".to_string(),
2390 is_available: true,
2391 };
2392
2393 let normalized = NormalizedDevice::from_simulator(&simulator);
2394
2395 assert_eq!(normalized.platform, DevicePlatform::Apple);
2396 assert_eq!(normalized.kind, DeviceKind::Simulator);
2397 assert_eq!(normalized.identifier, simulator.udid);
2398 assert_eq!(normalized.name, "Test-iOS-26");
2399 assert_eq!(normalized.state, "Booted");
2400 assert_eq!(normalized.transport, "simctl");
2401 }
2402
2403 #[test]
2404 fn normalizes_android_emulator_serial_as_emulator_kind() {
2405 let device = AndroidDevice {
2406 serial: "emulator-5554".to_string(),
2407 state: "device".to_string(),
2408 product: Some("sdk_gphone64_arm64".to_string()),
2409 model: Some("sdk_gphone64_arm64".to_string()),
2410 device: Some("emu64a".to_string()),
2411 transport_id: Some("1".to_string()),
2412 };
2413
2414 let normalized = NormalizedDevice::from_android_device(&device);
2415
2416 assert_eq!(normalized.platform, DevicePlatform::Android);
2417 assert_eq!(normalized.kind, DeviceKind::Emulator);
2418 assert_eq!(normalized.identifier, "emulator-5554");
2419 assert_eq!(normalized.name, "sdk_gphone64_arm64");
2420 assert_eq!(normalized.transport, "adb");
2421 }
2422
2423 #[test]
2424 fn normalizes_android_physical_serial_as_physical_device_kind() {
2425 let device = AndroidDevice {
2426 serial: "R58N123ABCD".to_string(),
2427 state: "device".to_string(),
2428 product: None,
2429 model: None,
2430 device: None,
2431 transport_id: None,
2432 };
2433
2434 let normalized = NormalizedDevice::from_android_device(&device);
2435
2436 assert_eq!(normalized.kind, DeviceKind::PhysicalDevice);
2437 // Falls back to the serial when no product/model/device property is reported.
2438 assert_eq!(normalized.name, "R58N123ABCD");
2439 }
2440
2441 #[test]
2442 fn defaults_host_inspect_scale_to_pixels() {
2443 let scale = HostInspectScale::default();
2444
2445 assert_eq!(scale, HostInspectScale::PIXELS);
2446 assert_eq!(scale.pixels_per_unit(), 1.0);
2447 assert_eq!(scale.to_string(), "1");
2448 }
2449
2450 #[test]
2451 fn converts_between_units_and_pixels() {
2452 let retina = HostInspectScale::new(2.0).expect("2 is a valid scale");
2453
2454 assert_eq!(retina.pixels(720.0), 1440.0);
2455 assert_eq!(retina.units(1440.0), 720.0);
2456 assert_eq!(retina.to_string(), "2");
2457 }
2458
2459 #[test]
2460 fn parses_host_inspect_scale() {
2461 let scale = "2.5".parse::<HostInspectScale>().expect("2.5 should parse");
2462
2463 assert_eq!(scale.pixels_per_unit(), 2.5);
2464 assert!("0".parse::<HostInspectScale>().is_err());
2465 assert!("-1".parse::<HostInspectScale>().is_err());
2466 assert!("nan".parse::<HostInspectScale>().is_err());
2467 assert!("two".parse::<HostInspectScale>().is_err());
2468 }
2469
2470 #[test]
2471 fn parses_and_formats_host_inspect_rect() {
2472 let rect = "10, 20, 30, 40"
2473 .parse::<HostInspectRect>()
2474 .expect("a four number rect should parse");
2475
2476 assert_eq!(rect, HostInspectRect::new(10.0, 20.0, 30.0, 40.0));
2477 assert_eq!(rect.to_string(), "10,20,30,40");
2478 assert_eq!(
2479 rect.to_string()
2480 .parse::<HostInspectRect>()
2481 .expect("round trip"),
2482 rect
2483 );
2484 }
2485
2486 #[test]
2487 fn rejects_a_rect_that_cannot_be_used() {
2488 assert!("0,0,4".parse::<HostInspectRect>().is_err());
2489 assert!("0,0,4,4,4".parse::<HostInspectRect>().is_err());
2490 assert!("a,b,c,d".parse::<HostInspectRect>().is_err());
2491 assert!("0,0,0,4".parse::<HostInspectRect>().is_err());
2492 assert!("0,0,4,-1".parse::<HostInspectRect>().is_err());
2493 assert!("0,0,inf,4".parse::<HostInspectRect>().is_err());
2494 }
2495
2496 #[test]
2497 fn discovers_the_host_inspect_helper_from_the_environment() {
2498 let directory = tempfile::tempdir().expect("a temporary directory");
2499 let helper = directory.path().join("inspect-from-environment");
2500 std::fs::write(&helper, "#!/bin/sh\n").expect("the helper should write");
2501
2502 let previous = env::var_os("XCRS_HOST_INSPECT");
2503 env::set_var("XCRS_HOST_INSPECT", &helper);
2504 let discovered = discover_host_inspect_path();
2505 match previous {
2506 Some(value) => env::set_var("XCRS_HOST_INSPECT", value),
2507 None => env::remove_var("XCRS_HOST_INSPECT"),
2508 }
2509
2510 // The environment wins over every other candidate, exactly as it does
2511 // for the Android debug bridge.
2512 assert_eq!(discovered, helper);
2513 }
2514
2515 /// Writes a stand-in for the Swift helper so the client can be exercised
2516 /// without a built `xcrs-host-inspect` or a macOS host.
2517 #[cfg(unix)]
2518 fn write_fake_helper(directory: &Path, script: &str) -> PathBuf {
2519 use std::io::Write;
2520 use std::os::unix::fs::OpenOptionsExt;
2521
2522 let path = directory.join(HOST_INSPECT_HELPER);
2523 // One handle writes the script and carries the executable bit, so the
2524 // file is never re-opened and its descriptor is closed before the test
2525 // starts it.
2526 let mut helper = std::fs::OpenOptions::new()
2527 .write(true)
2528 .create(true)
2529 .truncate(true)
2530 .mode(0o755)
2531 .open(&path)
2532 .expect("the fake helper should open");
2533 helper
2534 .write_all(script.as_bytes())
2535 .expect("the fake helper should write");
2536 drop(helper);
2537
2538 path
2539 }
2540
2541 /// A fake helper that records the arguments it was handed and prints
2542 /// `stdout` before exiting with `exit_code`.
2543 #[cfg(unix)]
2544 fn recording_helper(directory: &Path, stdout: &str, exit_code: i32) -> (PathBuf, PathBuf) {
2545 let recorded = directory.join("arguments.txt");
2546 let script = format!(
2547 "#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\ncat <<'EOF'\n{}\nEOF\nexit {}\n",
2548 recorded.display(),
2549 stdout,
2550 exit_code
2551 );
2552
2553 (write_fake_helper(directory, &script), recorded)
2554 }
2555
2556 #[cfg(unix)]
2557 fn recorded_arguments(recorded: &Path) -> Vec<String> {
2558 std::fs::read_to_string(recorded)
2559 .expect("the helper should have recorded its arguments")
2560 .lines()
2561 .map(str::to_string)
2562 .collect()
2563 }
2564
2565 #[cfg(unix)]
2566 const WINDOW_LIST_JSON: &str = r#"{
2567 "windows": [
2568 {
2569 "id": 42,
2570 "owner": "Safari",
2571 "title": "Example",
2572 "layer": 0,
2573 "bounds": { "x": 0, "y": 25, "width": 1440, "height": 875 }
2574 }
2575 ]
2576 }"#;
2577
2578 #[cfg(unix)]
2579 const IMAGE_JSON: &str = r#"{
2580 "path": "/tmp/screenshot.png",
2581 "pixelWidth": 2880,
2582 "pixelHeight": 1800,
2583 "scale": 2,
2584 "width": 1440,
2585 "height": 900
2586 }"#;
2587
2588 #[cfg(unix)]
2589 #[test]
2590 fn decodes_a_host_window_list() {
2591 let directory = tempfile::tempdir().expect("a temporary directory");
2592 let (helper, _) = recording_helper(directory.path(), WINDOW_LIST_JSON, 0);
2593 let host = HostInspect::with_path(helper);
2594
2595 let list = host.windows(None).expect("the window list should decode");
2596
2597 assert_eq!(list.windows.len(), 1);
2598 let window = &list.windows[0];
2599 assert_eq!(window.id, 42);
2600 assert_eq!(window.owner, "Safari");
2601 assert_eq!(window.title, "Example");
2602 assert_eq!(window.layer, 0);
2603 // Bounds stay in screen points, so they are not scaled.
2604 assert_eq!(
2605 window.bounds,
2606 HostInspectRect::new(0.0, 25.0, 1440.0, 875.0)
2607 );
2608 }
2609
2610 #[cfg(unix)]
2611 #[test]
2612 fn waits_out_a_helper_that_is_still_open_for_writing() {
2613 let directory = tempfile::tempdir().expect("a temporary directory");
2614 let (helper, recorded) = recording_helper(directory.path(), WINDOW_LIST_JSON, 0);
2615 let host = HostInspect::with_path(&helper);
2616
2617 // Unix refuses to start a file that is open for writing anywhere, and
2618 // that is what a sibling thread forking mid-write produces. Holding the
2619 // descriptor here reproduces it on demand.
2620 let held = std::fs::OpenOptions::new()
2621 .write(true)
2622 .open(&helper)
2623 .expect("the helper should reopen for writing");
2624 let release = std::thread::spawn(move || {
2625 std::thread::sleep(Duration::from_millis(50));
2626 drop(held);
2627 });
2628
2629 host.windows(None)
2630 .expect("windows should wait out the open handle");
2631 release.join().expect("the releasing thread should finish");
2632
2633 assert_eq!(recorded_arguments(&recorded), vec!["windows"]);
2634 }
2635
2636 #[cfg(unix)]
2637 #[test]
2638 fn builds_the_windows_arguments() {
2639 let directory = tempfile::tempdir().expect("a temporary directory");
2640 let (helper, recorded) = recording_helper(directory.path(), WINDOW_LIST_JSON, 0);
2641 let host = HostInspect::with_path(helper);
2642
2643 host.windows(None).expect("windows should succeed");
2644 assert_eq!(recorded_arguments(&recorded), vec!["windows"]);
2645
2646 host.windows(Some("Safari"))
2647 .expect("a filtered windows call should succeed");
2648 assert_eq!(
2649 recorded_arguments(&recorded),
2650 vec!["windows", "--owner", "Safari"]
2651 );
2652 }
2653
2654 #[cfg(unix)]
2655 #[test]
2656 fn builds_the_ocr_arguments_and_decodes_the_result() {
2657 let directory = tempfile::tempdir().expect("a temporary directory");
2658 let stdout = format!(
2659 r#"{{
2660 "image": {},
2661 "languages": ["en-US", "sv-SE"],
2662 "results": [
2663 {{
2664 "text": "Sign in",
2665 "confidence": 0.9,
2666 "x": 10,
2667 "y": 20,
2668 "width": 100,
2669 "height": 30
2670 }}
2671 ]
2672 }}"#,
2673 IMAGE_JSON
2674 );
2675 let (helper, recorded) = recording_helper(directory.path(), &stdout, 0);
2676 let host = HostInspect::with_path(helper);
2677
2678 let mut request = HostOcrRequest::new("/tmp/screenshot.png");
2679 request.scale = HostInspectScale::new(2.0).expect("2 is a valid scale");
2680 request.languages = vec!["en-US".to_string(), "sv-SE".to_string()];
2681
2682 let result = host
2683 .recognize_text(&request)
2684 .expect("the OCR result should decode");
2685
2686 assert_eq!(
2687 recorded_arguments(&recorded),
2688 vec![
2689 "ocr",
2690 "--image",
2691 "/tmp/screenshot.png",
2692 "--scale",
2693 "2",
2694 "--language",
2695 "en-US",
2696 "--language",
2697 "sv-SE",
2698 ]
2699 );
2700 // Image geometry is reported in caller units beside the pixel size.
2701 assert_eq!(result.image.pixel_width, 2880);
2702 assert_eq!(result.image.width, 1440.0);
2703 assert_eq!(result.image.scale, 2.0);
2704 assert_eq!(result.languages, vec!["en-US", "sv-SE"]);
2705 assert_eq!(result.results.len(), 1);
2706 assert_eq!(result.results[0].text, "Sign in");
2707 assert_eq!(result.results[0].confidence, 0.9);
2708 }
2709
2710 #[cfg(unix)]
2711 #[test]
2712 fn builds_the_crop_arguments_and_decodes_the_result() {
2713 let directory = tempfile::tempdir().expect("a temporary directory");
2714 let stdout = format!(
2715 r#"{{
2716 "image": {},
2717 "rect": {{ "x": 0, "y": 0, "width": 4, "height": 4 }},
2718 "out": "/tmp/crop.png",
2719 "pixelWidth": 8,
2720 "pixelHeight": 8
2721 }}"#,
2722 IMAGE_JSON
2723 );
2724 let (helper, recorded) = recording_helper(directory.path(), &stdout, 0);
2725 let host = HostInspect::with_path(helper);
2726 let request = HostCropRequest::new(
2727 "/tmp/screenshot.png",
2728 HostInspectRect::new(0.0, 0.0, 4.0, 4.0),
2729 "/tmp/crop.png",
2730 );
2731
2732 let result = host.crop(&request).expect("the crop result should decode");
2733
2734 assert_eq!(
2735 recorded_arguments(&recorded),
2736 vec![
2737 "crop",
2738 "--image",
2739 "/tmp/screenshot.png",
2740 "--rect",
2741 "0,0,4,4",
2742 "--out",
2743 "/tmp/crop.png",
2744 "--scale",
2745 "1",
2746 ]
2747 );
2748 assert_eq!(result.rect, HostInspectRect::new(0.0, 0.0, 4.0, 4.0));
2749 assert_eq!(result.out, "/tmp/crop.png");
2750 assert_eq!(result.pixel_width, 8);
2751 assert_eq!(result.pixel_height, 8);
2752 }
2753
2754 #[cfg(unix)]
2755 #[test]
2756 fn builds_the_probe_arguments_and_decodes_the_result() {
2757 let directory = tempfile::tempdir().expect("a temporary directory");
2758 let stdout = format!(
2759 r##"{{
2760 "image": {},
2761 "axis": "column",
2762 "position": 1,
2763 "from": 4,
2764 "to": 8,
2765 "runs": [
2766 {{ "start": 4, "end": 8, "width": 4, "color": "#0000f8" }}
2767 ]
2768 }}"##,
2769 IMAGE_JSON
2770 );
2771 let (helper, recorded) = recording_helper(directory.path(), &stdout, 0);
2772 let host = HostInspect::with_path(helper);
2773
2774 let mut request = HostProbeRequest::new("/tmp/screenshot.png", HostProbeAxis::Column, 1.0);
2775 request.from = Some(4.0);
2776 request.to = Some(8.0);
2777
2778 let result = host
2779 .probe(&request)
2780 .expect("the probe result should decode");
2781
2782 assert_eq!(
2783 recorded_arguments(&recorded),
2784 vec![
2785 "probe",
2786 "--image",
2787 "/tmp/screenshot.png",
2788 "--column",
2789 "1",
2790 "--scale",
2791 "1",
2792 "--from",
2793 "4",
2794 "--to",
2795 "8",
2796 ]
2797 );
2798 assert_eq!(result.axis, HostProbeAxis::Column);
2799 assert_eq!(result.position, 1.0);
2800 assert_eq!(result.from, 4.0);
2801 assert_eq!(result.to, 8.0);
2802 assert_eq!(result.runs.len(), 1);
2803 assert_eq!(result.runs[0].color, "#0000f8");
2804 assert_eq!(result.runs[0].start, 4.0);
2805 assert_eq!(result.runs[0].end, 8.0);
2806 }
2807
2808 #[cfg(unix)]
2809 #[test]
2810 fn omits_the_unset_probe_bounds() {
2811 let directory = tempfile::tempdir().expect("a temporary directory");
2812 let stdout = format!(
2813 r#"{{ "image": {}, "axis": "row", "position": 1, "from": 0, "to": 8, "runs": [] }}"#,
2814 IMAGE_JSON
2815 );
2816 let (helper, recorded) = recording_helper(directory.path(), &stdout, 0);
2817 let host = HostInspect::with_path(helper);
2818 let request = HostProbeRequest::new("/tmp/screenshot.png", HostProbeAxis::Row, 1.0);
2819
2820 host.probe(&request).expect("the probe should succeed");
2821
2822 assert_eq!(
2823 recorded_arguments(&recorded),
2824 vec![
2825 "probe",
2826 "--image",
2827 "/tmp/screenshot.png",
2828 "--row",
2829 "1",
2830 "--scale",
2831 "1",
2832 ]
2833 );
2834 }
2835
2836 #[cfg(unix)]
2837 #[test]
2838 fn keeps_the_host_inspect_exit_code_as_data() {
2839 let directory = tempfile::tempdir().expect("a temporary directory");
2840 let (helper, _) = recording_helper(directory.path(), "", 2);
2841 let host = HostInspect::with_path(helper);
2842
2843 let invocation = host
2844 .invoke(&["windows".to_string()])
2845 .expect("a usage error is still a completed run");
2846
2847 assert_eq!(invocation.exit_code, Some(2));
2848 assert!(invocation.is_usage_error());
2849 assert!(!invocation.succeeded());
2850 }
2851
2852 #[cfg(unix)]
2853 #[test]
2854 fn reports_a_failed_helper_with_its_message() {
2855 let directory = tempfile::tempdir().expect("a temporary directory");
2856 let script =
2857 "#!/bin/sh\necho 'cannot open an image /nonexistent/missing.png' >&2\nexit 1\n";
2858 let helper = write_fake_helper(directory.path(), script);
2859 let host = HostInspect::with_path(helper);
2860
2861 let error = host
2862 .windows(None)
2863 .expect_err("a failed helper should not decode");
2864
2865 let message = error.to_string();
2866 assert!(message.contains("xcrs-host-inspect windows exited with status 1"));
2867 assert!(message.contains("cannot open an image /nonexistent/missing.png"));
2868 }
2869
2870 #[cfg(unix)]
2871 #[test]
2872 fn reports_a_silently_failed_helper_without_a_dangling_message() {
2873 let directory = tempfile::tempdir().expect("a temporary directory");
2874 let (helper, _) = recording_helper(directory.path(), "", 1);
2875 let host = HostInspect::with_path(helper);
2876
2877 let error = host
2878 .windows(None)
2879 .expect_err("a failed helper should not decode");
2880
2881 assert_eq!(
2882 error.to_string(),
2883 "xcrs-host-inspect windows exited with status 1"
2884 );
2885 }
2886
2887 #[cfg(unix)]
2888 #[test]
2889 fn rejects_output_that_is_not_json() {
2890 let directory = tempfile::tempdir().expect("a temporary directory");
2891 let (helper, _) = recording_helper(directory.path(), "not json", 0);
2892 let host = HostInspect::with_path(helper);
2893
2894 let error = host
2895 .windows(None)
2896 .expect_err("malformed output should not decode");
2897
2898 assert!(error
2899 .to_string()
2900 .contains("failed to decode xcrs-host-inspect windows JSON"));
2901 }
2902
2903 #[test]
2904 fn reports_a_missing_host_inspect_helper() {
2905 let host = HostInspect::with_path("/nonexistent/xcrs-host-inspect");
2906
2907 let error = host
2908 .windows(None)
2909 .expect_err("a missing helper should not run");
2910
2911 let message = error.to_string();
2912 assert!(message.contains("failed to run /nonexistent/xcrs-host-inspect"));
2913 assert!(message.contains("swift build"));
2914 }
2915
2916 #[test]
2917 fn validates_before_running_the_helper() {
2918 // Point at a helper that cannot exist, so any error other than the
2919 // validation one would prove the request was sent anyway.
2920 let host = HostInspect::with_path("/nonexistent/xcrs-host-inspect");
2921
2922 let error = host
2923 .crop(&HostCropRequest::new(
2924 "/tmp/screenshot.png",
2925 HostInspectRect::new(0.0, 0.0, 0.0, 4.0),
2926 "/tmp/crop.png",
2927 ))
2928 .expect_err("a zero width rect should be rejected");
2929 assert!(error.to_string().contains("must be positive"));
2930
2931 let error = host
2932 .probe(&HostProbeRequest::new(
2933 "/tmp/screenshot.png",
2934 HostProbeAxis::Row,
2935 f64::NAN,
2936 ))
2937 .expect_err("a non-finite position should be rejected");
2938 assert!(error.to_string().contains("must be a finite number"));
2939 }
2940 }