1use crate::process::{is_alive, kill_tree, Tracker};
10use crate::util::{err, new_id, sleep_ms, Error, Result};
11use crate::workspace::QaWorkspace;
12use rightkit_child_process::OwnedChild;
13#[cfg(not(target_os = "macos"))]
14use rightkit_child_process::OwnedCommand;
15use rightkit_daemon::{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,
75 pub owned_frontmost_pids: Vec<u32>,
77 pub frontmost_pid_unavailable_samples: usize,
81 pub owned_never_frontmost_failure: Option<String>,
85}
86
87impl StopReport {
88 pub fn permission_dialog(&self) -> Option<String> {
91 crate::userdata::permission_dialog_in(&self.frontmost_states)
92 }
93}
94
95pub struct Control {
96 pub pid: u32,
97 pub service: String,
99 pub log_path: PathBuf,
100 raw_log: PathBuf,
101 suite: Suite,
102 endpoint: String,
103 client: Mutex<Option<Arc<Client>>>,
104 child: Option<OwnedChild>,
105 tracker: Tracker,
106 frontmost_before: Option<String>,
107 focus: Option<FocusMonitor>,
108 request_timeout: Duration,
109 worker: Mutex<Option<Worker>>,
110 pace_ms: u64,
112 stopped: bool,
113}
114
115const APP: &str = "right-qa";
116
117fn svc_err(e: ServiceError) -> Error {
118 Error(e.to_string())
119}
120
121enum CallError {
122 TimedOut,
123 Service(ServiceError),
124}
125
126fn bounded<T: Send + 'static>(
129 timeout: Duration,
130 f: impl FnOnce() -> T + Send + 'static,
131) -> Option<T> {
132 let (tx, rx) = std::sync::mpsc::channel();
133 std::thread::spawn(move || {
134 let _ = tx.send(f());
135 });
136 rx.recv_timeout(timeout).ok()
137}
138
139type Job = Box<dyn FnOnce() + Send>;
140
141struct Worker {
145 jobs: std::sync::mpsc::Sender<Job>,
146}
147
148impl Worker {
149 fn spawn() -> Self {
150 let (jobs, rx) = std::sync::mpsc::channel::<Job>();
151 std::thread::spawn(move || {
152 for job in rx {
153 job();
154 }
155 });
156 Worker { jobs }
157 }
158}
159
160fn call_bounded(
161 client: &Arc<Client>,
162 slot: &Mutex<Option<Worker>>,
163 method: &str,
164 params: Value,
165 timeout: Duration,
166) -> std::result::Result<Value, CallError> {
167 let (tx, rx) = std::sync::mpsc::channel();
168 let (c, m) = (client.clone(), method.to_string());
169 let mut job: Option<Job> = Some(Box::new(move || {
170 let _ = tx.send(c.call(&m, params));
171 }));
172 {
173 let mut w = slot.lock().unwrap();
174 for _ in 0..2 {
175 let worker = w.get_or_insert_with(Worker::spawn);
176 match worker.jobs.send(job.take().unwrap()) {
177 Ok(()) => break,
178 Err(e) => {
179 job = Some(e.0);
181 *w = None;
182 }
183 }
184 }
185 if job.is_some() {
186 return Err(CallError::TimedOut);
187 }
188 }
189 match rx.recv_timeout(timeout) {
190 Ok(r) => r.map_err(CallError::Service),
191 Err(_) => {
192 client.abort();
193 *slot.lock().unwrap() = None;
194 Err(CallError::TimedOut)
195 }
196 }
197}
198
199fn wait_unsupported(msg: &str) -> bool {
201 msg.contains("unknown method")
202 || msg.contains("request_not_allowed")
203 || msg.contains("not allowed")
204}
205
206fn connect_bounded(suite: &Suite, service: &str, timeout: Duration) -> Result<Client> {
207 let (s, sv) = (suite.clone(), service.to_string());
208 match bounded(timeout, move || Client::connect(&s, &sv, APP)) {
209 Some(r) => r.map_err(svc_err),
210 None => err(format!(
211 "connecting to {service} did not finish within {}ms",
212 timeout.as_millis()
213 )),
214 }
215}
216
217fn bounded_connect_health(suite: &Suite, service: &str, timeout: Duration) -> Option<Arc<Client>> {
218 let (s, sv) = (suite.clone(), service.to_string());
219 bounded(timeout, move || {
220 let c = Client::connect(&s, &sv, APP).ok()?;
221 c.call("health", json!({})).ok()?;
222 Some(Arc::new(c))
223 })
224 .flatten()
225}
226
227fn endpoint_open(endpoint: &str) -> bool {
228 rightkit_daemon::probe_endpoint(endpoint)
229}
230
231pub fn frontmost() -> Option<String> {
234 #[cfg(target_os = "macos")]
235 {
236 rightkit_qa_driver::mac::frontmost_name()
237 }
238 #[cfg(windows)]
239 {
240 use windows_sys::Win32::UI::WindowsAndMessaging::{
241 GetForegroundWindow, GetWindowThreadProcessId,
242 };
243 unsafe {
244 let h = GetForegroundWindow();
245 if h.is_null() {
246 return None;
247 }
248 let mut pid = 0u32;
249 if GetWindowThreadProcessId(h, &mut pid) == 0 || pid == 0 {
250 return None;
251 }
252 Some(format!("{pid}:{:x}", h as usize))
253 }
254 }
255 #[cfg(not(any(target_os = "macos", windows)))]
256 {
257 None
258 }
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263#[cfg_attr(not(windows), allow(dead_code))]
264enum Foreground {
265 NoWindow,
268 Pid(u32),
270 Unreadable,
272}
273
274#[cfg_attr(not(windows), allow(dead_code))]
276fn classify_foreground(window_present: bool, thread_id: u32, pid: u32) -> Foreground {
277 if !window_present {
278 Foreground::NoWindow
279 } else if thread_id == 0 || pid == 0 {
280 Foreground::Unreadable
281 } else {
282 Foreground::Pid(pid)
283 }
284}
285
286#[cfg(windows)]
287fn foreground_pid() -> Foreground {
288 use windows_sys::Win32::UI::WindowsAndMessaging::{
289 GetForegroundWindow, GetWindowThreadProcessId,
290 };
291 unsafe {
292 let h = GetForegroundWindow();
293 if h.is_null() {
294 return classify_foreground(false, 0, 0);
295 }
296 let mut pid = 0u32;
297 let thread_id = GetWindowThreadProcessId(h, &mut pid);
298 classify_foreground(true, thread_id, pid)
299 }
300}
301
302#[cfg_attr(not(windows), allow(dead_code))]
307fn windows_owned_never_frontmost(
308 enforce_owned: bool,
309 unreadable_owners: usize,
310 observed: &[u32],
311) -> bool {
312 !enforce_owned || (unreadable_owners == 0 && observed.is_empty())
313}
314
315fn owned_failure_reason(
319 observed: &[u32],
320 unavailable: usize,
321 registered: bool,
322 unmeasured: usize,
323) -> String {
324 if !observed.is_empty() {
325 format!("an owned app process was frontmost (pid {observed:?})")
326 } else if unavailable > 0 {
327 format!("the foreground owner's pid could not be read in {unavailable} sample(s); the background proof fails closed")
328 } else if !registered {
329 "the owned executable was never registered before launch, so startup samples could not be attributed".to_string()
330 } else if unmeasured > 0 {
331 format!("{unmeasured} foreground sample(s) were unmeasurable (name or owned-process inventory unavailable); the background proof fails closed")
332 } else {
333 "the background proof did not hold".to_string()
334 }
335}
336
337struct FocusMonitor {
341 stop: Arc<std::sync::atomic::AtomicBool>,
342 seen: Arc<Mutex<(usize, usize, Vec<String>)>>, owned: Arc<Mutex<(Vec<u32>, Option<String>)>>, enforce_owned: bool,
345 owned_frontmost: Arc<Mutex<Vec<u32>>>,
346 #[cfg_attr(not(windows), allow(dead_code))]
348 unreadable_owners: Arc<std::sync::atomic::AtomicUsize>,
349 thread: Option<std::thread::JoinHandle<()>>,
350}
351
352impl FocusMonitor {
353 fn start(enforce_owned: bool) -> FocusMonitor {
354 let seen: Arc<Mutex<(usize, usize, Vec<String>)>> = Arc::new(Mutex::new((0, 0, vec![])));
355 let owned: Arc<Mutex<(Vec<u32>, Option<String>)>> = Arc::new(Mutex::new((vec![], None)));
356 let owned_frontmost = Arc::new(Mutex::new(vec![]));
357 let unreadable_owners = Arc::new(std::sync::atomic::AtomicUsize::new(0));
358 let record = move |seen: &Mutex<(usize, usize, Vec<String>)>,
359 _owned: &Mutex<(Vec<u32>, Option<String>)>,
360 _owned_frontmost: &Mutex<Vec<u32>>,
361 _unreadable: &std::sync::atomic::AtomicUsize| {
362 {
363 let mut g = seen.lock().unwrap_or_else(|e| e.into_inner());
364 match frontmost() {
365 Some(f) => {
366 g.0 += 1;
367 if g.2.last() != Some(&f) {
368 g.2.push(f);
369 }
370 }
371 None => g.1 += 1,
372 }
373 }
374 #[cfg(windows)]
375 if enforce_owned {
376 match foreground_pid() {
377 Foreground::Pid(pid) => {
378 let owned = _owned.lock().unwrap_or_else(|e| e.into_inner()).0.clone();
379 if owned.contains(&pid) {
380 let mut observed =
381 _owned_frontmost.lock().unwrap_or_else(|e| e.into_inner());
382 if !observed.contains(&pid) {
383 observed.push(pid);
384 }
385 }
386 }
387 Foreground::NoWindow => {}
388 Foreground::Unreadable => {
389 _unreadable.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
390 }
391 }
392 }
393 #[cfg(target_os = "macos")]
394 if enforce_owned {
395 match rightkit_qa_driver::mac::frontmost_pid() {
396 Some(pid) => {
397 let (primary, prefix) =
398 _owned.lock().unwrap_or_else(|e| e.into_inner()).clone();
399 let mut candidates = primary;
400 let mut inventory_ok = true;
401 if let Some(prefix) = prefix {
402 match pids_with_command_prefix(&prefix) {
403 Ok(pids) => candidates.extend(pids),
404 Err(_) => inventory_ok = false,
405 }
406 }
407 if !inventory_ok {
408 let mut g = seen.lock().unwrap_or_else(|e| e.into_inner());
409 g.1 += 1;
410 }
411 if candidates.into_iter().any(|p| p == pid as u32) {
412 let mut observed =
413 _owned_frontmost.lock().unwrap_or_else(|e| e.into_inner());
414 if !observed.contains(&(pid as u32)) {
415 observed.push(pid as u32);
416 }
417 }
418 }
419 None => {
420 _unreadable.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
422 let mut g = seen.lock().unwrap_or_else(|e| e.into_inner());
423 g.1 += 1;
424 }
425 }
426 }
427 };
428 record(&seen, &owned, &owned_frontmost, &unreadable_owners);
429 let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
430 let (s2, seen2, owned2, observed2, unreadable2) = (
431 stop.clone(),
432 seen.clone(),
433 owned.clone(),
434 owned_frontmost.clone(),
435 unreadable_owners.clone(),
436 );
437 let thread = std::thread::spawn(move || {
438 while !s2.load(std::sync::atomic::Ordering::SeqCst) {
439 std::thread::sleep(Duration::from_millis(20));
440 record(&seen2, &owned2, &observed2, &unreadable2);
441 }
442 });
443 FocusMonitor {
444 stop,
445 seen,
446 owned,
447 enforce_owned,
448 owned_frontmost,
449 unreadable_owners,
450 thread: Some(thread),
451 }
452 }
453 fn track_primary(&self, pid: u32) {
454 let mut owned = self.owned.lock().unwrap_or_else(|e| e.into_inner());
455 if !owned.0.contains(&pid) {
456 owned.0.push(pid);
457 }
458 }
459 #[cfg(target_os = "macos")]
460 fn track_prefix(&self, prefix: String) {
461 self.owned.lock().unwrap_or_else(|e| e.into_inner()).1 = Some(prefix);
462 }
463 fn finish(&mut self) -> (bool, Vec<String>, bool, Vec<u32>, usize, Option<String>) {
466 self.stop.store(true, std::sync::atomic::Ordering::SeqCst);
467 if let Some(t) = self.thread.take() {
468 let _ = t.join();
469 }
470 let g = self.seen.lock().unwrap_or_else(|e| e.into_inner());
471 let unchanged = g.0 > 0 && g.1 == 0 && g.2.len() == 1;
472 let observed = self
473 .owned_frontmost
474 .lock()
475 .unwrap_or_else(|e| e.into_inner())
476 .clone();
477 #[cfg(target_os = "macos")]
478 let identity_registered = self
481 .owned
482 .lock()
483 .unwrap_or_else(|e| e.into_inner())
484 .1
485 .is_some();
486 #[cfg(target_os = "macos")]
487 let owned_never_frontmost =
488 !self.enforce_owned || (identity_registered && g.1 == 0 && observed.is_empty());
489 #[cfg(windows)]
490 let owned_never_frontmost = windows_owned_never_frontmost(
491 self.enforce_owned,
492 self.unreadable_owners
493 .load(std::sync::atomic::Ordering::SeqCst),
494 &observed,
495 );
496 #[cfg(not(any(target_os = "macos", windows)))]
497 let owned_never_frontmost = !self.enforce_owned;
500 let unavailable = self
501 .unreadable_owners
502 .load(std::sync::atomic::Ordering::SeqCst);
503 #[cfg(target_os = "macos")]
504 let registered = identity_registered;
505 #[cfg(not(target_os = "macos"))]
506 let registered = true;
507 let failure = (!owned_never_frontmost)
508 .then(|| owned_failure_reason(&observed, unavailable, registered, g.1));
509 (
510 unchanged,
511 g.2.clone(),
512 owned_never_frontmost,
513 observed,
514 unavailable,
515 failure,
516 )
517 }
518}
519
520fn check_env(env: &[(String, String)]) -> Result<()> {
524 for (k, _) in env {
525 let up = k.to_ascii_uppercase();
526 if [
527 "TOKEN",
528 "SECRET",
529 "PASSWORD",
530 "PASSWD",
531 "API_KEY",
532 "APIKEY",
533 "PRIVATE_KEY",
534 "CREDENTIAL",
535 ]
536 .iter()
537 .any(|w| up.contains(w))
538 {
539 return err(format!("environment key {k} names a credential; secrets are never passed to a launched app through its command line or environment"));
540 }
541 #[cfg(target_os = "macos")]
542 if !up.starts_with("RIGHTKIT_") {
543 return err(format!("environment key {k} would be exposed in `open` arguments; macOS launches carry only RIGHTKIT_* keys"));
544 }
545 }
546 Ok(())
547}
548
549#[cfg(target_os = "macos")]
553#[doc(hidden)]
554pub fn resolve_bundle_executable(bundle: &Path) -> Option<String> {
555 let plist = bundle.join("Contents").join("Info.plist");
556 let out = Command::new("plutil")
557 .args(["-extract", "CFBundleExecutable", "raw", "-o", "-"])
558 .arg(&plist)
559 .output()
560 .ok()?;
561 if !out.status.success() {
562 return None;
563 }
564 let name = String::from_utf8(out.stdout).ok()?;
565 let name = name.trim();
566 if name.is_empty() || name == "." || name == ".." || name.contains('/') || name.contains('\0') {
567 return None;
568 }
569 let exe = bundle.join("Contents").join("MacOS").join(name);
570 let meta = fs::metadata(&exe).ok()?;
571 if !meta.is_file() {
572 return None;
573 }
574 Some(exe.to_string_lossy().into_owned())
575}
576
577#[cfg(target_os = "macos")]
578fn make_bundle(dir: &Path, binary: &Path, id: &str, background: bool) -> Result<PathBuf> {
579 let exe = binary
580 .file_name()
581 .and_then(|n| n.to_str())
582 .ok_or_else(|| Error("binary has no file name".into()))?;
583 let app = dir.join(format!("{exe}.app"));
584 let macos = app.join("Contents").join("MacOS");
585 fs::create_dir_all(&macos)?;
586 let target = macos.join(exe);
587 let _ = fs::remove_file(&target);
588 let abs = fs::canonicalize(binary)
589 .map_err(|e| Error(format!("app binary not found: {}: {e}", binary.display())))?;
590 fs::copy(&abs, &target)?;
592 let ui_element = if background {
594 "<key>LSUIElement</key><true/>\n"
595 } else {
596 ""
597 };
598 let plist = format!(
599 "<?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"
600 );
601 fs::write(app.join("Contents").join("Info.plist"), plist)?;
602 Ok(app)
603}
604
605#[cfg(target_os = "macos")]
606fn stage_bundle(spec: &LaunchSpec, ws: &QaWorkspace, id: &str) -> Result<PathBuf> {
607 let dir = ws.home.join("apps").join(id);
608 fs::create_dir_all(&dir)?;
609 if spec.binary.extension().is_some_and(|e| e == "app") {
610 let source = fs::canonicalize(&spec.binary)?;
611 let bundle = dir.join(
612 source
613 .file_name()
614 .ok_or_else(|| Error("bundle has no file name".into()))?,
615 );
616 let status = Command::new("/usr/bin/ditto")
618 .arg(&source)
619 .arg(&bundle)
620 .status()?;
621 if !status.success() {
622 return err(format!("app bundle staging failed: {status}"));
623 }
624 check_bundle_links(&bundle, &bundle)?;
625 Ok(bundle)
626 } else {
627 make_bundle(&dir, &spec.binary, &id[..8], spec.mode != Mode::Visible)
628 }
629}
630
631#[cfg(target_os = "macos")]
632fn check_bundle_links(bundle: &Path, dir: &Path) -> Result<()> {
633 for entry in fs::read_dir(dir)? {
634 let entry = entry?;
635 let path = entry.path();
636 let kind = entry.file_type()?;
637 if kind.is_symlink() {
638 if !fs::canonicalize(&path)?.starts_with(bundle) {
639 return err(format!(
640 "staged app bundle link escapes internal bundle: {}",
641 path.display()
642 ));
643 }
644 } else if kind.is_dir() {
645 check_bundle_links(bundle, &path)?;
646 }
647 }
648 Ok(())
649}
650
651fn launch_env(
652 spec: &LaunchSpec,
653 ws: &QaWorkspace,
654 suite: &Suite,
655 service: &str,
656) -> Result<Vec<(String, String)>> {
657 check_env(&spec.env)?;
660 let mut env = ws.env.clone();
661 env.insert("RIGHTKIT_CONTROL_SERVICE".into(), service.into());
662 env.insert(
663 "RIGHTKIT_SUITE_ROOT".into(),
664 suite.root().to_string_lossy().into(),
665 );
666 env.insert(
667 "RIGHTKIT_QA_HIDDEN".into(),
668 if spec.mode == Mode::Hidden { "1" } else { "0" }.into(),
669 );
670 env.insert(
671 "RIGHTKIT_QA_BACKGROUND".into(),
672 if spec.mode != Mode::Visible { "1" } else { "0" }.into(),
673 );
674 for (key, value) in &spec.env {
675 let path = Path::new(value);
676 if path.is_absolute()
677 && (!path.starts_with(&ws.home)
678 || path
679 .components()
680 .any(|c| c == std::path::Component::ParentDir))
681 {
682 return err(format!(
683 "environment path {key} must be staged inside the QA app home"
684 ));
685 }
686 if let Some(owned) = env.get(key) {
687 let app_path = ws.env.contains_key(key)
688 && key != "RIGHTKIT_SUITE_ROOT"
689 && Path::new(owned).is_absolute();
690 let internal = Path::new(value).starts_with(&ws.home)
691 && !Path::new(value)
692 .components()
693 .any(|c| c == std::path::Component::ParentDir);
694 if value != owned && !(app_path && internal) {
695 return err(format!(
696 "environment key {key} overrides a harness-owned launch value"
697 ));
698 }
699 }
700 env.insert(key.clone(), value.clone());
701 }
702 Ok(env.into_iter().collect())
703}
704
705#[cfg(target_os = "macos")]
706fn open_command(
707 spec: &LaunchSpec,
708 ws: &QaWorkspace,
709 env: &[(String, String)],
710 log_path: &Path,
711 bundle: &Path,
712) -> Command {
713 let mut cmd = Command::new("open");
714 cmd.current_dir(&ws.home);
715 match spec.mode {
716 Mode::Hidden => cmd.args(["-g", "-j", "-n"]),
717 Mode::Background => cmd.args(["-g", "-n"]),
718 Mode::Visible => cmd.args(["-n"]),
719 };
720 for (k, v) in env {
721 cmd.env(k, v).arg("--env").arg(format!("{k}={v}"));
722 }
723 cmd.arg("--stdout")
724 .arg(log_path)
725 .arg("--stderr")
726 .arg(log_path)
727 .arg(bundle);
728 cmd
729}
730
731#[cfg(target_os = "macos")]
732fn pids_with_command_prefix(prefix: &str) -> std::result::Result<Vec<u32>, String> {
735 let out = Command::new("ps")
736 .args(["-ax", "-o", "pid=,command="])
737 .output()
738 .map_err(|e| format!("ps PID inventory failed: {e}"))?;
739 if !out.status.success() {
740 return Err(format!("ps PID inventory exited with {}", out.status));
741 }
742 let mut pids = vec![];
743 for line in String::from_utf8_lossy(&out.stdout).lines() {
744 let line = line.trim_start();
745 let Some((pid, cmd)) = line.split_once(' ') else {
746 continue;
747 };
748 if cmd.trim_start().starts_with(prefix) {
749 pids.push(
750 pid.trim()
751 .parse::<u32>()
752 .map_err(|e| format!("invalid PID inventory row: {e}"))?,
753 );
754 }
755 }
756 Ok(pids)
757}
758
759pub fn launch(spec: &LaunchSpec, ws: &QaWorkspace, tracker: &Tracker) -> Result<Control> {
760 if !spec.binary.exists() {
761 return err(format!(
762 "UI app binary not found: {}",
763 spec.binary.display()
764 ));
765 }
766 let guard = crate::userdata::Guard::for_workspace(ws)?;
769 let id = new_id().replace('-', "");
770 let service = format!("control-{}", &id[..8]);
771 let suite =
773 Suite::at(ws.home.join(format!("suite-{}", &id[..8]))).map_err(|e| Error(e.to_string()))?;
774 let log_path = ws.evidence_dir.join(format!("app-{}.log", &id[..8]));
775 let log_dir = ws.home.join(".rightkit-qa");
781 fs::create_dir_all(&log_dir)?;
782 let raw_log = log_dir.join(format!("app-{}.log", &id[..8]));
783 let env = launch_env(spec, ws, &suite, &service)?;
784 guard.check_dir(&ws.home)?;
785 guard.check_env(&env)?;
786 crate::userdata::check_app_data_dir(&guard, &spec.binary, &env)?;
788 let frontmost_before = frontmost();
789 let focus = FocusMonitor::start(spec.mode != Mode::Visible);
790 let deadline = Instant::now() + spec.startup_timeout;
791
792 let (pid, child) = start_process(spec, ws, &env, &raw_log, &id, tracker, &focus, deadline)?;
795 focus.track_primary(pid);
796 tracker.register(pid, &spec.label);
797 crate::workspace::record_launched(ws, pid);
799
800 let mut control = Control {
801 pid,
802 service: service.clone(),
803 log_path,
804 raw_log,
805 suite: suite.clone(),
806 endpoint: String::new(),
807 client: Mutex::new(None),
808 child,
809 tracker: tracker.clone(),
810 frontmost_before,
811 focus: Some(focus),
812 request_timeout: Duration::from_secs(20),
813 worker: Mutex::new(None),
814 pace_ms: std::env::var("RIGHTKIT_QA_PACE_MS")
815 .ok()
816 .and_then(|v| v.trim().parse().ok())
817 .unwrap_or(0),
818 stopped: false,
819 };
820 loop {
821 let left = deadline.saturating_duration_since(Instant::now());
823 if let Some(c) = bounded_connect_health(&suite, &service, left.min(Duration::from_secs(5)))
824 {
825 control.endpoint = c.record().endpoint.clone();
826 *control.client.lock().unwrap() = Some(c);
827 break;
828 }
829 if !is_alive(pid) {
830 let tail = fs::read_to_string(&control.raw_log)
831 .map(|t| crate::util::tail(&t, 1500))
832 .unwrap_or_default();
833 return err(format!(
834 "app exited before its control server came up (pid {pid}); log tail: {tail}"
835 ));
836 }
837 if Instant::now() >= deadline {
838 let _ = control.stop();
839 return err(format!(
840 "control service did not answer within {}ms; was the app built with its control feature and launched with RIGHTKIT_CONTROL_SERVICE?{}",
841 spec.startup_timeout.as_millis(),
842 non_interactive_hint()
843 ));
844 }
845 sleep_ms(100);
846 }
847 loop {
850 if let Ok(Value::String(state)) =
852 control.eval("return document.readyState + ' ' + location.href;")
853 {
854 let (ready, href) = state.split_once(' ').unwrap_or((&state, ""));
855 if (ready == "complete" || ready == "interactive") && href != "about:blank" {
856 break;
857 }
858 }
859 if Instant::now() >= deadline {
860 let alive = is_alive(pid);
861 let _ = control.stop();
862 return err(format!(
863 "app window never became ready within {}ms (process alive: {}){}",
864 spec.startup_timeout.as_millis(),
865 alive,
866 non_interactive_hint()
867 ));
868 }
869 sleep_ms(20);
870 }
871 Ok(control)
872}
873
874fn non_interactive_hint() -> String {
878 #[cfg(windows)]
879 if let Some(why) = windows_non_interactive_reason() {
880 return format!(
881 ". This harness process is not in the interactive desktop session ({why}); a hidden WebView2 app cannot get a ready window from a non-interactive session (SSH, service). Start the run from the logged-in user's desktop session, for example a one-shot `schtasks /create /it` task (see the rightkit-qa README, Windows section)"
882 );
883 }
884 String::new()
885}
886
887#[cfg(windows)]
891fn windows_non_interactive_reason() -> Option<String> {
892 use std::ffi::c_void;
893 #[repr(C)]
894 struct UserObjectFlags {
895 inherit: i32,
896 reserved: i32,
897 flags: u32,
898 }
899 #[link(name = "user32")]
900 extern "system" {
901 fn GetProcessWindowStation() -> *mut c_void;
902 fn GetUserObjectInformationW(
903 h: *mut c_void,
904 index: i32,
905 info: *mut c_void,
906 len: u32,
907 needed: *mut u32,
908 ) -> i32;
909 }
910 #[link(name = "kernel32")]
911 extern "system" {
912 fn GetCurrentProcessId() -> u32;
913 fn ProcessIdToSessionId(pid: u32, session: *mut u32) -> i32;
914 }
915 const UOI_FLAGS: i32 = 1;
916 const WSF_VISIBLE: u32 = 1;
917 let mut session = 0u32;
918 unsafe {
920 if ProcessIdToSessionId(GetCurrentProcessId(), &mut session) != 0 && session == 0 {
921 return Some("session 0".into());
922 }
923 let station = GetProcessWindowStation();
924 if station.is_null() {
925 return None;
926 }
927 let mut info = UserObjectFlags {
928 inherit: 0,
929 reserved: 0,
930 flags: 0,
931 };
932 let mut needed = 0u32;
933 if GetUserObjectInformationW(
934 station,
935 UOI_FLAGS,
936 &mut info as *mut _ as *mut c_void,
937 std::mem::size_of::<UserObjectFlags>() as u32,
938 &mut needed,
939 ) != 0
940 && info.flags & WSF_VISIBLE == 0
941 {
942 return Some(format!("session {session}, non-visible window station"));
943 }
944 }
945 None
946}
947
948#[allow(clippy::too_many_arguments)]
949fn start_process(
950 spec: &LaunchSpec,
951 ws: &QaWorkspace,
952 env: &[(String, String)],
953 log_path: &Path,
954 id: &str,
955 _tracker: &Tracker,
956 focus: &FocusMonitor,
957 deadline: Instant,
958) -> Result<(u32, Option<OwnedChild>)> {
959 #[cfg(target_os = "macos")]
960 {
961 let bundle = stage_bundle(spec, ws, id)?;
962 let needle = format!("{}/Contents/MacOS/", bundle.display());
963 if let Some(executable) = resolve_bundle_executable(&bundle) {
967 focus.track_prefix(executable);
968 }
969 let mut cmd = open_command(spec, ws, env, log_path, &bundle);
970 let st = cmd.status()?;
971 if !st.success() {
972 return err(format!("open exited {st}"));
973 }
974 loop {
975 if let Some(pid) = pids_with_command_prefix(&needle)
976 .ok()
977 .and_then(|pids| pids.into_iter().next())
978 {
979 return Ok((pid, None));
980 }
981 if Instant::now() >= deadline {
982 return err("launched app pid not found");
983 }
984 sleep_ms(100);
985 }
986 }
987 #[cfg(not(target_os = "macos"))]
988 {
989 let _ = (ws, id);
990 let log = fs::File::create(log_path)?;
991 let log2 = log.try_clone()?;
992 let mut cmd = Command::new(&spec.binary);
993 cmd.current_dir(&ws.home)
994 .stdin(Stdio::null())
995 .stdout(Stdio::from(log))
996 .stderr(Stdio::from(log2));
997 for (k, v) in env {
998 cmd.env(k, v);
999 }
1000 let mut owned = OwnedCommand::from_command(cmd);
1001 if spec.mode != Mode::Visible {
1002 owned.windows_hide();
1003 }
1004 let _ = deadline;
1005 let child = owned
1006 .spawn()
1007 .map_err(|e| Error(format!("failed to start {}: {e}", spec.binary.display())))?;
1008 focus.track_primary(child.id());
1010 Ok((child.id(), Some(child)))
1011 }
1012}
1013
1014impl Control {
1015 pub fn assert_no_permission_dialog(&self) -> Result<()> {
1021 match crate::userdata::live_permission_dialog_check(self.pid) {
1022 crate::userdata::DialogCheck::Dialog(owner) => err(format!(
1023 "a system permission dialog ({owner}) appeared for the app under test"
1024 )),
1025 _ => Ok(()),
1026 }
1027 }
1028
1029 pub fn set_request_timeout(&mut self, t: Duration) {
1030 self.request_timeout = t;
1031 }
1032
1033 fn rpc(&self, method: &str, params: Value) -> Result<Value> {
1038 self.rpc_within(method, params, self.request_timeout)
1039 }
1040
1041 fn rpc_within(&self, method: &str, params: Value, request_timeout: Duration) -> Result<Value> {
1042 let replayable = matches!(method, "health" | "dom" | "ax" | "screenshot" | "move");
1043 let mut g = self.client.lock().unwrap();
1044 for attempt in 0..2 {
1045 let client = match g.as_ref() {
1046 Some(c) => c.clone(),
1047 None => {
1048 let c = Arc::new(connect_bounded(
1050 &self.suite,
1051 &self.service,
1052 request_timeout,
1053 )?);
1054 *g = Some(c.clone());
1055 c
1056 }
1057 };
1058 match call_bounded(
1059 &client,
1060 &self.worker,
1061 method,
1062 params.clone(),
1063 request_timeout,
1064 ) {
1065 Ok(v) => return Ok(v),
1066 Err(CallError::TimedOut) => {
1067 *g = None;
1068 return err(format!(
1069 "{method}: no reply within {}ms; connection aborted",
1070 request_timeout.as_millis()
1071 ));
1072 }
1073 Err(CallError::Service(e)) if e.retryable && replayable && attempt == 0 => {
1074 *g = None
1075 }
1076 Err(CallError::Service(e)) => {
1077 if e.retryable {
1078 *g = None;
1079 }
1080 let mut m = svc_err(e);
1081 if !replayable {
1082 m = Error(format!("{} (not replayed: {method} has effects and the first attempt may have been applied)", m.0));
1083 }
1084 return Err(m);
1085 }
1086 }
1087 }
1088 err("control rpc failed")
1089 }
1090
1091 fn json(&self, method: &str, params: Value) -> Result<Value> {
1092 let v = self.rpc(method, params)?;
1093 Self::ok_or_error(method, v)
1094 }
1095
1096 fn ok_or_error(method: &str, v: Value) -> Result<Value> {
1097 if v.get("ok") == Some(&Value::Bool(false)) {
1098 return err(format!(
1099 "{method}: {}",
1100 v.get("error")
1101 .and_then(Value::as_str)
1102 .unwrap_or("unknown error")
1103 ));
1104 }
1105 Ok(v)
1106 }
1107
1108 fn paced(&self, mut p: Value) -> Value {
1110 if self.pace_ms > 0 {
1111 p["paceMs"] = json!(self.pace_ms);
1112 }
1113 p
1114 }
1115 pub fn set_pace_ms(&mut self, ms: u64) {
1119 self.pace_ms = ms;
1120 }
1121 pub fn batch(&self, steps: &[(&str, Value)]) -> Result<Vec<Value>> {
1125 let steps: Vec<Value> = steps
1126 .iter()
1127 .map(|(m, p)| {
1128 let p = if matches!(*m, "click" | "drag" | "key" | "type") {
1129 self.paced(p.clone())
1130 } else {
1131 p.clone()
1132 };
1133 json!({"method": m, "params": p})
1134 })
1135 .collect();
1136 let budget = self.request_timeout * (steps.len().max(1) as u32).min(30);
1138 let v = self.rpc_within("batch", json!({"steps": steps}), budget)?;
1139 let v = Self::ok_or_error("batch", v)?;
1140 Ok(v.get("results")
1141 .and_then(Value::as_array)
1142 .cloned()
1143 .unwrap_or_default())
1144 }
1145
1146 pub fn health(&self) -> Result<Value> {
1147 self.rpc("health", json!({}))
1148 }
1149 pub fn set_viewport(&self, width: u32, height: u32) -> Result<(u32, u32)> {
1152 let v = self.json("set_viewport", json!({"width": width, "height": height}))?;
1153 let dimension = |key: &str| {
1154 v.get(key)
1155 .and_then(Value::as_u64)
1156 .and_then(|n| u32::try_from(n).ok())
1157 .filter(|n| *n > 0)
1158 .ok_or_else(|| Error(format!("set_viewport returned invalid {key}")))
1159 };
1160 Ok((dimension("innerWidth")?, dimension("innerHeight")?))
1161 }
1162 pub fn unlisted_app_probe(&self) -> Result<String> {
1164 match Client::connect(&self.suite, &self.service, "not-allowlisted-probe") {
1165 Ok(_) => err("an unlisted app was accepted by the control service"),
1166 Err(e) => Ok(e.code),
1167 }
1168 }
1169 pub fn click(&self, x: f64, y: f64, button: &str, count: u32) -> Result<()> {
1170 self.json(
1171 "click",
1172 self.paced(json!({"x": x, "y": y, "button": button, "count": count})),
1173 )
1174 .map(|_| ())
1175 }
1176 pub fn click_with(
1179 &self,
1180 x: f64,
1181 y: f64,
1182 button: &str,
1183 count: u32,
1184 modifiers: &[&str],
1185 ) -> Result<()> {
1186 self.json(
1187 "click",
1188 self.paced(
1189 json!({"x": x, "y": y, "button": button, "count": count, "modifiers": modifiers}),
1190 ),
1191 )
1192 .map(|_| ())
1193 }
1194 pub fn move_to(&self, x: f64, y: f64) -> Result<()> {
1195 self.json("move", json!({"x": x, "y": y})).map(|_| ())
1196 }
1197 pub fn pointer(
1201 &self,
1202 phase: &str,
1203 x: f64,
1204 y: f64,
1205 button: &str,
1206 modifiers: &[&str],
1207 ) -> Result<()> {
1208 self.json(
1209 "pointer",
1210 json!({"phase": phase, "x": x, "y": y, "button": button, "modifiers": modifiers}),
1211 )
1212 .map(|_| ())
1213 }
1214 pub fn drag(&self, from: (f64, f64), to: (f64, f64), steps: u32) -> Result<()> {
1215 self.json(
1216 "drag",
1217 self.paced(json!({"x1": from.0, "y1": from.1, "x2": to.0, "y2": to.1, "steps": steps})),
1218 )
1219 .map(|_| ())
1220 }
1221 pub fn wheel(&self, x: f64, y: f64, dx: f64, dy: f64) -> Result<()> {
1222 self.json("wheel", json!({"x": x, "y": y, "dx": dx, "dy": dy}))
1223 .map(|_| ())
1224 }
1225 pub fn key(&self, spec: &str) -> Result<()> {
1226 self.json("key", self.paced(json!({"key": spec})))
1227 .map(|_| ())
1228 }
1229 pub fn type_text(&self, text: &str) -> Result<()> {
1230 self.json("type", self.paced(json!({"text": text})))
1231 .map(|_| ())
1232 }
1233 pub fn eval(&self, js: &str) -> Result<Value> {
1235 Ok(self
1236 .json("eval", json!({"js": js}))?
1237 .get("value")
1238 .cloned()
1239 .unwrap_or(Value::Null))
1240 }
1241 pub fn dom(&self, selector: &str) -> Result<Vec<Value>> {
1242 let v = self.json("dom", json!({"selector": selector}))?;
1243 Ok(v.get("elements")
1244 .and_then(Value::as_array)
1245 .cloned()
1246 .unwrap_or_default())
1247 }
1248 pub fn ax(&self) -> Result<Vec<Value>> {
1249 let v = self.json("ax", json!({}))?;
1250 Ok(v.get("nodes")
1251 .and_then(Value::as_array)
1252 .cloned()
1253 .unwrap_or_default())
1254 }
1255 pub fn screenshot_png(&self) -> Result<Vec<u8>> {
1256 let v = self.json("screenshot", json!({"inline": true}))?;
1257 if v.get("masked").and_then(Value::as_u64).is_none() {
1260 return err("control server does not redact screenshots; capture refused");
1261 }
1262 let body = match v.get("data").and_then(Value::as_str) {
1263 Some(b64) => crate::util::base64_decode(b64)
1264 .ok_or_else(|| Error("screenshot data is not valid base64".into()))?,
1265 None => {
1267 let path = PathBuf::from(
1268 v.get("path")
1269 .and_then(Value::as_str)
1270 .ok_or_else(|| Error("screenshot returned no data or path".into()))?,
1271 );
1272 let body = fs::read(&path)?;
1273 let _ = fs::remove_file(&path);
1274 body
1275 }
1276 };
1277 if body.len() < 8 || &body[1..4] != b"PNG" {
1278 return err("screenshot method did not return a PNG");
1279 }
1280 Ok(body)
1281 }
1282 pub fn screenshot_to(&self, path: &Path) -> Result<()> {
1283 if let Some(p) = path.parent() {
1284 fs::create_dir_all(p)?;
1285 }
1286 fs::write(path, self.screenshot_png()?)?;
1287 Ok(())
1288 }
1289 pub fn command(&self, name: &str, args: &Value) -> Result<Value> {
1292 let args = match args {
1293 Value::String(s) => s.clone(),
1294 Value::Null => String::new(),
1295 other => other.to_string(),
1296 };
1297 let v = self.json("command", json!({"name": name, "args": args}))?;
1298 Ok(v.get("result").cloned().unwrap_or(Value::Null))
1299 }
1300
1301 pub fn center_of(&self, selector: &str) -> Result<(f64, f64)> {
1303 let els = self.dom(selector)?;
1304 let first = els
1305 .first()
1306 .ok_or_else(|| Error(format!("selector matched no element: {selector}")))?;
1307 let r: Vec<f64> = first
1308 .get("rect")
1309 .and_then(Value::as_array)
1310 .map(|a| a.iter().filter_map(Value::as_f64).collect())
1311 .unwrap_or_default();
1312 if r.len() < 4 || r[2] <= 0.0 || r[3] <= 0.0 {
1313 return err(format!("{selector} has no visible box"));
1314 }
1315 Ok((r[0] + r[2] / 2.0, r[1] + r[3] / 2.0))
1316 }
1317 pub fn click_selector(&self, selector: &str) -> Result<()> {
1318 let (x, y) = self.center_of(selector)?;
1319 self.click(x, y, "left", 1)
1320 }
1321
1322 pub fn wait_eval(&self, js: &str, timeout: Duration) -> Result<Value> {
1326 let ms = timeout.as_millis().clamp(1, 600_000) as u64;
1327 let reply = self.rpc_within(
1328 "wait",
1329 json!({"js": js, "timeoutMs": ms}),
1330 timeout + self.request_timeout,
1331 );
1332 match reply {
1333 Ok(v) => {
1334 let v = Self::ok_or_error("wait", v)?;
1335 if v.get("matched") == Some(&Value::Bool(true)) {
1336 return Ok(v.get("value").cloned().unwrap_or(Value::Null));
1337 }
1338 let last = match v.get("error") {
1339 Some(e) if !e.is_null() => e.clone(),
1340 _ => v.get("last").cloned().unwrap_or(Value::Null),
1341 };
1342 err(format!("timeout waiting for `{js}` (last: {last})"))
1343 }
1344 Err(e) if wait_unsupported(&e.0) => self.wait_eval_polling(js, timeout),
1345 Err(e) => Err(e),
1346 }
1347 }
1348
1349 fn wait_eval_polling(&self, js: &str, timeout: Duration) -> Result<Value> {
1350 let deadline = Instant::now() + timeout;
1351 let mut last;
1352 loop {
1353 match self.eval(js) {
1354 Ok(v) => {
1355 let truthy = !matches!(v, Value::Null | Value::Bool(false))
1356 && v != json!(0)
1357 && v != json!("");
1358 if truthy {
1359 return Ok(v);
1360 }
1361 last = v;
1362 }
1363 Err(e) => last = json!(e.0),
1364 }
1365 if Instant::now() >= deadline {
1366 return err(format!("timeout waiting for `{js}` (last: {last})"));
1367 }
1368 sleep_ms(25);
1369 }
1370 }
1371
1372 pub fn wait_for_text(
1375 &self,
1376 selector: Option<&str>,
1377 text: &str,
1378 timeout: Duration,
1379 ) -> Result<()> {
1380 let sel = serde_json::to_string(selector.unwrap_or("body")).unwrap_or_default();
1381 let needle = serde_json::to_string(text).unwrap_or_default();
1382 let js = format!(
1383 "return ((document.querySelector({sel}) || {{}}).innerText || '').includes({needle});"
1384 );
1385 self.wait_eval(&js, timeout).map(|_| ())
1386 }
1387
1388 pub fn stop(&mut self) -> Result<StopReport> {
1391 if self.stopped {
1392 return err("control session already stopped");
1393 }
1394 self.stopped = true;
1395 if let Some(mut c) = self.child.take() {
1396 let _ = c.terminate_tree();
1397 }
1398 kill_tree(self.pid);
1399 let deadline = Instant::now() + Duration::from_secs(5);
1400 while Instant::now() < deadline && (is_alive(self.pid) || endpoint_open(&self.endpoint)) {
1401 sleep_ms(50);
1402 }
1403 let log_copy = fs::copy(&self.raw_log, &self.log_path);
1406 let after = frontmost();
1407 let (
1408 steady,
1409 states,
1410 owned_never_frontmost,
1411 owned_frontmost_pids,
1412 frontmost_pid_unavailable_samples,
1413 owned_never_frontmost_failure,
1414 ) = self.focus.take().map(|mut f| f.finish()).unwrap_or((
1415 false,
1416 vec![],
1417 false,
1418 vec![],
1419 0,
1420 Some("no focus monitor ran".to_string()),
1421 ));
1422 let process_gone = !is_alive(self.pid);
1423 let endpoint_closed = self.endpoint.is_empty() || !endpoint_open(&self.endpoint);
1424 if process_gone {
1425 self.tracker.forget(self.pid);
1426 }
1427 log_copy.map_err(|e| {
1428 Error(format!(
1429 "cannot preserve app log {}: {e}",
1430 self.raw_log.display()
1431 ))
1432 })?;
1433 Ok(StopReport {
1434 pid: self.pid,
1435 endpoint_closed,
1436 process_gone,
1437 frontmost_unchanged: steady
1438 && self.frontmost_before.is_some()
1439 && self.frontmost_before == after,
1440 frontmost_states: states,
1441 frontmost_before: self.frontmost_before.clone(),
1442 frontmost_after: after,
1443 owned_never_frontmost,
1444 owned_frontmost_pids,
1445 frontmost_pid_unavailable_samples,
1446 owned_never_frontmost_failure,
1447 })
1448 }
1449}
1450
1451impl Drop for Control {
1452 fn drop(&mut self) {
1453 if !self.stopped {
1454 let _ = self.stop();
1455 }
1456 }
1457}
1458
1459#[cfg(test)]
1460mod launch_path_tests {
1461 use super::*;
1462
1463 #[test]
1464 fn windows_foreground_is_classified_without_a_windows_host() {
1465 assert_eq!(classify_foreground(false, 0, 0), Foreground::NoWindow);
1466 assert_eq!(classify_foreground(true, 7, 4242), Foreground::Pid(4242));
1467 assert_eq!(classify_foreground(true, 0, 0), Foreground::Unreadable);
1469 assert_eq!(classify_foreground(true, 7, 0), Foreground::Unreadable);
1470 assert_eq!(classify_foreground(true, 0, 4242), Foreground::Unreadable);
1471 }
1472
1473 #[test]
1474 fn background_failure_reason_tells_frontmost_from_unreadable() {
1475 let r = owned_failure_reason(&[42], 3, true, 3);
1476 assert!(r.contains("was frontmost") && r.contains("42"), "{r}");
1477 let r = owned_failure_reason(&[], 2, true, 2);
1478 assert!(r.contains("pid could not be read in 2"), "{r}");
1479 assert!(!r.contains("was frontmost"), "{r}");
1480 assert!(owned_failure_reason(&[], 0, false, 0).contains("never registered"));
1481 assert!(owned_failure_reason(&[], 0, true, 4).contains("unmeasurable"));
1482 }
1483
1484 #[test]
1485 fn windows_background_invariant_fails_closed() {
1486 assert!(windows_owned_never_frontmost(false, 3, &[9]));
1488 assert!(windows_owned_never_frontmost(true, 0, &[]));
1490 assert!(!windows_owned_never_frontmost(true, 0, &[9]));
1491 assert!(!windows_owned_never_frontmost(true, 1, &[]));
1492 }
1493
1494 fn fixture() -> (QaWorkspace, LaunchSpec) {
1495 let root = crate::storage::app_temp_dir().join(format!("rkqa-launch-{}", new_id()));
1496 let ws = crate::workspace::create(&root, None, "control-test-app").unwrap();
1497 let binary = ws.root.join("control-test-app");
1498 fs::write(&binary, "fixture binary").unwrap();
1499 let spec = LaunchSpec {
1500 binary,
1501 mode: Mode::Hidden,
1502 env: vec![],
1503 startup_timeout: Duration::from_secs(90),
1504 label: "fixture".into(),
1505 };
1506 (ws, spec)
1507 }
1508
1509 fn cleanup(ws: &QaWorkspace) {
1510 let root = ws
1511 .root
1512 .parent()
1513 .unwrap()
1514 .parent()
1515 .unwrap()
1516 .parent()
1517 .unwrap()
1518 .to_path_buf();
1519 let report = crate::workspace::dispose(ws, false, 1, 0);
1520 assert!(report.retained.is_empty(), "{report:?}");
1521 fs::remove_dir_all(root).unwrap();
1522 }
1523
1524 #[test]
1525 fn launch_environment_keeps_all_owned_paths_internal_and_rejects_external_overrides() {
1526 let (ws, mut spec) = fixture();
1527 let suite = Suite::at(ws.home.join("suite-12345678")).unwrap();
1528 let env: std::collections::BTreeMap<_, _> =
1529 launch_env(&spec, &ws, &suite, "control-12345678")
1530 .unwrap()
1531 .into_iter()
1532 .collect();
1533 for (key, value) in &env {
1534 if Path::new(value).is_absolute() {
1535 assert!(Path::new(value).starts_with(&ws.home), "{key}={value}");
1536 assert!(!Path::new(value).starts_with(&ws.root), "{key}={value}");
1537 }
1538 }
1539 assert_eq!(env["HOME"], ws.home.to_string_lossy());
1540 assert_eq!(env["TMPDIR"], ws.home.join("tmp").to_string_lossy());
1541 assert_eq!(env["RIGHTKIT_SUITE_ROOT"], suite.root().to_string_lossy());
1542 assert_eq!(env["RIGHTKIT_QA_HIDDEN"], "1");
1543 assert_eq!(env["RIGHTKIT_QA_BACKGROUND"], "1");
1544 #[cfg(target_os = "macos")]
1547 assert!(
1548 Path::new(&env["TMPDIR"])
1549 .join("rk-12345678-control-12345678.sock")
1550 .as_os_str()
1551 .len()
1552 <= 100
1553 );
1554 assert!(suite
1555 .service_dir("control-12345678")
1556 .join("service.json")
1557 .starts_with(&ws.home));
1558 for key in [
1559 "RIGHTKIT_SUITE_ROOT",
1560 "RIGHTKIT_QA_DATA_DIR",
1561 "RIGHTKIT_RUN_ROOT",
1562 ] {
1563 spec.env = vec![(
1564 key.into(),
1565 ws.root.join("external").to_string_lossy().into(),
1566 )];
1567 assert!(
1568 launch_env(&spec, &ws, &suite, "control-12345678").is_err(),
1569 "{key}"
1570 );
1571 }
1572 spec.env = vec![("RIGHTKIT_SECRET".into(), "redacted".into())];
1573 assert!(launch_env(&spec, &ws, &suite, "control-12345678").is_err());
1574 cleanup(&ws);
1575 }
1576
1577 #[cfg(target_os = "macos")]
1578 #[test]
1579 fn raw_binary_and_supplied_bundle_are_staged_inside_app_home_before_open() {
1580 let (ws, mut spec) = fixture();
1581 let id = "1234567890abcdef";
1582 let bundle = stage_bundle(&spec, &ws, id).unwrap();
1583 assert!(bundle.starts_with(ws.home.join("apps")));
1584 assert!(!bundle.starts_with(&ws.root));
1585 assert_eq!(
1586 fs::read(bundle.join("Contents/MacOS/control-test-app")).unwrap(),
1587 b"fixture binary"
1588 );
1589 let plist = fs::read_to_string(bundle.join("Contents/Info.plist")).unwrap();
1590 assert!(plist.contains("<key>LSUIElement</key><true/>"));
1591 let suite = Suite::at(ws.home.join("suite-12345678")).unwrap();
1592 let env = launch_env(&spec, &ws, &suite, "control-12345678").unwrap();
1593 let log = ws.home.join("app-12345678.log");
1594 let cmd = open_command(&spec, &ws, &env, &log, &bundle);
1595 assert_eq!(cmd.get_current_dir(), Some(ws.home.as_path()));
1596 let args: Vec<_> = cmd
1597 .get_args()
1598 .map(|v| v.to_string_lossy().into_owned())
1599 .collect();
1600 assert_eq!(&args[..3], &["-g", "-j", "-n"]);
1601 for (key, value) in &env {
1602 assert!(
1603 args.windows(2)
1604 .any(|pair| pair[0] == "--env" && pair[1] == format!("{key}={value}")),
1605 "{key}"
1606 );
1607 }
1608 for flag in ["--stdout", "--stderr"] {
1609 assert!(args
1610 .windows(2)
1611 .any(|pair| pair[0] == flag && Path::new(&pair[1]) == log));
1612 }
1613 assert_eq!(args.last().unwrap(), &bundle.to_string_lossy());
1614 spec.binary = make_bundle(&ws.root, &spec.binary, "original", true).unwrap();
1616 fs::create_dir_all(spec.binary.join("Contents/Resources")).unwrap();
1617 fs::write(
1618 spec.binary.join("Contents/Resources/fixture.txt"),
1619 "resource",
1620 )
1621 .unwrap();
1622 let supplied = stage_bundle(&spec, &ws, "fedcba0987654321").unwrap();
1623 assert!(supplied.starts_with(&ws.home));
1624 assert_ne!(supplied, spec.binary);
1625 assert_eq!(
1626 fs::read_to_string(supplied.join("Contents/Resources/fixture.txt")).unwrap(),
1627 "resource"
1628 );
1629 std::os::unix::fs::symlink(
1630 ws.root.join("control-test-app"),
1631 spec.binary.join("Contents/Resources/external"),
1632 )
1633 .unwrap();
1634 assert!(stage_bundle(&spec, &ws, "0123456789abcdef")
1635 .unwrap_err()
1636 .0
1637 .contains("escapes internal bundle"));
1638 cleanup(&ws);
1639 }
1640}