Skip to main content

rightkit_qa/
control.rs

1//! Drive the real Tauri app through the in-app `rightkit-qa-driver` server.
2//!
3//! The app is launched hidden or backgrounded with `RIGHTKIT_CONTROL_SERVICE` and an
4//! isolated `RIGHTKIT_SUITE_ROOT`, discovers the `rightkit-daemon` record
5//! (`service.json`), and talks over the current-user-only socket / named pipe as the
6//! allowlisted app `right-qa`. No TCP and no shared secrets. The harness never searches for
7//! processes by name to kill them: it records the pid it started and kills that
8//! tree only.
9use 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    /// Window never shown (`open -g -j -n` on macOS, hidden window on Windows).
28    Hidden,
29    /// Window shown but the app is not activated and frontmost is unchanged.
30    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    /// A `.app` bundle, or a plain executable (wrapped into a throwaway bundle on macOS).
50    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    /// Every distinct foreground state sampled during the session (empty when unmeasurable).
66    pub frontmost_states: Vec<String>,
67    /// Every sampled foreground PID belonged to no app process started by this control session.
68    /// This is the background invariant; `frontmost_unchanged` remains diagnostic only.
69    /// macOS checks the started pid and every process under the bundle executable; Windows
70    /// checks the started pid only (the owner of the foreground window, from
71    /// `GetForegroundWindow` + `GetWindowThreadProcessId`, sampled every 20 ms). Both fail
72    /// closed when a foreground owner exists but cannot be read. Other targets always report
73    /// `false` for a non-visible launch.
74    pub owned_never_frontmost: bool,
75    /// Owned fixture PIDs observed in foreground, if any.
76    pub owned_frontmost_pids: Vec<u32>,
77}
78
79impl StopReport {
80    /// The first sampled foreground name that belongs to a system permission-dialog agent.
81    /// Positive evidence only: `None` does not prove no dialog appeared.
82    pub fn permission_dialog(&self) -> Option<String> {
83        crate::userdata::permission_dialog_in(&self.frontmost_states)
84    }
85}
86
87pub struct Control {
88    pub pid: u32,
89    /// `rightkit-daemon` name the app serves (`control-xxxxxxxx`).
90    pub service: String,
91    pub log_path: PathBuf,
92    raw_log: PathBuf,
93    suite: Suite,
94    endpoint: String,
95    client: Mutex<Option<Arc<Client>>>,
96    child: Option<OwnedChild>,
97    tracker: Tracker,
98    frontmost_before: Option<String>,
99    focus: Option<FocusMonitor>,
100    request_timeout: Duration,
101    stopped: bool,
102}
103
104const APP: &str = "right-qa";
105
106fn svc_err(e: ServiceError) -> Error {
107    Error(e.to_string())
108}
109
110enum CallError {
111    TimedOut,
112    Service(ServiceError),
113}
114
115/// Run `f` on a helper thread and give up after `timeout`; the thread is left to finish
116/// on its own (it ends when the app is killed or the connection is aborted).
117fn bounded<T: Send + 'static>(
118    timeout: Duration,
119    f: impl FnOnce() -> T + Send + 'static,
120) -> Option<T> {
121    let (tx, rx) = std::sync::mpsc::channel();
122    std::thread::spawn(move || {
123        let _ = tx.send(f());
124    });
125    rx.recv_timeout(timeout).ok()
126}
127
128fn call_bounded(
129    client: &Arc<Client>,
130    method: &str,
131    params: Value,
132    timeout: Duration,
133) -> std::result::Result<Value, CallError> {
134    let c = client.clone();
135    let m = method.to_string();
136    match bounded(timeout, move || c.call(&m, params)) {
137        Some(r) => r.map_err(CallError::Service),
138        None => {
139            client.abort();
140            Err(CallError::TimedOut)
141        }
142    }
143}
144
145fn connect_bounded(suite: &Suite, service: &str, timeout: Duration) -> Result<Client> {
146    let (s, sv) = (suite.clone(), service.to_string());
147    match bounded(timeout, move || Client::connect(&s, &sv, APP)) {
148        Some(r) => r.map_err(svc_err),
149        None => err(format!(
150            "connecting to {service} did not finish within {}ms",
151            timeout.as_millis()
152        )),
153    }
154}
155
156fn bounded_connect_health(suite: &Suite, service: &str, timeout: Duration) -> Option<Arc<Client>> {
157    let (s, sv) = (suite.clone(), service.to_string());
158    bounded(timeout, move || {
159        let c = Client::connect(&s, &sv, APP).ok()?;
160        c.call("health", json!({})).ok()?;
161        Some(Arc::new(c))
162    })
163    .flatten()
164}
165
166fn endpoint_open(endpoint: &str) -> bool {
167    rightkit_daemon::probe_endpoint(endpoint)
168}
169
170/// Foreground app as `name` (macOS) or `pid:hwnd` (Windows). `None` means the
171/// foreground could NOT be measured; it is never treated as "unchanged".
172pub fn frontmost() -> Option<String> {
173    #[cfg(target_os = "macos")]
174    {
175        rightkit_qa_driver::mac::frontmost_name()
176    }
177    #[cfg(windows)]
178    {
179        use windows_sys::Win32::UI::WindowsAndMessaging::{
180            GetForegroundWindow, GetWindowThreadProcessId,
181        };
182        unsafe {
183            let h = GetForegroundWindow();
184            if h.is_null() {
185                return None;
186            }
187            let mut pid = 0u32;
188            if GetWindowThreadProcessId(h, &mut pid) == 0 || pid == 0 {
189                return None;
190            }
191            Some(format!("{pid}:{:x}", h as usize))
192        }
193    }
194    #[cfg(not(any(target_os = "macos", windows)))]
195    {
196        None
197    }
198}
199
200/// What the Windows foreground query found.
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202#[cfg_attr(not(windows), allow(dead_code))]
203enum Foreground {
204    /// No foreground window at all (Windows reports this transiently, and on a desktop
205    /// with no active window). Nothing we own can be in front, so this is a measurement.
206    NoWindow,
207    /// The process that owns the foreground window.
208    Pid(u32),
209    /// A foreground window exists but its owner could not be read.
210    Unreadable,
211}
212
213/// Interpret `GetForegroundWindow` / `GetWindowThreadProcessId` results.
214#[cfg_attr(not(windows), allow(dead_code))]
215fn classify_foreground(window_present: bool, thread_id: u32, pid: u32) -> Foreground {
216    if !window_present {
217        Foreground::NoWindow
218    } else if thread_id == 0 || pid == 0 {
219        Foreground::Unreadable
220    } else {
221        Foreground::Pid(pid)
222    }
223}
224
225#[cfg(windows)]
226fn foreground_pid() -> Foreground {
227    use windows_sys::Win32::UI::WindowsAndMessaging::{
228        GetForegroundWindow, GetWindowThreadProcessId,
229    };
230    unsafe {
231        let h = GetForegroundWindow();
232        if h.is_null() {
233            return classify_foreground(false, 0, 0);
234        }
235        let mut pid = 0u32;
236        let thread_id = GetWindowThreadProcessId(h, &mut pid);
237        classify_foreground(true, thread_id, pid)
238    }
239}
240
241/// The Windows background invariant: no sample found a process this session started in
242/// the foreground, and every foreground owner that existed could be read. Process
243/// identity is the started pid only; helper processes (WebView2 renderers) own no
244/// top-level window, so they cannot be the foreground window's owner.
245#[cfg_attr(not(windows), allow(dead_code))]
246fn windows_owned_never_frontmost(
247    enforce_owned: bool,
248    unreadable_owners: usize,
249    observed: &[u32],
250) -> bool {
251    !enforce_owned || (unreadable_owners == 0 && observed.is_empty())
252}
253
254/// Samples the foreground on its own thread for the whole life of a control session, so
255/// a transient steal (focus leaves and returns) is caught, and an unmeasurable
256/// foreground is reported as such instead of comparing equal.
257struct FocusMonitor {
258    stop: Arc<std::sync::atomic::AtomicBool>,
259    seen: Arc<Mutex<(usize, usize, Vec<String>)>>, // (measured, unavailable, distinct states in order)
260    owned: Arc<Mutex<(Vec<u32>, Option<String>)>>, // primary PID(s), exact executable prefix
261    enforce_owned: bool,
262    owned_frontmost: Arc<Mutex<Vec<u32>>>,
263    /// Foreground samples whose owning pid could not be read (Windows).
264    #[cfg_attr(not(windows), allow(dead_code))]
265    unreadable_owners: Arc<std::sync::atomic::AtomicUsize>,
266    thread: Option<std::thread::JoinHandle<()>>,
267}
268
269impl FocusMonitor {
270    fn start(enforce_owned: bool) -> FocusMonitor {
271        let seen: Arc<Mutex<(usize, usize, Vec<String>)>> = Arc::new(Mutex::new((0, 0, vec![])));
272        let owned: Arc<Mutex<(Vec<u32>, Option<String>)>> = Arc::new(Mutex::new((vec![], None)));
273        let owned_frontmost = Arc::new(Mutex::new(vec![]));
274        let unreadable_owners = Arc::new(std::sync::atomic::AtomicUsize::new(0));
275        let record = move |seen: &Mutex<(usize, usize, Vec<String>)>,
276                           _owned: &Mutex<(Vec<u32>, Option<String>)>,
277                           _owned_frontmost: &Mutex<Vec<u32>>,
278                           _unreadable: &std::sync::atomic::AtomicUsize| {
279            {
280                let mut g = seen.lock().unwrap_or_else(|e| e.into_inner());
281                match frontmost() {
282                    Some(f) => {
283                        g.0 += 1;
284                        if g.2.last() != Some(&f) {
285                            g.2.push(f);
286                        }
287                    }
288                    None => g.1 += 1,
289                }
290            }
291            #[cfg(windows)]
292            if enforce_owned {
293                match foreground_pid() {
294                    Foreground::Pid(pid) => {
295                        let owned = _owned.lock().unwrap_or_else(|e| e.into_inner()).0.clone();
296                        if owned.contains(&pid) {
297                            let mut observed =
298                                _owned_frontmost.lock().unwrap_or_else(|e| e.into_inner());
299                            if !observed.contains(&pid) {
300                                observed.push(pid);
301                            }
302                        }
303                    }
304                    Foreground::NoWindow => {}
305                    Foreground::Unreadable => {
306                        _unreadable.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
307                    }
308                }
309            }
310            #[cfg(target_os = "macos")]
311            if enforce_owned {
312                match rightkit_qa_driver::mac::frontmost_pid() {
313                    Some(pid) => {
314                        let (primary, prefix) =
315                            _owned.lock().unwrap_or_else(|e| e.into_inner()).clone();
316                        let mut candidates = primary;
317                        let mut inventory_ok = true;
318                        if let Some(prefix) = prefix {
319                            match pids_with_command_prefix(&prefix) {
320                                Ok(pids) => candidates.extend(pids),
321                                Err(_) => inventory_ok = false,
322                            }
323                        }
324                        if !inventory_ok {
325                            let mut g = seen.lock().unwrap_or_else(|e| e.into_inner());
326                            g.1 += 1;
327                        }
328                        if candidates.into_iter().any(|p| p == pid as u32) {
329                            let mut observed =
330                                _owned_frontmost.lock().unwrap_or_else(|e| e.into_inner());
331                            if !observed.contains(&(pid as u32)) {
332                                observed.push(pid as u32);
333                            }
334                        }
335                    }
336                    None => {
337                        // An unmeasurable PID makes the strict background proof fail.
338                        let mut g = seen.lock().unwrap_or_else(|e| e.into_inner());
339                        g.1 += 1;
340                    }
341                }
342            }
343        };
344        record(&seen, &owned, &owned_frontmost, &unreadable_owners);
345        let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
346        let (s2, seen2, owned2, observed2, unreadable2) = (
347            stop.clone(),
348            seen.clone(),
349            owned.clone(),
350            owned_frontmost.clone(),
351            unreadable_owners.clone(),
352        );
353        let thread = std::thread::spawn(move || {
354            while !s2.load(std::sync::atomic::Ordering::SeqCst) {
355                std::thread::sleep(Duration::from_millis(20));
356                record(&seen2, &owned2, &observed2, &unreadable2);
357            }
358        });
359        FocusMonitor {
360            stop,
361            seen,
362            owned,
363            enforce_owned,
364            owned_frontmost,
365            unreadable_owners,
366            thread: Some(thread),
367        }
368    }
369    fn track_primary(&self, pid: u32) {
370        let mut owned = self.owned.lock().unwrap_or_else(|e| e.into_inner());
371        if !owned.0.contains(&pid) {
372            owned.0.push(pid);
373        }
374    }
375    #[cfg(target_os = "macos")]
376    fn track_prefix(&self, prefix: String) {
377        self.owned.lock().unwrap_or_else(|e| e.into_inner()).1 = Some(prefix);
378    }
379    /// `(unchanged, states, owned_never_frontmost, owned_frontmost_pids)`.
380    fn finish(&mut self) -> (bool, Vec<String>, bool, Vec<u32>) {
381        self.stop.store(true, std::sync::atomic::Ordering::SeqCst);
382        if let Some(t) = self.thread.take() {
383            let _ = t.join();
384        }
385        let g = self.seen.lock().unwrap_or_else(|e| e.into_inner());
386        let unchanged = g.0 > 0 && g.1 == 0 && g.2.len() == 1;
387        let observed = self
388            .owned_frontmost
389            .lock()
390            .unwrap_or_else(|e| e.into_inner())
391            .clone();
392        #[cfg(target_os = "macos")]
393        // Unverified launch interval: if the executable identity was never registered
394        // before launch, startup samples could not be attributed, so never claim success.
395        let identity_registered = self
396            .owned
397            .lock()
398            .unwrap_or_else(|e| e.into_inner())
399            .1
400            .is_some();
401        #[cfg(target_os = "macos")]
402        let owned_never_frontmost =
403            !self.enforce_owned || (identity_registered && g.1 == 0 && observed.is_empty());
404        #[cfg(windows)]
405        let owned_never_frontmost = windows_owned_never_frontmost(
406            self.enforce_owned,
407            self.unreadable_owners
408                .load(std::sync::atomic::Ordering::SeqCst),
409            &observed,
410        );
411        #[cfg(not(any(target_os = "macos", windows)))]
412        // No native foreground-PID sampler exists on this target; background claims fail
413        // closed instead of treating name-only sampling as proof.
414        let owned_never_frontmost = !self.enforce_owned;
415        (unchanged, g.2.clone(), owned_never_frontmost, observed)
416    }
417}
418
419/// Caller environment must not put secrets in a process listing: on macOS
420/// `open --env K=V` exposes values in argv. Callers may supply only `RIGHTKIT_*` keys;
421/// harness-owned HOME/temp/profile paths are added separately.
422fn check_env(env: &[(String, String)]) -> Result<()> {
423    for (k, _) in env {
424        let up = k.to_ascii_uppercase();
425        if [
426            "TOKEN",
427            "SECRET",
428            "PASSWORD",
429            "PASSWD",
430            "API_KEY",
431            "APIKEY",
432            "PRIVATE_KEY",
433            "CREDENTIAL",
434        ]
435        .iter()
436        .any(|w| up.contains(w))
437        {
438            return err(format!("environment key {k} names a credential; secrets are never passed to a launched app through its command line or environment"));
439        }
440        #[cfg(target_os = "macos")]
441        if !up.starts_with("RIGHTKIT_") {
442            return err(format!("environment key {k} would be exposed in `open` arguments; macOS launches carry only RIGHTKIT_* keys"));
443        }
444    }
445    Ok(())
446}
447
448/// Absolute path of the executable that `open` will actually run for `bundle`, read from
449/// `Contents/Info.plist` `CFBundleExecutable`. `None` (fail closed) when the key is absent,
450/// names a path component trick, or the file is not a regular file inside `Contents/MacOS`.
451#[cfg(target_os = "macos")]
452#[doc(hidden)]
453pub fn resolve_bundle_executable(bundle: &Path) -> Option<String> {
454    let plist = bundle.join("Contents").join("Info.plist");
455    let out = Command::new("plutil")
456        .args(["-extract", "CFBundleExecutable", "raw", "-o", "-"])
457        .arg(&plist)
458        .output()
459        .ok()?;
460    if !out.status.success() {
461        return None;
462    }
463    let name = String::from_utf8(out.stdout).ok()?;
464    let name = name.trim();
465    if name.is_empty() || name == "." || name == ".." || name.contains('/') || name.contains('\0') {
466        return None;
467    }
468    let exe = bundle.join("Contents").join("MacOS").join(name);
469    let meta = fs::metadata(&exe).ok()?;
470    if !meta.is_file() {
471        return None;
472    }
473    Some(exe.to_string_lossy().into_owned())
474}
475
476#[cfg(target_os = "macos")]
477fn make_bundle(dir: &Path, binary: &Path, id: &str, background: bool) -> Result<PathBuf> {
478    let exe = binary
479        .file_name()
480        .and_then(|n| n.to_str())
481        .ok_or_else(|| Error("binary has no file name".into()))?;
482    let app = dir.join(format!("{exe}.app"));
483    let macos = app.join("Contents").join("MacOS");
484    fs::create_dir_all(&macos)?;
485    let target = macos.join(exe);
486    let _ = fs::remove_file(&target);
487    let abs = fs::canonicalize(binary)
488        .map_err(|e| Error(format!("app binary not found: {}: {e}", binary.display())))?;
489    // Copy into internal storage; never launch a link back to the build volume.
490    fs::copy(&abs, &target)?;
491    // Background fixtures must be non-activating before AppKit creates any window.
492    let ui_element = if background {
493        "<key>LSUIElement</key><true/>\n"
494    } else {
495        ""
496    };
497    let plist = format!(
498        "<?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"
499    );
500    fs::write(app.join("Contents").join("Info.plist"), plist)?;
501    Ok(app)
502}
503
504#[cfg(target_os = "macos")]
505fn stage_bundle(spec: &LaunchSpec, ws: &QaWorkspace, id: &str) -> Result<PathBuf> {
506    let dir = ws.home.join("apps").join(id);
507    fs::create_dir_all(&dir)?;
508    if spec.binary.extension().is_some_and(|e| e == "app") {
509        let source = fs::canonicalize(&spec.binary)?;
510        let bundle = dir.join(
511            source
512                .file_name()
513                .ok_or_else(|| Error("bundle has no file name".into()))?,
514        );
515        // Preserve bundle resources, framework links, permissions & signatures.
516        let status = Command::new("/usr/bin/ditto")
517            .arg(&source)
518            .arg(&bundle)
519            .status()?;
520        if !status.success() {
521            return err(format!("app bundle staging failed: {status}"));
522        }
523        check_bundle_links(&bundle, &bundle)?;
524        Ok(bundle)
525    } else {
526        make_bundle(&dir, &spec.binary, &id[..8], spec.mode != Mode::Visible)
527    }
528}
529
530#[cfg(target_os = "macos")]
531fn check_bundle_links(bundle: &Path, dir: &Path) -> Result<()> {
532    for entry in fs::read_dir(dir)? {
533        let entry = entry?;
534        let path = entry.path();
535        let kind = entry.file_type()?;
536        if kind.is_symlink() {
537            if !fs::canonicalize(&path)?.starts_with(bundle) {
538                return err(format!(
539                    "staged app bundle link escapes internal bundle: {}",
540                    path.display()
541                ));
542            }
543        } else if kind.is_dir() {
544            check_bundle_links(bundle, &path)?;
545        }
546    }
547    Ok(())
548}
549
550fn launch_env(
551    spec: &LaunchSpec,
552    ws: &QaWorkspace,
553    suite: &Suite,
554    service: &str,
555) -> Result<Vec<(String, String)>> {
556    // Caller values retain the credential/argv restrictions. Workspace values are
557    // harness-owned paths, so HOME, TMPDIR & profile keys can safely travel via open.
558    check_env(&spec.env)?;
559    let mut env = ws.env.clone();
560    env.insert("RIGHTKIT_CONTROL_SERVICE".into(), service.into());
561    env.insert(
562        "RIGHTKIT_SUITE_ROOT".into(),
563        suite.root().to_string_lossy().into(),
564    );
565    env.insert(
566        "RIGHTKIT_QA_HIDDEN".into(),
567        if spec.mode == Mode::Hidden { "1" } else { "0" }.into(),
568    );
569    env.insert(
570        "RIGHTKIT_QA_BACKGROUND".into(),
571        if spec.mode != Mode::Visible { "1" } else { "0" }.into(),
572    );
573    for (key, value) in &spec.env {
574        let path = Path::new(value);
575        if path.is_absolute()
576            && (!path.starts_with(&ws.home)
577                || path
578                    .components()
579                    .any(|c| c == std::path::Component::ParentDir))
580        {
581            return err(format!(
582                "environment path {key} must be staged inside the QA app home"
583            ));
584        }
585        if let Some(owned) = env.get(key) {
586            let app_path = ws.env.contains_key(key)
587                && key != "RIGHTKIT_SUITE_ROOT"
588                && Path::new(owned).is_absolute();
589            let internal = Path::new(value).starts_with(&ws.home)
590                && !Path::new(value)
591                    .components()
592                    .any(|c| c == std::path::Component::ParentDir);
593            if value != owned && !(app_path && internal) {
594                return err(format!(
595                    "environment key {key} overrides a harness-owned launch value"
596                ));
597            }
598        }
599        env.insert(key.clone(), value.clone());
600    }
601    Ok(env.into_iter().collect())
602}
603
604#[cfg(target_os = "macos")]
605fn open_command(
606    spec: &LaunchSpec,
607    ws: &QaWorkspace,
608    env: &[(String, String)],
609    log_path: &Path,
610    bundle: &Path,
611) -> Command {
612    let mut cmd = Command::new("open");
613    cmd.current_dir(&ws.home);
614    match spec.mode {
615        Mode::Hidden => cmd.args(["-g", "-j", "-n"]),
616        Mode::Background => cmd.args(["-g", "-n"]),
617        Mode::Visible => cmd.args(["-n"]),
618    };
619    for (k, v) in env {
620        cmd.env(k, v).arg("--env").arg(format!("{k}={v}"));
621    }
622    cmd.arg("--stdout")
623        .arg(log_path)
624        .arg("--stderr")
625        .arg(log_path)
626        .arg(bundle);
627    cmd
628}
629
630#[cfg(target_os = "macos")]
631/// Pids whose command line STARTS with `prefix` (so `open`, which merely carries the
632/// bundle path as an argument, is never mistaken for the app).
633fn pids_with_command_prefix(prefix: &str) -> std::result::Result<Vec<u32>, String> {
634    let out = Command::new("ps")
635        .args(["-ax", "-o", "pid=,command="])
636        .output()
637        .map_err(|e| format!("ps PID inventory failed: {e}"))?;
638    if !out.status.success() {
639        return Err(format!("ps PID inventory exited with {}", out.status));
640    }
641    let mut pids = vec![];
642    for line in String::from_utf8_lossy(&out.stdout).lines() {
643        let line = line.trim_start();
644        let Some((pid, cmd)) = line.split_once(' ') else {
645            continue;
646        };
647        if cmd.trim_start().starts_with(prefix) {
648            pids.push(
649                pid.trim()
650                    .parse::<u32>()
651                    .map_err(|e| format!("invalid PID inventory row: {e}"))?,
652            );
653        }
654    }
655    Ok(pids)
656}
657
658pub fn launch(spec: &LaunchSpec, ws: &QaWorkspace, tracker: &Tracker) -> Result<Control> {
659    if !spec.binary.exists() {
660        return err(format!(
661            "UI app binary not found: {}",
662            spec.binary.display()
663        ));
664    }
665    // Before anything is created or started: the QA home must be a QA root this runner made, on the
666    // internal disk, and clear of every user-data location (see `userdata`). No opt-out.
667    let guard = crate::userdata::Guard::for_workspace(ws)?;
668    let id = new_id().replace('-', "");
669    let service = format!("control-{}", &id[..8]);
670    // Isolated per-run suite root: the app's service.json never collides with an installed app's.
671    let suite =
672        Suite::at(ws.home.join(format!("suite-{}", &id[..8]))).map_err(|e| Error(e.to_string()))?;
673    let log_path = ws.evidence_dir.join(format!("app-{}.log", &id[..8]));
674    // launchd-started apps cannot open log files on external volumes (`open` fails with
675    // -10810), so the live log goes to the system temp dir and is copied into the
676    // evidence dir when the app stops.
677    // Kept in a subdirectory: apps commonly list or watch HOME (file explorers, recents), and
678    // a live log written directly into it is a change source that can feed back into the app.
679    let log_dir = ws.home.join(".rightkit-qa");
680    fs::create_dir_all(&log_dir)?;
681    let raw_log = log_dir.join(format!("app-{}.log", &id[..8]));
682    let env = launch_env(spec, ws, &suite, &service)?;
683    guard.check_dir(&ws.home)?;
684    guard.check_env(&env)?;
685    // The app must be told its isolated data directory; otherwise it resolves its own default.
686    crate::userdata::check_app_data_dir(&guard, &spec.binary, &env)?;
687    let frontmost_before = frontmost();
688    let focus = FocusMonitor::start(spec.mode != Mode::Visible);
689    let deadline = Instant::now() + spec.startup_timeout;
690
691    // The executable identity is registered inside start_process BEFORE `open`, so
692    // startup foreground samples are attributed; the pid follows once known.
693    let (pid, child) = start_process(spec, ws, &env, &raw_log, &id, tracker, &focus, deadline)?;
694    focus.track_primary(pid);
695    tracker.register(pid, &spec.label);
696    // Disposal may end what this app started and left in the home, and nothing else.
697    crate::workspace::record_launched(ws, pid);
698
699    let mut control = Control {
700        pid,
701        service: service.clone(),
702        log_path,
703        raw_log,
704        suite: suite.clone(),
705        endpoint: String::new(),
706        client: Mutex::new(None),
707        child,
708        tracker: tracker.clone(),
709        frontmost_before,
710        focus: Some(focus),
711        request_timeout: Duration::from_secs(20),
712        stopped: false,
713    };
714    loop {
715        // Each attempt is bounded by the startup deadline: a hung host must not defeat it.
716        let left = deadline.saturating_duration_since(Instant::now());
717        if let Some(c) = bounded_connect_health(&suite, &service, left.min(Duration::from_secs(5)))
718        {
719            control.endpoint = c.record().endpoint.clone();
720            *control.client.lock().unwrap() = Some(c);
721            break;
722        }
723        if !is_alive(pid) {
724            let tail = fs::read_to_string(&control.raw_log)
725                .map(|t| crate::util::tail(&t, 1500))
726                .unwrap_or_default();
727            return err(format!(
728                "app exited before its control server came up (pid {pid}); log tail: {tail}"
729            ));
730        }
731        if Instant::now() >= deadline {
732            let _ = control.stop();
733            return err(format!(
734                "control service did not answer within {}ms; was the app built with its control feature and launched with RIGHTKIT_CONTROL_SERVICE?{}",
735                spec.startup_timeout.as_millis(),
736                non_interactive_hint()
737            ));
738        }
739        sleep_ms(100);
740    }
741    // The server comes up before the window exists (hidden launch): wait until the page
742    // itself answers, so the first scenario step never races window creation.
743    loop {
744        if let Ok(Value::String(state)) = control.eval("return document.readyState;") {
745            if state == "complete" || state == "interactive" {
746                break;
747            }
748        }
749        if Instant::now() >= deadline {
750            let alive = is_alive(pid);
751            let _ = control.stop();
752            return err(format!(
753                "app window never became ready within {}ms (process alive: {}){}",
754                spec.startup_timeout.as_millis(),
755                alive,
756                non_interactive_hint()
757            ));
758        }
759        sleep_ms(100);
760    }
761    Ok(control)
762}
763
764/// Extra explanation for a launch timeout when the harness itself runs outside the
765/// interactive desktop (SSH or a service on Windows): a WebView2 app launched from there
766/// never gets a ready window. Empty everywhere else.
767fn non_interactive_hint() -> String {
768    #[cfg(windows)]
769    if let Some(why) = windows_non_interactive_reason() {
770        return format!(
771            ". 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)"
772        );
773    }
774    String::new()
775}
776
777/// `Some(reason)` when this process is in session 0 or on a window station that is not
778/// visible (the shape of an SSH or service logon); `None` on an interactive desktop or when
779/// it cannot be determined.
780#[cfg(windows)]
781fn windows_non_interactive_reason() -> Option<String> {
782    use std::ffi::c_void;
783    #[repr(C)]
784    struct UserObjectFlags {
785        inherit: i32,
786        reserved: i32,
787        flags: u32,
788    }
789    #[link(name = "user32")]
790    extern "system" {
791        fn GetProcessWindowStation() -> *mut c_void;
792        fn GetUserObjectInformationW(
793            h: *mut c_void,
794            index: i32,
795            info: *mut c_void,
796            len: u32,
797            needed: *mut u32,
798        ) -> i32;
799    }
800    #[link(name = "kernel32")]
801    extern "system" {
802        fn GetCurrentProcessId() -> u32;
803        fn ProcessIdToSessionId(pid: u32, session: *mut u32) -> i32;
804    }
805    const UOI_FLAGS: i32 = 1;
806    const WSF_VISIBLE: u32 = 1;
807    let mut session = 0u32;
808    // SAFETY: plain Win32 queries on this process with correctly sized out-parameters.
809    unsafe {
810        if ProcessIdToSessionId(GetCurrentProcessId(), &mut session) != 0 && session == 0 {
811            return Some("session 0".into());
812        }
813        let station = GetProcessWindowStation();
814        if station.is_null() {
815            return None;
816        }
817        let mut info = UserObjectFlags {
818            inherit: 0,
819            reserved: 0,
820            flags: 0,
821        };
822        let mut needed = 0u32;
823        if GetUserObjectInformationW(
824            station,
825            UOI_FLAGS,
826            &mut info as *mut _ as *mut c_void,
827            std::mem::size_of::<UserObjectFlags>() as u32,
828            &mut needed,
829        ) != 0
830            && info.flags & WSF_VISIBLE == 0
831        {
832            return Some(format!("session {session}, non-visible window station"));
833        }
834    }
835    None
836}
837
838#[allow(clippy::too_many_arguments)]
839fn start_process(
840    spec: &LaunchSpec,
841    ws: &QaWorkspace,
842    env: &[(String, String)],
843    log_path: &Path,
844    id: &str,
845    _tracker: &Tracker,
846    focus: &FocusMonitor,
847    deadline: Instant,
848) -> Result<(u32, Option<OwnedChild>)> {
849    #[cfg(target_os = "macos")]
850    {
851        let bundle = stage_bundle(spec, ws, id)?;
852        let needle = format!("{}/Contents/MacOS/", bundle.display());
853        // Register the REAL executable (CFBundleExecutable) before `open`. When it cannot be
854        // resolved and validated, register nothing: the launch interval stays unverified and
855        // `owned_never_frontmost` fails closed instead of matching a guessed name.
856        if let Some(executable) = resolve_bundle_executable(&bundle) {
857            focus.track_prefix(executable);
858        }
859        let mut cmd = open_command(spec, ws, env, log_path, &bundle);
860        let st = cmd.status()?;
861        if !st.success() {
862            return err(format!("open exited {st}"));
863        }
864        loop {
865            if let Some(pid) = pids_with_command_prefix(&needle)
866                .ok()
867                .and_then(|pids| pids.into_iter().next())
868            {
869                return Ok((pid, None));
870            }
871            if Instant::now() >= deadline {
872                return err("launched app pid not found");
873            }
874            sleep_ms(100);
875        }
876    }
877    #[cfg(not(target_os = "macos"))]
878    {
879        let _ = (ws, id);
880        let log = fs::File::create(log_path)?;
881        let log2 = log.try_clone()?;
882        let mut cmd = Command::new(&spec.binary);
883        cmd.current_dir(&ws.home)
884            .stdin(Stdio::null())
885            .stdout(Stdio::from(log))
886            .stderr(Stdio::from(log2));
887        for (k, v) in env {
888            cmd.env(k, v);
889        }
890        let mut owned = OwnedCommand::from_command(cmd);
891        if spec.mode != Mode::Visible {
892            owned.windows_hide();
893        }
894        let _ = deadline;
895        let child = owned
896            .spawn()
897            .map_err(|e| Error(format!("failed to start {}: {e}", spec.binary.display())))?;
898        // Attribute foreground samples to the app from its first instant.
899        focus.track_primary(child.id());
900        Ok((child.id(), Some(child)))
901    }
902}
903
904impl Control {
905    /// Fail when a system permission dialog is known to be showing for this app. TODO: the
906    /// automation layer cannot enumerate windows by owner (`UserNotificationCenter`,
907    /// `CoreServicesUIAgent`), so this is a no-op that returns `Ok` today; see
908    /// [`crate::userdata::live_permission_dialog_check`]. Use
909    /// [`StopReport::permission_dialog`] for the evidence that does exist.
910    pub fn assert_no_permission_dialog(&self) -> Result<()> {
911        match crate::userdata::live_permission_dialog_check(self.pid) {
912            crate::userdata::DialogCheck::Dialog(owner) => err(format!(
913                "a system permission dialog ({owner}) appeared for the app under test"
914            )),
915            _ => Ok(()),
916        }
917    }
918
919    pub fn set_request_timeout(&mut self, t: Duration) {
920        self.request_timeout = t;
921    }
922
923    /// One bounded request. The deadline is real: when it passes the connection is
924    /// aborted (unblocking the read) and the call fails. A request that may already have
925    /// reached the app is replayed only when the method is a pure read; effects
926    /// (click, key, type, command, eval, ...) are never silently executed twice.
927    fn rpc(&self, method: &str, params: Value) -> Result<Value> {
928        let replayable = matches!(method, "health" | "dom" | "ax" | "screenshot" | "move");
929        let mut g = self.client.lock().unwrap();
930        for attempt in 0..2 {
931            let client = match g.as_ref() {
932                Some(c) => c.clone(),
933                None => {
934                    // Not yet sent anything: reconnecting is always safe.
935                    let c = Arc::new(connect_bounded(
936                        &self.suite,
937                        &self.service,
938                        self.request_timeout,
939                    )?);
940                    *g = Some(c.clone());
941                    c
942                }
943            };
944            match call_bounded(&client, method, params.clone(), self.request_timeout) {
945                Ok(v) => return Ok(v),
946                Err(CallError::TimedOut) => {
947                    *g = None;
948                    return err(format!(
949                        "{method}: no reply within {}ms; connection aborted",
950                        self.request_timeout.as_millis()
951                    ));
952                }
953                Err(CallError::Service(e)) if e.retryable && replayable && attempt == 0 => {
954                    *g = None
955                }
956                Err(CallError::Service(e)) => {
957                    if e.retryable {
958                        *g = None;
959                    }
960                    let mut m = svc_err(e);
961                    if !replayable {
962                        m = Error(format!("{} (not replayed: {method} has effects and the first attempt may have been applied)", m.0));
963                    }
964                    return Err(m);
965                }
966            }
967        }
968        err("control rpc failed")
969    }
970
971    fn json(&self, method: &str, params: Value) -> Result<Value> {
972        let v = self.rpc(method, params)?;
973        if v.get("ok") == Some(&Value::Bool(false)) {
974            return err(format!(
975                "{method}: {}",
976                v.get("error")
977                    .and_then(Value::as_str)
978                    .unwrap_or("unknown error")
979            ));
980        }
981        Ok(v)
982    }
983
984    pub fn health(&self) -> Result<Value> {
985        self.rpc("health", json!({}))
986    }
987    /// Resize hidden native content without screen clamping or activation.
988    /// Returns measured CSS dimensions; this effect is never automatically replayed.
989    pub fn set_viewport(&self, width: u32, height: u32) -> Result<(u32, u32)> {
990        let v = self.json("set_viewport", json!({"width": width, "height": height}))?;
991        let dimension = |key: &str| {
992            v.get(key)
993                .and_then(Value::as_u64)
994                .and_then(|n| u32::try_from(n).ok())
995                .filter(|n| *n > 0)
996                .ok_or_else(|| Error(format!("set_viewport returned invalid {key}")))
997        };
998        Ok((dimension("innerWidth")?, dimension("innerHeight")?))
999    }
1000    /// Prove the allowlist: an app that is not on it is refused at hello. Returns the error code.
1001    pub fn unlisted_app_probe(&self) -> Result<String> {
1002        match Client::connect(&self.suite, &self.service, "not-allowlisted-probe") {
1003            Ok(_) => err("an unlisted app was accepted by the control service"),
1004            Err(e) => Ok(e.code),
1005        }
1006    }
1007    pub fn click(&self, x: f64, y: f64, button: &str, count: u32) -> Result<()> {
1008        self.json(
1009            "click",
1010            json!({"x": x, "y": y, "button": button, "count": count}),
1011        )
1012        .map(|_| ())
1013    }
1014    /// [`click`](Self::click) holding modifiers (`"shift"`, `"cmd"`, `"alt"`, `"ctrl"`). They
1015    /// travel on the native mouse events, so the page sees `e.shiftKey` etc. as real input.
1016    pub fn click_with(
1017        &self,
1018        x: f64,
1019        y: f64,
1020        button: &str,
1021        count: u32,
1022        modifiers: &[&str],
1023    ) -> Result<()> {
1024        self.json(
1025            "click",
1026            json!({"x": x, "y": y, "button": button, "count": count, "modifiers": modifiers}),
1027        )
1028        .map(|_| ())
1029    }
1030    pub fn move_to(&self, x: f64, y: f64) -> Result<()> {
1031        self.json("move", json!({"x": x, "y": y})).map(|_| ())
1032    }
1033    /// One native pointer phase (`down`, `drag`, `up`, `move`) in viewport CSS
1034    /// pixels. Use down/drag/up for a scrub split across requests; keep the same
1035    /// button and modifiers on each phase. Hidden launches use the embedded path.
1036    pub fn pointer(
1037        &self,
1038        phase: &str,
1039        x: f64,
1040        y: f64,
1041        button: &str,
1042        modifiers: &[&str],
1043    ) -> Result<()> {
1044        self.json(
1045            "pointer",
1046            json!({"phase": phase, "x": x, "y": y, "button": button, "modifiers": modifiers}),
1047        )
1048        .map(|_| ())
1049    }
1050    pub fn drag(&self, from: (f64, f64), to: (f64, f64), steps: u32) -> Result<()> {
1051        self.json(
1052            "drag",
1053            json!({"x1": from.0, "y1": from.1, "x2": to.0, "y2": to.1, "steps": steps}),
1054        )
1055        .map(|_| ())
1056    }
1057    pub fn wheel(&self, x: f64, y: f64, dx: f64, dy: f64) -> Result<()> {
1058        self.json("wheel", json!({"x": x, "y": y, "dx": dx, "dy": dy}))
1059            .map(|_| ())
1060    }
1061    pub fn key(&self, spec: &str) -> Result<()> {
1062        self.json("key", json!({"key": spec})).map(|_| ())
1063    }
1064    pub fn type_text(&self, text: &str) -> Result<()> {
1065        self.json("type", json!({"text": text})).map(|_| ())
1066    }
1067    /// Evaluate a JS function body (`return ...`) in the page; returns its JSON value.
1068    pub fn eval(&self, js: &str) -> Result<Value> {
1069        Ok(self
1070            .json("eval", json!({"js": js}))?
1071            .get("value")
1072            .cloned()
1073            .unwrap_or(Value::Null))
1074    }
1075    pub fn dom(&self, selector: &str) -> Result<Vec<Value>> {
1076        let v = self.json("dom", json!({"selector": selector}))?;
1077        Ok(v.get("elements")
1078            .and_then(Value::as_array)
1079            .cloned()
1080            .unwrap_or_default())
1081    }
1082    pub fn ax(&self) -> Result<Vec<Value>> {
1083        let v = self.json("ax", json!({}))?;
1084        Ok(v.get("nodes")
1085            .and_then(Value::as_array)
1086            .cloned()
1087            .unwrap_or_default())
1088    }
1089    pub fn screenshot_png(&self) -> Result<Vec<u8>> {
1090        let v = self.json("screenshot", json!({}))?;
1091        // The server masks credential elements before writing; a server that does not
1092        // report `masked` predates redaction and its captures are refused.
1093        if v.get("masked").and_then(Value::as_u64).is_none() {
1094            return err("control server does not redact screenshots; capture refused");
1095        }
1096        let path = PathBuf::from(
1097            v.get("path")
1098                .and_then(Value::as_str)
1099                .ok_or_else(|| Error("screenshot returned no path".into()))?,
1100        );
1101        let body = fs::read(&path)?;
1102        let _ = fs::remove_file(&path);
1103        if body.len() < 8 || &body[1..4] != b"PNG" {
1104            return err("screenshot method did not return a PNG");
1105        }
1106        Ok(body)
1107    }
1108    pub fn screenshot_to(&self, path: &Path) -> Result<()> {
1109        if let Some(p) = path.parent() {
1110            fs::create_dir_all(p)?;
1111        }
1112        fs::write(path, self.screenshot_png()?)?;
1113        Ok(())
1114    }
1115    /// A typed app command registered with `Control::command(name, f)`. The server
1116    /// hands the command its `args` string verbatim.
1117    pub fn command(&self, name: &str, args: &Value) -> Result<Value> {
1118        let args = match args {
1119            Value::String(s) => s.clone(),
1120            Value::Null => String::new(),
1121            other => other.to_string(),
1122        };
1123        let v = self.json("command", json!({"name": name, "args": args}))?;
1124        Ok(v.get("result").cloned().unwrap_or(Value::Null))
1125    }
1126
1127    /// Centre of the first element matching `selector`, in viewport CSS px.
1128    pub fn center_of(&self, selector: &str) -> Result<(f64, f64)> {
1129        let els = self.dom(selector)?;
1130        let first = els
1131            .first()
1132            .ok_or_else(|| Error(format!("selector matched no element: {selector}")))?;
1133        let r: Vec<f64> = first
1134            .get("rect")
1135            .and_then(Value::as_array)
1136            .map(|a| a.iter().filter_map(Value::as_f64).collect())
1137            .unwrap_or_default();
1138        if r.len() < 4 || r[2] <= 0.0 || r[3] <= 0.0 {
1139            return err(format!("{selector} has no visible box"));
1140        }
1141        Ok((r[0] + r[2] / 2.0, r[1] + r[3] / 2.0))
1142    }
1143    pub fn click_selector(&self, selector: &str) -> Result<()> {
1144        let (x, y) = self.center_of(selector)?;
1145        self.click(x, y, "left", 1)
1146    }
1147
1148    /// Poll a JS function body until it returns something truthy; bounded.
1149    pub fn wait_eval(&self, js: &str, timeout: Duration) -> Result<Value> {
1150        let deadline = Instant::now() + timeout;
1151        let mut last;
1152        loop {
1153            match self.eval(js) {
1154                Ok(v) => {
1155                    let truthy = !matches!(v, Value::Null | Value::Bool(false))
1156                        && v != json!(0)
1157                        && v != json!("");
1158                    if truthy {
1159                        return Ok(v);
1160                    }
1161                    last = v;
1162                }
1163                Err(e) => last = json!(e.0),
1164            }
1165            if Instant::now() >= deadline {
1166                return err(format!("timeout waiting for `{js}` (last: {last})"));
1167            }
1168            sleep_ms(100);
1169        }
1170    }
1171
1172    /// Wait until `text` appears in the visible text (`innerText`) of the first element
1173    /// matching `selector` (`None` = the whole body). Polls every 100 ms until `timeout`.
1174    pub fn wait_for_text(
1175        &self,
1176        selector: Option<&str>,
1177        text: &str,
1178        timeout: Duration,
1179    ) -> Result<()> {
1180        let sel = serde_json::to_string(selector.unwrap_or("body")).unwrap_or_default();
1181        let needle = serde_json::to_string(text).unwrap_or_default();
1182        let js = format!(
1183            "return ((document.querySelector({sel}) || {{}}).innerText || '').includes({needle});"
1184        );
1185        self.wait_eval(&js, timeout).map(|_| ())
1186    }
1187
1188    /// Stop the app: kill the recorded pid tree, prove the port closed, and check
1189    /// we did not change the frontmost app.
1190    pub fn stop(&mut self) -> Result<StopReport> {
1191        if self.stopped {
1192            return err("control session already stopped");
1193        }
1194        self.stopped = true;
1195        if let Some(mut c) = self.child.take() {
1196            let _ = c.terminate_tree();
1197        }
1198        kill_tree(self.pid);
1199        let deadline = Instant::now() + Duration::from_secs(5);
1200        while Instant::now() < deadline && (is_alive(self.pid) || endpoint_open(&self.endpoint)) {
1201            sleep_ms(50);
1202        }
1203        // Keep the live log until workspace finish/disposal; failed evidence copies
1204        // must not destroy the only readable startup diagnostics.
1205        let log_copy = fs::copy(&self.raw_log, &self.log_path);
1206        let after = frontmost();
1207        let (steady, states, owned_never_frontmost, owned_frontmost_pids) = self
1208            .focus
1209            .take()
1210            .map(|mut f| f.finish())
1211            .unwrap_or((false, vec![], false, vec![]));
1212        let process_gone = !is_alive(self.pid);
1213        let endpoint_closed = self.endpoint.is_empty() || !endpoint_open(&self.endpoint);
1214        if process_gone {
1215            self.tracker.forget(self.pid);
1216        }
1217        log_copy.map_err(|e| {
1218            Error(format!(
1219                "cannot preserve app log {}: {e}",
1220                self.raw_log.display()
1221            ))
1222        })?;
1223        Ok(StopReport {
1224            pid: self.pid,
1225            endpoint_closed,
1226            process_gone,
1227            frontmost_unchanged: steady
1228                && self.frontmost_before.is_some()
1229                && self.frontmost_before == after,
1230            frontmost_states: states,
1231            frontmost_before: self.frontmost_before.clone(),
1232            frontmost_after: after,
1233            owned_never_frontmost,
1234            owned_frontmost_pids,
1235        })
1236    }
1237}
1238
1239impl Drop for Control {
1240    fn drop(&mut self) {
1241        if !self.stopped {
1242            let _ = self.stop();
1243        }
1244    }
1245}
1246
1247#[cfg(test)]
1248mod launch_path_tests {
1249    use super::*;
1250
1251    #[test]
1252    fn windows_foreground_is_classified_without_a_windows_host() {
1253        assert_eq!(classify_foreground(false, 0, 0), Foreground::NoWindow);
1254        assert_eq!(classify_foreground(true, 7, 4242), Foreground::Pid(4242));
1255        // A foreground window whose owner cannot be read is not "nothing in front".
1256        assert_eq!(classify_foreground(true, 0, 0), Foreground::Unreadable);
1257        assert_eq!(classify_foreground(true, 7, 0), Foreground::Unreadable);
1258        assert_eq!(classify_foreground(true, 0, 4242), Foreground::Unreadable);
1259    }
1260
1261    #[test]
1262    fn windows_background_invariant_fails_closed() {
1263        // Visible launches enforce nothing.
1264        assert!(windows_owned_never_frontmost(false, 3, &[9]));
1265        // Background: the owned pid in front, or an unreadable owner, both fail.
1266        assert!(windows_owned_never_frontmost(true, 0, &[]));
1267        assert!(!windows_owned_never_frontmost(true, 0, &[9]));
1268        assert!(!windows_owned_never_frontmost(true, 1, &[]));
1269    }
1270
1271    fn fixture() -> (QaWorkspace, LaunchSpec) {
1272        let root = crate::storage::app_temp_dir().join(format!("rkqa-launch-{}", new_id()));
1273        let ws = crate::workspace::create(&root, None, "control-test-app").unwrap();
1274        let binary = ws.root.join("control-test-app");
1275        fs::write(&binary, "fixture binary").unwrap();
1276        let spec = LaunchSpec {
1277            binary,
1278            mode: Mode::Hidden,
1279            env: vec![],
1280            startup_timeout: Duration::from_secs(90),
1281            label: "fixture".into(),
1282        };
1283        (ws, spec)
1284    }
1285
1286    fn cleanup(ws: &QaWorkspace) {
1287        let root = ws
1288            .root
1289            .parent()
1290            .unwrap()
1291            .parent()
1292            .unwrap()
1293            .parent()
1294            .unwrap()
1295            .to_path_buf();
1296        let report = crate::workspace::dispose(ws, false, 1, 0);
1297        assert!(report.retained.is_empty(), "{report:?}");
1298        fs::remove_dir_all(root).unwrap();
1299    }
1300
1301    #[test]
1302    fn launch_environment_keeps_all_owned_paths_internal_and_rejects_external_overrides() {
1303        let (ws, mut spec) = fixture();
1304        let suite = Suite::at(ws.home.join("suite-12345678")).unwrap();
1305        let env: std::collections::BTreeMap<_, _> =
1306            launch_env(&spec, &ws, &suite, "control-12345678")
1307                .unwrap()
1308                .into_iter()
1309                .collect();
1310        for (key, value) in &env {
1311            if Path::new(value).is_absolute() {
1312                assert!(Path::new(value).starts_with(&ws.home), "{key}={value}");
1313                assert!(!Path::new(value).starts_with(&ws.root), "{key}={value}");
1314            }
1315        }
1316        assert_eq!(env["HOME"], ws.home.to_string_lossy());
1317        assert_eq!(env["TMPDIR"], ws.home.join("tmp").to_string_lossy());
1318        assert_eq!(env["RIGHTKIT_SUITE_ROOT"], suite.root().to_string_lossy());
1319        assert_eq!(env["RIGHTKIT_QA_HIDDEN"], "1");
1320        assert_eq!(env["RIGHTKIT_QA_BACKGROUND"], "1");
1321        // rightkit-daemon falls back to TMPDIR/rk-<suite-hash>-<service>.sock
1322        // when its runtime socket exceeds 100 bytes. App TMPDIR must fit too.
1323        #[cfg(target_os = "macos")]
1324        assert!(
1325            Path::new(&env["TMPDIR"])
1326                .join("rk-12345678-control-12345678.sock")
1327                .as_os_str()
1328                .len()
1329                <= 100
1330        );
1331        assert!(suite
1332            .service_dir("control-12345678")
1333            .join("service.json")
1334            .starts_with(&ws.home));
1335        for key in [
1336            "RIGHTKIT_SUITE_ROOT",
1337            "RIGHTKIT_QA_DATA_DIR",
1338            "RIGHTKIT_RUN_ROOT",
1339        ] {
1340            spec.env = vec![(
1341                key.into(),
1342                ws.root.join("external").to_string_lossy().into(),
1343            )];
1344            assert!(
1345                launch_env(&spec, &ws, &suite, "control-12345678").is_err(),
1346                "{key}"
1347            );
1348        }
1349        spec.env = vec![("RIGHTKIT_SECRET".into(), "redacted".into())];
1350        assert!(launch_env(&spec, &ws, &suite, "control-12345678").is_err());
1351        cleanup(&ws);
1352    }
1353
1354    #[cfg(target_os = "macos")]
1355    #[test]
1356    fn raw_binary_and_supplied_bundle_are_staged_inside_app_home_before_open() {
1357        let (ws, mut spec) = fixture();
1358        let id = "1234567890abcdef";
1359        let bundle = stage_bundle(&spec, &ws, id).unwrap();
1360        assert!(bundle.starts_with(ws.home.join("apps")));
1361        assert!(!bundle.starts_with(&ws.root));
1362        assert_eq!(
1363            fs::read(bundle.join("Contents/MacOS/control-test-app")).unwrap(),
1364            b"fixture binary"
1365        );
1366        let plist = fs::read_to_string(bundle.join("Contents/Info.plist")).unwrap();
1367        assert!(plist.contains("<key>LSUIElement</key><true/>"));
1368        let suite = Suite::at(ws.home.join("suite-12345678")).unwrap();
1369        let env = launch_env(&spec, &ws, &suite, "control-12345678").unwrap();
1370        let log = ws.home.join("app-12345678.log");
1371        let cmd = open_command(&spec, &ws, &env, &log, &bundle);
1372        assert_eq!(cmd.get_current_dir(), Some(ws.home.as_path()));
1373        let args: Vec<_> = cmd
1374            .get_args()
1375            .map(|v| v.to_string_lossy().into_owned())
1376            .collect();
1377        assert_eq!(&args[..3], &["-g", "-j", "-n"]);
1378        for (key, value) in &env {
1379            assert!(
1380                args.windows(2)
1381                    .any(|pair| pair[0] == "--env" && pair[1] == format!("{key}={value}")),
1382                "{key}"
1383            );
1384        }
1385        for flag in ["--stdout", "--stderr"] {
1386            assert!(args
1387                .windows(2)
1388                .any(|pair| pair[0] == flag && Path::new(&pair[1]) == log));
1389        }
1390        assert_eq!(args.last().unwrap(), &bundle.to_string_lossy());
1391        // A provided .app must be copied too; it may live on the external build volume.
1392        spec.binary = make_bundle(&ws.root, &spec.binary, "original", true).unwrap();
1393        fs::create_dir_all(spec.binary.join("Contents/Resources")).unwrap();
1394        fs::write(
1395            spec.binary.join("Contents/Resources/fixture.txt"),
1396            "resource",
1397        )
1398        .unwrap();
1399        let supplied = stage_bundle(&spec, &ws, "fedcba0987654321").unwrap();
1400        assert!(supplied.starts_with(&ws.home));
1401        assert_ne!(supplied, spec.binary);
1402        assert_eq!(
1403            fs::read_to_string(supplied.join("Contents/Resources/fixture.txt")).unwrap(),
1404            "resource"
1405        );
1406        std::os::unix::fs::symlink(
1407            ws.root.join("control-test-app"),
1408            spec.binary.join("Contents/Resources/external"),
1409        )
1410        .unwrap();
1411        assert!(stage_bundle(&spec, &ws, "0123456789abcdef")
1412            .unwrap_err()
1413            .0
1414            .contains("escapes internal bundle"));
1415        cleanup(&ws);
1416    }
1417}