1use crate::process::{is_alive, kill_tree, Tracker};
10use crate::util::{err, new_id, sleep_ms, Error, Result};
11use crate::workspace::QaWorkspace;
12use rightkit_process::OwnedChild;
13#[cfg(not(target_os = "macos"))]
14use rightkit_process::OwnedCommand;
15use rightkit_service::{Client, ServiceError, Suite};
16use serde_json::{json, Value};
17use std::fs;
18use std::path::{Path, PathBuf};
19use std::process::Command;
20#[cfg(not(target_os = "macos"))]
21use std::process::Stdio;
22use std::sync::{Arc, Mutex};
23use std::time::{Duration, Instant};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Mode {
27 Hidden,
29 Background,
31 Visible,
32}
33
34impl Mode {
35 pub fn parse(s: &str) -> Result<Self> {
36 match s {
37 "hidden" => Ok(Mode::Hidden),
38 "background" => Ok(Mode::Background),
39 "visible" => Ok(Mode::Visible),
40 other => err(format!(
41 "unknown ui mode '{other}' (hidden|background|visible)"
42 )),
43 }
44 }
45}
46
47#[derive(Debug, Clone)]
48pub struct LaunchSpec {
49 pub binary: PathBuf,
51 pub mode: Mode,
52 pub env: Vec<(String, String)>,
53 pub startup_timeout: Duration,
54 pub label: String,
55}
56
57#[derive(Debug, Clone)]
58pub struct StopReport {
59 pub pid: u32,
60 pub endpoint_closed: bool,
61 pub process_gone: bool,
62 pub frontmost_before: Option<String>,
63 pub frontmost_after: Option<String>,
64 pub frontmost_unchanged: bool,
65 pub frontmost_states: Vec<String>,
67 pub owned_never_frontmost: bool,
70 pub owned_frontmost_pids: Vec<u32>,
72}
73
74pub struct Control {
75 pub pid: u32,
76 pub service: String,
78 pub log_path: PathBuf,
79 raw_log: PathBuf,
80 suite: Suite,
81 endpoint: String,
82 client: Mutex<Option<Arc<Client>>>,
83 child: Option<OwnedChild>,
84 tracker: Tracker,
85 frontmost_before: Option<String>,
86 focus: Option<FocusMonitor>,
87 request_timeout: Duration,
88 stopped: bool,
89}
90
91const APP: &str = "right-qa";
92
93fn svc_err(e: ServiceError) -> Error {
94 Error(e.to_string())
95}
96
97enum CallError {
98 TimedOut,
99 Service(ServiceError),
100}
101
102fn bounded<T: Send + 'static>(
105 timeout: Duration,
106 f: impl FnOnce() -> T + Send + 'static,
107) -> Option<T> {
108 let (tx, rx) = std::sync::mpsc::channel();
109 std::thread::spawn(move || {
110 let _ = tx.send(f());
111 });
112 rx.recv_timeout(timeout).ok()
113}
114
115fn call_bounded(
116 client: &Arc<Client>,
117 method: &str,
118 params: Value,
119 timeout: Duration,
120) -> std::result::Result<Value, CallError> {
121 let c = client.clone();
122 let m = method.to_string();
123 match bounded(timeout, move || c.call(&m, params)) {
124 Some(r) => r.map_err(CallError::Service),
125 None => {
126 client.abort();
127 Err(CallError::TimedOut)
128 }
129 }
130}
131
132fn connect_bounded(suite: &Suite, service: &str, timeout: Duration) -> Result<Client> {
133 let (s, sv) = (suite.clone(), service.to_string());
134 match bounded(timeout, move || Client::connect(&s, &sv, APP)) {
135 Some(r) => r.map_err(svc_err),
136 None => err(format!(
137 "connecting to {service} did not finish within {}ms",
138 timeout.as_millis()
139 )),
140 }
141}
142
143fn bounded_connect_health(suite: &Suite, service: &str, timeout: Duration) -> Option<Arc<Client>> {
144 let (s, sv) = (suite.clone(), service.to_string());
145 bounded(timeout, move || {
146 let c = Client::connect(&s, &sv, APP).ok()?;
147 c.call("health", json!({})).ok()?;
148 Some(Arc::new(c))
149 })
150 .flatten()
151}
152
153fn endpoint_open(endpoint: &str) -> bool {
154 rightkit_service::probe_endpoint(endpoint)
155}
156
157pub fn frontmost() -> Option<String> {
160 #[cfg(target_os = "macos")]
161 {
162 rightkit_control::mac::frontmost_name()
163 }
164 #[cfg(windows)]
165 {
166 use windows::Win32::UI::WindowsAndMessaging::{
167 GetForegroundWindow, GetWindowThreadProcessId,
168 };
169 unsafe {
170 let h = GetForegroundWindow();
171 if h.0.is_null() {
172 return None;
173 }
174 let mut pid = 0u32;
175 if GetWindowThreadProcessId(h, Some(&mut pid)) == 0 || pid == 0 {
176 return None;
177 }
178 Some(format!("{pid}:{:x}", h.0 as usize))
179 }
180 }
181 #[cfg(not(any(target_os = "macos", windows)))]
182 {
183 None
184 }
185}
186
187struct FocusMonitor {
191 stop: Arc<std::sync::atomic::AtomicBool>,
192 seen: Arc<Mutex<(usize, usize, Vec<String>)>>, owned: Arc<Mutex<(Vec<u32>, Option<String>)>>, enforce_owned: bool,
195 owned_frontmost: Arc<Mutex<Vec<u32>>>,
196 thread: Option<std::thread::JoinHandle<()>>,
197}
198
199impl FocusMonitor {
200 fn start(enforce_owned: bool) -> FocusMonitor {
201 let seen: Arc<Mutex<(usize, usize, Vec<String>)>> = Arc::new(Mutex::new((0, 0, vec![])));
202 let owned: Arc<Mutex<(Vec<u32>, Option<String>)>> = Arc::new(Mutex::new((vec![], None)));
203 let owned_frontmost = Arc::new(Mutex::new(vec![]));
204 let record = move |seen: &Mutex<(usize, usize, Vec<String>)>,
205 _owned: &Mutex<(Vec<u32>, Option<String>)>,
206 _owned_frontmost: &Mutex<Vec<u32>>| {
207 {
208 let mut g = seen.lock().unwrap_or_else(|e| e.into_inner());
209 match frontmost() {
210 Some(f) => {
211 g.0 += 1;
212 if g.2.last() != Some(&f) {
213 g.2.push(f);
214 }
215 }
216 None => g.1 += 1,
217 }
218 }
219 #[cfg(target_os = "macos")]
220 if enforce_owned {
221 match rightkit_control::mac::frontmost_pid() {
222 Some(pid) => {
223 let (primary, prefix) =
224 _owned.lock().unwrap_or_else(|e| e.into_inner()).clone();
225 let mut candidates = primary;
226 let mut inventory_ok = true;
227 if let Some(prefix) = prefix {
228 match pids_with_command_prefix(&prefix) {
229 Ok(pids) => candidates.extend(pids),
230 Err(_) => inventory_ok = false,
231 }
232 }
233 if !inventory_ok {
234 let mut g = seen.lock().unwrap_or_else(|e| e.into_inner());
235 g.1 += 1;
236 }
237 if candidates.into_iter().any(|p| p == pid as u32) {
238 let mut observed =
239 _owned_frontmost.lock().unwrap_or_else(|e| e.into_inner());
240 if !observed.contains(&(pid as u32)) {
241 observed.push(pid as u32);
242 }
243 }
244 }
245 None => {
246 let mut g = seen.lock().unwrap_or_else(|e| e.into_inner());
248 g.1 += 1;
249 }
250 }
251 }
252 };
253 record(&seen, &owned, &owned_frontmost);
254 let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
255 let (s2, seen2, owned2, observed2) = (
256 stop.clone(),
257 seen.clone(),
258 owned.clone(),
259 owned_frontmost.clone(),
260 );
261 let thread = std::thread::spawn(move || {
262 while !s2.load(std::sync::atomic::Ordering::SeqCst) {
263 std::thread::sleep(Duration::from_millis(20));
264 record(&seen2, &owned2, &observed2);
265 }
266 });
267 FocusMonitor {
268 stop,
269 seen,
270 owned,
271 enforce_owned,
272 owned_frontmost,
273 thread: Some(thread),
274 }
275 }
276 fn track_primary(&self, pid: u32) {
277 self.owned
278 .lock()
279 .unwrap_or_else(|e| e.into_inner())
280 .0
281 .push(pid);
282 }
283 #[cfg(target_os = "macos")]
284 fn track_prefix(&self, prefix: String) {
285 self.owned.lock().unwrap_or_else(|e| e.into_inner()).1 = Some(prefix);
286 }
287 fn finish(&mut self) -> (bool, Vec<String>, bool, Vec<u32>) {
289 self.stop.store(true, std::sync::atomic::Ordering::SeqCst);
290 if let Some(t) = self.thread.take() {
291 let _ = t.join();
292 }
293 let g = self.seen.lock().unwrap_or_else(|e| e.into_inner());
294 let unchanged = g.0 > 0 && g.1 == 0 && g.2.len() == 1;
295 let observed = self
296 .owned_frontmost
297 .lock()
298 .unwrap_or_else(|e| e.into_inner())
299 .clone();
300 #[cfg(target_os = "macos")]
301 let identity_registered = self
304 .owned
305 .lock()
306 .unwrap_or_else(|e| e.into_inner())
307 .1
308 .is_some();
309 #[cfg(target_os = "macos")]
310 let owned_never_frontmost =
311 !self.enforce_owned || (identity_registered && g.1 == 0 && observed.is_empty());
312 #[cfg(not(target_os = "macos"))]
313 let owned_never_frontmost = !self.enforce_owned;
316 (unchanged, g.2.clone(), owned_never_frontmost, observed)
317 }
318}
319
320fn check_env(env: &[(String, String)]) -> Result<()> {
324 for (k, _) in env {
325 let up = k.to_ascii_uppercase();
326 if [
327 "TOKEN",
328 "SECRET",
329 "PASSWORD",
330 "PASSWD",
331 "API_KEY",
332 "APIKEY",
333 "PRIVATE_KEY",
334 "CREDENTIAL",
335 ]
336 .iter()
337 .any(|w| up.contains(w))
338 {
339 return err(format!("environment key {k} names a credential; secrets are never passed to a launched app through its command line or environment"));
340 }
341 #[cfg(target_os = "macos")]
342 if !up.starts_with("RIGHTKIT_") {
343 return err(format!("environment key {k} would be exposed in `open` arguments; macOS launches carry only RIGHTKIT_* keys"));
344 }
345 }
346 Ok(())
347}
348
349#[cfg(target_os = "macos")]
353#[doc(hidden)]
354pub fn resolve_bundle_executable(bundle: &Path) -> Option<String> {
355 let plist = bundle.join("Contents").join("Info.plist");
356 let out = Command::new("plutil")
357 .args(["-extract", "CFBundleExecutable", "raw", "-o", "-"])
358 .arg(&plist)
359 .output()
360 .ok()?;
361 if !out.status.success() {
362 return None;
363 }
364 let name = String::from_utf8(out.stdout).ok()?;
365 let name = name.trim();
366 if name.is_empty() || name == "." || name == ".." || name.contains('/') || name.contains('\0') {
367 return None;
368 }
369 let exe = bundle.join("Contents").join("MacOS").join(name);
370 let meta = fs::metadata(&exe).ok()?;
371 if !meta.is_file() {
372 return None;
373 }
374 Some(exe.to_string_lossy().into_owned())
375}
376
377#[cfg(target_os = "macos")]
378fn make_bundle(dir: &Path, binary: &Path, id: &str, background: bool) -> Result<PathBuf> {
379 let exe = binary
380 .file_name()
381 .and_then(|n| n.to_str())
382 .ok_or_else(|| Error("binary has no file name".into()))?;
383 let app = dir.join(format!("{exe}.app"));
384 let macos = app.join("Contents").join("MacOS");
385 fs::create_dir_all(&macos)?;
386 let target = macos.join(exe);
387 let _ = fs::remove_file(&target);
388 let abs = fs::canonicalize(binary)
389 .map_err(|e| Error(format!("app binary not found: {}: {e}", binary.display())))?;
390 fs::copy(&abs, &target)?;
392 let ui_element = if background {
394 "<key>LSUIElement</key><true/>\n"
395 } else {
396 ""
397 };
398 let plist = format!(
399 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\"><dict>\n<key>CFBundleExecutable</key><string>{exe}</string>\n<key>CFBundleIdentifier</key><string>app.rightkit.qa.{id}</string>\n<key>CFBundleName</key><string>{exe}</string>\n<key>CFBundlePackageType</key><string>APPL</string>\n<key>CFBundleVersion</key><string>1</string>\n{ui_element}</dict></plist>\n"
400 );
401 fs::write(app.join("Contents").join("Info.plist"), plist)?;
402 Ok(app)
403}
404
405#[cfg(target_os = "macos")]
406fn stage_bundle(spec: &LaunchSpec, ws: &QaWorkspace, id: &str) -> Result<PathBuf> {
407 let dir = ws.home.join("apps").join(id);
408 fs::create_dir_all(&dir)?;
409 if spec.binary.extension().is_some_and(|e| e == "app") {
410 let source = fs::canonicalize(&spec.binary)?;
411 let bundle = dir.join(
412 source
413 .file_name()
414 .ok_or_else(|| Error("bundle has no file name".into()))?,
415 );
416 let status = Command::new("/usr/bin/ditto")
418 .arg(&source)
419 .arg(&bundle)
420 .status()?;
421 if !status.success() {
422 return err(format!("app bundle staging failed: {status}"));
423 }
424 check_bundle_links(&bundle, &bundle)?;
425 Ok(bundle)
426 } else {
427 make_bundle(&dir, &spec.binary, &id[..8], spec.mode != Mode::Visible)
428 }
429}
430
431#[cfg(target_os = "macos")]
432fn check_bundle_links(bundle: &Path, dir: &Path) -> Result<()> {
433 for entry in fs::read_dir(dir)? {
434 let entry = entry?;
435 let path = entry.path();
436 let kind = entry.file_type()?;
437 if kind.is_symlink() {
438 if !fs::canonicalize(&path)?.starts_with(bundle) {
439 return err(format!(
440 "staged app bundle link escapes internal bundle: {}",
441 path.display()
442 ));
443 }
444 } else if kind.is_dir() {
445 check_bundle_links(bundle, &path)?;
446 }
447 }
448 Ok(())
449}
450
451fn launch_env(
452 spec: &LaunchSpec,
453 ws: &QaWorkspace,
454 suite: &Suite,
455 service: &str,
456) -> Result<Vec<(String, String)>> {
457 check_env(&spec.env)?;
460 let mut env = ws.env.clone();
461 env.insert("RIGHTKIT_CONTROL_SERVICE".into(), service.into());
462 env.insert(
463 "RIGHTKIT_SUITE_ROOT".into(),
464 suite.root().to_string_lossy().into(),
465 );
466 env.insert(
467 "RIGHTKIT_QA_HIDDEN".into(),
468 if spec.mode == Mode::Hidden { "1" } else { "0" }.into(),
469 );
470 env.insert(
471 "RIGHTKIT_QA_BACKGROUND".into(),
472 if spec.mode != Mode::Visible { "1" } else { "0" }.into(),
473 );
474 for (key, value) in &spec.env {
475 let path = Path::new(value);
476 if path.is_absolute()
477 && (!path.starts_with(&ws.home)
478 || path
479 .components()
480 .any(|c| c == std::path::Component::ParentDir))
481 {
482 return err(format!(
483 "environment path {key} must be staged inside the QA app home"
484 ));
485 }
486 if let Some(owned) = env.get(key) {
487 let app_path = ws.env.contains_key(key)
488 && key != "RIGHTKIT_SUITE_ROOT"
489 && Path::new(owned).is_absolute();
490 let internal = Path::new(value).starts_with(&ws.home)
491 && !Path::new(value)
492 .components()
493 .any(|c| c == std::path::Component::ParentDir);
494 if value != owned && !(app_path && internal) {
495 return err(format!(
496 "environment key {key} overrides a harness-owned launch value"
497 ));
498 }
499 }
500 env.insert(key.clone(), value.clone());
501 }
502 Ok(env.into_iter().collect())
503}
504
505#[cfg(target_os = "macos")]
506fn open_command(
507 spec: &LaunchSpec,
508 ws: &QaWorkspace,
509 env: &[(String, String)],
510 log_path: &Path,
511 bundle: &Path,
512) -> Command {
513 let mut cmd = Command::new("open");
514 cmd.current_dir(&ws.home);
515 match spec.mode {
516 Mode::Hidden => cmd.args(["-g", "-j", "-n"]),
517 Mode::Background => cmd.args(["-g", "-n"]),
518 Mode::Visible => cmd.args(["-n"]),
519 };
520 for (k, v) in env {
521 cmd.env(k, v).arg("--env").arg(format!("{k}={v}"));
522 }
523 cmd.arg("--stdout")
524 .arg(log_path)
525 .arg("--stderr")
526 .arg(log_path)
527 .arg(bundle);
528 cmd
529}
530
531#[cfg(target_os = "macos")]
532fn pids_with_command_prefix(prefix: &str) -> std::result::Result<Vec<u32>, String> {
535 let out = Command::new("ps")
536 .args(["-ax", "-o", "pid=,command="])
537 .output()
538 .map_err(|e| format!("ps PID inventory failed: {e}"))?;
539 if !out.status.success() {
540 return Err(format!("ps PID inventory exited with {}", out.status));
541 }
542 let mut pids = vec![];
543 for line in String::from_utf8_lossy(&out.stdout).lines() {
544 let line = line.trim_start();
545 let Some((pid, cmd)) = line.split_once(' ') else {
546 continue;
547 };
548 if cmd.trim_start().starts_with(prefix) {
549 pids.push(
550 pid.trim()
551 .parse::<u32>()
552 .map_err(|e| format!("invalid PID inventory row: {e}"))?,
553 );
554 }
555 }
556 Ok(pids)
557}
558
559pub fn launch(spec: &LaunchSpec, ws: &QaWorkspace, tracker: &Tracker) -> Result<Control> {
560 if !spec.binary.exists() {
561 return err(format!(
562 "UI app binary not found: {}",
563 spec.binary.display()
564 ));
565 }
566 let id = new_id().replace('-', "");
567 let service = format!("control-{}", &id[..8]);
568 let suite =
570 Suite::at(ws.home.join(format!("suite-{}", &id[..8]))).map_err(|e| Error(e.to_string()))?;
571 let log_path = ws.evidence_dir.join(format!("app-{}.log", &id[..8]));
572 let raw_log = ws.home.join(format!("app-{}.log", &id[..8]));
576 let env = launch_env(spec, ws, &suite, &service)?;
577 let frontmost_before = frontmost();
578 let focus = FocusMonitor::start(spec.mode != Mode::Visible);
579 let deadline = Instant::now() + spec.startup_timeout;
580
581 let (pid, child) = start_process(spec, ws, &env, &raw_log, &id, tracker, &focus, deadline)?;
584 focus.track_primary(pid);
585 tracker.register(pid, &spec.label);
586
587 let mut control = Control {
588 pid,
589 service: service.clone(),
590 log_path,
591 raw_log,
592 suite: suite.clone(),
593 endpoint: String::new(),
594 client: Mutex::new(None),
595 child,
596 tracker: tracker.clone(),
597 frontmost_before,
598 focus: Some(focus),
599 request_timeout: Duration::from_secs(20),
600 stopped: false,
601 };
602 loop {
603 let left = deadline.saturating_duration_since(Instant::now());
605 if let Some(c) = bounded_connect_health(&suite, &service, left.min(Duration::from_secs(5)))
606 {
607 control.endpoint = c.record().endpoint.clone();
608 *control.client.lock().unwrap() = Some(c);
609 break;
610 }
611 if !is_alive(pid) {
612 let tail = fs::read_to_string(&control.raw_log)
613 .map(|t| crate::util::tail(&t, 1500))
614 .unwrap_or_default();
615 return err(format!(
616 "app exited before its control server came up (pid {pid}); log tail: {tail}"
617 ));
618 }
619 if Instant::now() >= deadline {
620 let _ = control.stop();
621 return err(format!(
622 "control service did not answer within {}ms; was the app built with its control feature and launched with RIGHTKIT_CONTROL_SERVICE?",
623 spec.startup_timeout.as_millis()
624 ));
625 }
626 sleep_ms(100);
627 }
628 loop {
631 if let Ok(Value::String(state)) = control.eval("return document.readyState;") {
632 if state == "complete" || state == "interactive" {
633 break;
634 }
635 }
636 if Instant::now() >= deadline {
637 let _ = control.stop();
638 return err(format!(
639 "app window never became ready within {}ms",
640 spec.startup_timeout.as_millis()
641 ));
642 }
643 sleep_ms(100);
644 }
645 Ok(control)
646}
647
648#[allow(clippy::too_many_arguments)]
649fn start_process(
650 spec: &LaunchSpec,
651 ws: &QaWorkspace,
652 env: &[(String, String)],
653 log_path: &Path,
654 id: &str,
655 _tracker: &Tracker,
656 focus: &FocusMonitor,
657 deadline: Instant,
658) -> Result<(u32, Option<OwnedChild>)> {
659 #[cfg(target_os = "macos")]
660 {
661 let bundle = stage_bundle(spec, ws, id)?;
662 let needle = format!("{}/Contents/MacOS/", bundle.display());
663 if let Some(executable) = resolve_bundle_executable(&bundle) {
667 focus.track_prefix(executable);
668 }
669 let mut cmd = open_command(spec, ws, env, log_path, &bundle);
670 let st = cmd.status()?;
671 if !st.success() {
672 return err(format!("open exited {st}"));
673 }
674 loop {
675 if let Some(pid) = pids_with_command_prefix(&needle)
676 .ok()
677 .and_then(|pids| pids.into_iter().next())
678 {
679 return Ok((pid, None));
680 }
681 if Instant::now() >= deadline {
682 return err("launched app pid not found");
683 }
684 sleep_ms(100);
685 }
686 }
687 #[cfg(not(target_os = "macos"))]
688 {
689 let _ = (ws, id, focus);
690 let log = fs::File::create(log_path)?;
691 let log2 = log.try_clone()?;
692 let mut cmd = Command::new(&spec.binary);
693 cmd.current_dir(&ws.home)
694 .stdin(Stdio::null())
695 .stdout(Stdio::from(log))
696 .stderr(Stdio::from(log2));
697 for (k, v) in env {
698 cmd.env(k, v);
699 }
700 let mut owned = OwnedCommand::from_command(cmd);
701 if spec.mode != Mode::Visible {
702 owned.windows_hide();
703 }
704 let _ = deadline;
705 let child = owned
706 .spawn()
707 .map_err(|e| Error(format!("failed to start {}: {e}", spec.binary.display())))?;
708 Ok((child.id(), Some(child)))
709 }
710}
711
712impl Control {
713 pub fn set_request_timeout(&mut self, t: Duration) {
714 self.request_timeout = t;
715 }
716
717 fn rpc(&self, method: &str, params: Value) -> Result<Value> {
722 let replayable = matches!(method, "health" | "dom" | "ax" | "screenshot" | "move");
723 let mut g = self.client.lock().unwrap();
724 for attempt in 0..2 {
725 let client = match g.as_ref() {
726 Some(c) => c.clone(),
727 None => {
728 let c = Arc::new(connect_bounded(
730 &self.suite,
731 &self.service,
732 self.request_timeout,
733 )?);
734 *g = Some(c.clone());
735 c
736 }
737 };
738 match call_bounded(&client, method, params.clone(), self.request_timeout) {
739 Ok(v) => return Ok(v),
740 Err(CallError::TimedOut) => {
741 *g = None;
742 return err(format!(
743 "{method}: no reply within {}ms; connection aborted",
744 self.request_timeout.as_millis()
745 ));
746 }
747 Err(CallError::Service(e)) if e.retryable && replayable && attempt == 0 => {
748 *g = None
749 }
750 Err(CallError::Service(e)) => {
751 if e.retryable {
752 *g = None;
753 }
754 let mut m = svc_err(e);
755 if !replayable {
756 m = Error(format!("{} (not replayed: {method} has effects and the first attempt may have been applied)", m.0));
757 }
758 return Err(m);
759 }
760 }
761 }
762 err("control rpc failed")
763 }
764
765 fn json(&self, method: &str, params: Value) -> Result<Value> {
766 let v = self.rpc(method, params)?;
767 if v.get("ok") == Some(&Value::Bool(false)) {
768 return err(format!(
769 "{method}: {}",
770 v.get("error")
771 .and_then(Value::as_str)
772 .unwrap_or("unknown error")
773 ));
774 }
775 Ok(v)
776 }
777
778 pub fn health(&self) -> Result<Value> {
779 self.rpc("health", json!({}))
780 }
781 pub fn set_viewport(&self, width: u32, height: u32) -> Result<(u32, u32)> {
784 let v = self.json("set_viewport", json!({"width": width, "height": height}))?;
785 let dimension = |key: &str| {
786 v.get(key)
787 .and_then(Value::as_u64)
788 .and_then(|n| u32::try_from(n).ok())
789 .filter(|n| *n > 0)
790 .ok_or_else(|| Error(format!("set_viewport returned invalid {key}")))
791 };
792 Ok((dimension("innerWidth")?, dimension("innerHeight")?))
793 }
794 pub fn unlisted_app_probe(&self) -> Result<String> {
796 match Client::connect(&self.suite, &self.service, "not-allowlisted-probe") {
797 Ok(_) => err("an unlisted app was accepted by the control service"),
798 Err(e) => Ok(e.code),
799 }
800 }
801 pub fn click(&self, x: f64, y: f64, button: &str, count: u32) -> Result<()> {
802 self.json(
803 "click",
804 json!({"x": x, "y": y, "button": button, "count": count}),
805 )
806 .map(|_| ())
807 }
808 pub fn click_with(
811 &self,
812 x: f64,
813 y: f64,
814 button: &str,
815 count: u32,
816 modifiers: &[&str],
817 ) -> Result<()> {
818 self.json(
819 "click",
820 json!({"x": x, "y": y, "button": button, "count": count, "modifiers": modifiers}),
821 )
822 .map(|_| ())
823 }
824 pub fn move_to(&self, x: f64, y: f64) -> Result<()> {
825 self.json("move", json!({"x": x, "y": y})).map(|_| ())
826 }
827 pub fn pointer(
831 &self,
832 phase: &str,
833 x: f64,
834 y: f64,
835 button: &str,
836 modifiers: &[&str],
837 ) -> Result<()> {
838 self.json(
839 "pointer",
840 json!({"phase": phase, "x": x, "y": y, "button": button, "modifiers": modifiers}),
841 )
842 .map(|_| ())
843 }
844 pub fn drag(&self, from: (f64, f64), to: (f64, f64), steps: u32) -> Result<()> {
845 self.json(
846 "drag",
847 json!({"x1": from.0, "y1": from.1, "x2": to.0, "y2": to.1, "steps": steps}),
848 )
849 .map(|_| ())
850 }
851 pub fn wheel(&self, x: f64, y: f64, dx: f64, dy: f64) -> Result<()> {
852 self.json("wheel", json!({"x": x, "y": y, "dx": dx, "dy": dy}))
853 .map(|_| ())
854 }
855 pub fn key(&self, spec: &str) -> Result<()> {
856 self.json("key", json!({"key": spec})).map(|_| ())
857 }
858 pub fn type_text(&self, text: &str) -> Result<()> {
859 self.json("type", json!({"text": text})).map(|_| ())
860 }
861 pub fn eval(&self, js: &str) -> Result<Value> {
863 Ok(self
864 .json("eval", json!({"js": js}))?
865 .get("value")
866 .cloned()
867 .unwrap_or(Value::Null))
868 }
869 pub fn dom(&self, selector: &str) -> Result<Vec<Value>> {
870 let v = self.json("dom", json!({"selector": selector}))?;
871 Ok(v.get("elements")
872 .and_then(Value::as_array)
873 .cloned()
874 .unwrap_or_default())
875 }
876 pub fn ax(&self) -> Result<Vec<Value>> {
877 let v = self.json("ax", json!({}))?;
878 Ok(v.get("nodes")
879 .and_then(Value::as_array)
880 .cloned()
881 .unwrap_or_default())
882 }
883 pub fn screenshot_png(&self) -> Result<Vec<u8>> {
884 let v = self.json("screenshot", json!({}))?;
885 if v.get("masked").and_then(Value::as_u64).is_none() {
888 return err("control server does not redact screenshots; capture refused");
889 }
890 let path = PathBuf::from(
891 v.get("path")
892 .and_then(Value::as_str)
893 .ok_or_else(|| Error("screenshot returned no path".into()))?,
894 );
895 let body = fs::read(&path)?;
896 let _ = fs::remove_file(&path);
897 if body.len() < 8 || &body[1..4] != b"PNG" {
898 return err("screenshot method did not return a PNG");
899 }
900 Ok(body)
901 }
902 pub fn screenshot_to(&self, path: &Path) -> Result<()> {
903 if let Some(p) = path.parent() {
904 fs::create_dir_all(p)?;
905 }
906 fs::write(path, self.screenshot_png()?)?;
907 Ok(())
908 }
909 pub fn command(&self, name: &str, args: &Value) -> Result<Value> {
912 let args = match args {
913 Value::String(s) => s.clone(),
914 Value::Null => String::new(),
915 other => other.to_string(),
916 };
917 let v = self.json("command", json!({"name": name, "args": args}))?;
918 Ok(v.get("result").cloned().unwrap_or(Value::Null))
919 }
920
921 pub fn center_of(&self, selector: &str) -> Result<(f64, f64)> {
923 let els = self.dom(selector)?;
924 let first = els
925 .first()
926 .ok_or_else(|| Error(format!("selector matched no element: {selector}")))?;
927 let r: Vec<f64> = first
928 .get("rect")
929 .and_then(Value::as_array)
930 .map(|a| a.iter().filter_map(Value::as_f64).collect())
931 .unwrap_or_default();
932 if r.len() < 4 || r[2] <= 0.0 || r[3] <= 0.0 {
933 return err(format!("{selector} has no visible box"));
934 }
935 Ok((r[0] + r[2] / 2.0, r[1] + r[3] / 2.0))
936 }
937 pub fn click_selector(&self, selector: &str) -> Result<()> {
938 let (x, y) = self.center_of(selector)?;
939 self.click(x, y, "left", 1)
940 }
941
942 pub fn wait_eval(&self, js: &str, timeout: Duration) -> Result<Value> {
944 let deadline = Instant::now() + timeout;
945 let mut last;
946 loop {
947 match self.eval(js) {
948 Ok(v) => {
949 let truthy = !matches!(v, Value::Null | Value::Bool(false))
950 && v != json!(0)
951 && v != json!("");
952 if truthy {
953 return Ok(v);
954 }
955 last = v;
956 }
957 Err(e) => last = json!(e.0),
958 }
959 if Instant::now() >= deadline {
960 return err(format!("timeout waiting for `{js}` (last: {last})"));
961 }
962 sleep_ms(100);
963 }
964 }
965
966 pub fn wait_for_text(
969 &self,
970 selector: Option<&str>,
971 text: &str,
972 timeout: Duration,
973 ) -> Result<()> {
974 let sel = serde_json::to_string(selector.unwrap_or("body")).unwrap_or_default();
975 let needle = serde_json::to_string(text).unwrap_or_default();
976 let js = format!(
977 "return ((document.querySelector({sel}) || {{}}).innerText || '').includes({needle});"
978 );
979 self.wait_eval(&js, timeout).map(|_| ())
980 }
981
982 pub fn stop(&mut self) -> Result<StopReport> {
985 if self.stopped {
986 return err("control session already stopped");
987 }
988 self.stopped = true;
989 if let Some(mut c) = self.child.take() {
990 let _ = c.terminate_tree();
991 }
992 kill_tree(self.pid);
993 let deadline = Instant::now() + Duration::from_secs(5);
994 while Instant::now() < deadline && (is_alive(self.pid) || endpoint_open(&self.endpoint)) {
995 sleep_ms(50);
996 }
997 let log_copy = fs::copy(&self.raw_log, &self.log_path);
1000 let after = frontmost();
1001 let (steady, states, owned_never_frontmost, owned_frontmost_pids) = self
1002 .focus
1003 .take()
1004 .map(|mut f| f.finish())
1005 .unwrap_or((false, vec![], false, vec![]));
1006 let process_gone = !is_alive(self.pid);
1007 let endpoint_closed = self.endpoint.is_empty() || !endpoint_open(&self.endpoint);
1008 if process_gone {
1009 self.tracker.forget(self.pid);
1010 }
1011 log_copy.map_err(|e| {
1012 Error(format!(
1013 "cannot preserve app log {}: {e}",
1014 self.raw_log.display()
1015 ))
1016 })?;
1017 Ok(StopReport {
1018 pid: self.pid,
1019 endpoint_closed,
1020 process_gone,
1021 frontmost_unchanged: steady
1022 && self.frontmost_before.is_some()
1023 && self.frontmost_before == after,
1024 frontmost_states: states,
1025 frontmost_before: self.frontmost_before.clone(),
1026 frontmost_after: after,
1027 owned_never_frontmost,
1028 owned_frontmost_pids,
1029 })
1030 }
1031}
1032
1033impl Drop for Control {
1034 fn drop(&mut self) {
1035 if !self.stopped {
1036 let _ = self.stop();
1037 }
1038 }
1039}
1040
1041#[cfg(test)]
1042mod launch_path_tests {
1043 use super::*;
1044
1045 fn fixture() -> (QaWorkspace, LaunchSpec) {
1046 let root = crate::storage::app_temp_dir().join(format!("rkqa-launch-{}", new_id()));
1047 let ws = crate::workspace::create(&root, None, "control-test-app").unwrap();
1048 let binary = ws.root.join("control-test-app");
1049 fs::write(&binary, "fixture binary").unwrap();
1050 let spec = LaunchSpec {
1051 binary,
1052 mode: Mode::Hidden,
1053 env: vec![],
1054 startup_timeout: Duration::from_secs(90),
1055 label: "fixture".into(),
1056 };
1057 (ws, spec)
1058 }
1059
1060 fn cleanup(ws: &QaWorkspace) {
1061 let root = ws
1062 .root
1063 .parent()
1064 .unwrap()
1065 .parent()
1066 .unwrap()
1067 .parent()
1068 .unwrap()
1069 .to_path_buf();
1070 let report = crate::workspace::dispose(ws, false, 1, 0);
1071 assert!(report.retained.is_empty(), "{report:?}");
1072 fs::remove_dir_all(root).unwrap();
1073 }
1074
1075 #[test]
1076 fn launch_environment_keeps_all_owned_paths_internal_and_rejects_external_overrides() {
1077 let (ws, mut spec) = fixture();
1078 let suite = Suite::at(ws.home.join("suite-12345678")).unwrap();
1079 let env: std::collections::BTreeMap<_, _> =
1080 launch_env(&spec, &ws, &suite, "control-12345678")
1081 .unwrap()
1082 .into_iter()
1083 .collect();
1084 for (key, value) in &env {
1085 if Path::new(value).is_absolute() {
1086 assert!(Path::new(value).starts_with(&ws.home), "{key}={value}");
1087 assert!(!Path::new(value).starts_with(&ws.root), "{key}={value}");
1088 }
1089 }
1090 assert_eq!(env["HOME"], ws.home.to_string_lossy());
1091 assert_eq!(env["TMPDIR"], ws.home.join("tmp").to_string_lossy());
1092 assert_eq!(env["RIGHTKIT_SUITE_ROOT"], suite.root().to_string_lossy());
1093 assert_eq!(env["RIGHTKIT_QA_HIDDEN"], "1");
1094 assert_eq!(env["RIGHTKIT_QA_BACKGROUND"], "1");
1095 #[cfg(target_os = "macos")]
1098 assert!(
1099 Path::new(&env["TMPDIR"])
1100 .join("rk-12345678-control-12345678.sock")
1101 .as_os_str()
1102 .len()
1103 <= 100
1104 );
1105 assert!(suite
1106 .service_dir("control-12345678")
1107 .join("service.json")
1108 .starts_with(&ws.home));
1109 for key in [
1110 "RIGHTKIT_SUITE_ROOT",
1111 "RIGHTKIT_QA_DATA_DIR",
1112 "RIGHTKIT_RUN_ROOT",
1113 ] {
1114 spec.env = vec![(
1115 key.into(),
1116 ws.root.join("external").to_string_lossy().into(),
1117 )];
1118 assert!(
1119 launch_env(&spec, &ws, &suite, "control-12345678").is_err(),
1120 "{key}"
1121 );
1122 }
1123 spec.env = vec![("RIGHTKIT_SECRET".into(), "redacted".into())];
1124 assert!(launch_env(&spec, &ws, &suite, "control-12345678").is_err());
1125 cleanup(&ws);
1126 }
1127
1128 #[cfg(target_os = "macos")]
1129 #[test]
1130 fn raw_binary_and_supplied_bundle_are_staged_inside_app_home_before_open() {
1131 let (ws, mut spec) = fixture();
1132 let id = "1234567890abcdef";
1133 let bundle = stage_bundle(&spec, &ws, id).unwrap();
1134 assert!(bundle.starts_with(ws.home.join("apps")));
1135 assert!(!bundle.starts_with(&ws.root));
1136 assert_eq!(
1137 fs::read(bundle.join("Contents/MacOS/control-test-app")).unwrap(),
1138 b"fixture binary"
1139 );
1140 let plist = fs::read_to_string(bundle.join("Contents/Info.plist")).unwrap();
1141 assert!(plist.contains("<key>LSUIElement</key><true/>"));
1142 let suite = Suite::at(ws.home.join("suite-12345678")).unwrap();
1143 let env = launch_env(&spec, &ws, &suite, "control-12345678").unwrap();
1144 let log = ws.home.join("app-12345678.log");
1145 let cmd = open_command(&spec, &ws, &env, &log, &bundle);
1146 assert_eq!(cmd.get_current_dir(), Some(ws.home.as_path()));
1147 let args: Vec<_> = cmd
1148 .get_args()
1149 .map(|v| v.to_string_lossy().into_owned())
1150 .collect();
1151 assert_eq!(&args[..3], &["-g", "-j", "-n"]);
1152 for (key, value) in &env {
1153 assert!(
1154 args.windows(2)
1155 .any(|pair| pair[0] == "--env" && pair[1] == format!("{key}={value}")),
1156 "{key}"
1157 );
1158 }
1159 for flag in ["--stdout", "--stderr"] {
1160 assert!(args
1161 .windows(2)
1162 .any(|pair| pair[0] == flag && Path::new(&pair[1]) == log));
1163 }
1164 assert_eq!(args.last().unwrap(), &bundle.to_string_lossy());
1165 spec.binary = make_bundle(&ws.root, &spec.binary, "original", true).unwrap();
1167 fs::create_dir_all(spec.binary.join("Contents/Resources")).unwrap();
1168 fs::write(
1169 spec.binary.join("Contents/Resources/fixture.txt"),
1170 "resource",
1171 )
1172 .unwrap();
1173 let supplied = stage_bundle(&spec, &ws, "fedcba0987654321").unwrap();
1174 assert!(supplied.starts_with(&ws.home));
1175 assert_ne!(supplied, spec.binary);
1176 assert_eq!(
1177 fs::read_to_string(supplied.join("Contents/Resources/fixture.txt")).unwrap(),
1178 "resource"
1179 );
1180 std::os::unix::fs::symlink(
1181 ws.root.join("control-test-app"),
1182 spec.binary.join("Contents/Resources/external"),
1183 )
1184 .unwrap();
1185 assert!(stage_bundle(&spec, &ws, "0123456789abcdef")
1186 .unwrap_err()
1187 .0
1188 .contains("escapes internal bundle"));
1189 cleanup(&ws);
1190 }
1191}