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