Skip to main content

rightkit_qa/
control.rs

1//! Drive the real Tauri app through the in-app control server (`embedded`, feature `control`).
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 bundle at a fixed per-app path on macOS, replaced in place with `.previous` kept).
50    pub binary: PathBuf,
51    pub mode: Mode,
52    pub env: Vec<(String, String)>,
53    pub startup_timeout: Duration,
54    pub label: String,
55    /// Opt-in (default `false`): after the page is ready, make the window key and focused so
56    /// `document.hasFocus()`, `:focus` and `:focus-visible` match (keyboard and accessibility
57    /// scenarios). macOS activates the app and makes the window key (it may change the
58    /// frontmost app); Windows enables Chromium focus emulation and moves WebView2 focus into
59    /// the page, with no `SetForegroundWindow`. A focused launch cannot prove
60    /// `owned_never_frontmost`, so `ui_stop` skips that proof for it.
61    pub focus: bool,
62}
63
64#[derive(Debug, Clone)]
65pub struct StopReport {
66    pub pid: u32,
67    pub endpoint_closed: bool,
68    pub process_gone: bool,
69    pub frontmost_before: Option<String>,
70    pub frontmost_after: Option<String>,
71    pub frontmost_unchanged: bool,
72    /// Every distinct foreground state sampled during the session (empty when unmeasurable).
73    pub frontmost_states: Vec<String>,
74    /// Every sampled foreground PID belonged to no app process started by this control session.
75    /// This is the background invariant; `frontmost_unchanged` remains diagnostic only.
76    /// macOS checks the started pid and every process under the bundle executable; Windows
77    /// checks the started pid only (the owner of the foreground window, from
78    /// `GetForegroundWindow` + `GetWindowThreadProcessId`, sampled every 20 ms). Both fail
79    /// closed when a foreground owner exists but cannot be read. Other targets always report
80    /// `false` for a non-visible launch.
81    pub owned_never_frontmost: bool,
82    /// Owned fixture PIDs observed in foreground, if any.
83    pub owned_frontmost_pids: Vec<u32>,
84    /// Foreground samples whose owner pid could not be read (macOS: the frontmost application;
85    /// Windows: the foreground window's owner). Any such sample makes `owned_never_frontmost`
86    /// false: an unread owner proves nothing.
87    pub frontmost_pid_unavailable_samples: usize,
88    /// Why `owned_never_frontmost` is false, in words that tell "an owned app WAS frontmost"
89    /// from "the frontmost pid could not be read" and from "the owned executable was never
90    /// registered". `None` when the guard holds or was not enforced.
91    pub owned_never_frontmost_failure: Option<String>,
92}
93
94impl StopReport {
95    /// The first sampled foreground name that belongs to a system permission-dialog agent.
96    /// Positive evidence only: `None` does not prove no dialog appeared.
97    pub fn permission_dialog(&self) -> Option<String> {
98        crate::userdata::permission_dialog_in(&self.frontmost_states)
99    }
100}
101
102pub struct Control {
103    pub pid: u32,
104    /// `rightkit-daemon` name the app serves (`control-xxxxxxxx`).
105    pub service: String,
106    pub log_path: PathBuf,
107    raw_log: PathBuf,
108    suite: Suite,
109    endpoint: String,
110    client: Mutex<Option<Arc<Client>>>,
111    child: Option<OwnedChild>,
112    tracker: Tracker,
113    frontmost_before: Option<String>,
114    focus: Option<FocusMonitor>,
115    request_timeout: Duration,
116    worker: Mutex<Option<Worker>>,
117    /// Gap between native input events asked of the app (`paceMs`); 0 sends none.
118    pace_ms: u64,
119    stopped: bool,
120    /// The launch asked for a key/focused window (`LaunchSpec::focus`).
121    focus_requested: bool,
122}
123
124const APP: &str = "right-qa";
125
126fn svc_err(e: ServiceError) -> Error {
127    Error(e.to_string())
128}
129
130enum CallError {
131    TimedOut,
132    Service(ServiceError),
133}
134
135/// Run `f` on a helper thread and give up after `timeout`; the thread is left to finish
136/// on its own (it ends when the app is killed or the connection is aborted).
137fn bounded<T: Send + 'static>(
138    timeout: Duration,
139    f: impl FnOnce() -> T + Send + 'static,
140) -> Option<T> {
141    let (tx, rx) = std::sync::mpsc::channel();
142    std::thread::spawn(move || {
143        let _ = tx.send(f());
144    });
145    rx.recv_timeout(timeout).ok()
146}
147
148type Job = Box<dyn FnOnce() + Send>;
149
150/// A reused call thread: requests are handed to it instead of spawning a thread per call. A
151/// call that times out abandons it (the abort ends its call, then it exits) and the next call
152/// starts a fresh one.
153struct Worker {
154    jobs: std::sync::mpsc::Sender<Job>,
155}
156
157impl Worker {
158    fn spawn() -> Self {
159        let (jobs, rx) = std::sync::mpsc::channel::<Job>();
160        std::thread::spawn(move || {
161            for job in rx {
162                job();
163            }
164        });
165        Worker { jobs }
166    }
167}
168
169fn call_bounded(
170    client: &Arc<Client>,
171    slot: &Mutex<Option<Worker>>,
172    method: &str,
173    params: Value,
174    timeout: Duration,
175) -> std::result::Result<Value, CallError> {
176    let (tx, rx) = std::sync::mpsc::channel();
177    let (c, m) = (client.clone(), method.to_string());
178    let mut job: Option<Job> = Some(Box::new(move || {
179        let _ = tx.send(c.call(&m, params));
180    }));
181    {
182        let mut w = slot.lock().unwrap();
183        for _ in 0..2 {
184            let worker = w.get_or_insert_with(Worker::spawn);
185            match worker.jobs.send(job.take().unwrap()) {
186                Ok(()) => break,
187                Err(e) => {
188                    // The thread is gone: take the job back and start another.
189                    job = Some(e.0);
190                    *w = None;
191                }
192            }
193        }
194        if job.is_some() {
195            return Err(CallError::TimedOut);
196        }
197    }
198    match rx.recv_timeout(timeout) {
199        Ok(r) => r.map_err(CallError::Service),
200        Err(_) => {
201            client.abort();
202            *slot.lock().unwrap() = None;
203            Err(CallError::TimedOut)
204        }
205    }
206}
207
208/// The app's driver predates the `wait` method (or does not allow it).
209fn wait_unsupported(msg: &str) -> bool {
210    msg.contains("unknown method")
211        || msg.contains("request_not_allowed")
212        || msg.contains("not allowed")
213}
214
215fn connect_bounded(suite: &Suite, service: &str, timeout: Duration) -> Result<Client> {
216    let (s, sv) = (suite.clone(), service.to_string());
217    match bounded(timeout, move || Client::connect(&s, &sv, APP)) {
218        Some(r) => r.map_err(svc_err),
219        None => err(format!(
220            "connecting to {service} did not finish within {}ms",
221            timeout.as_millis()
222        )),
223    }
224}
225
226/// One connect + health attempt. `Err` says why it failed, so a launch timeout can report the
227/// last reason instead of nothing.
228fn bounded_connect_health(
229    suite: &Suite,
230    service: &str,
231    timeout: Duration,
232) -> std::result::Result<Arc<Client>, String> {
233    let (s, sv) = (suite.clone(), service.to_string());
234    match bounded(timeout, move || {
235        let c = Client::connect(&s, &sv, APP).map_err(|e| format!("connect: {e}"))?;
236        c.call("health", json!({}))
237            .map_err(|e| format!("health: {e}"))?;
238        Ok(Arc::new(c))
239    }) {
240        Some(r) => r,
241        None => Err(format!(
242            "connect/health did not finish within {}ms",
243            timeout.as_millis()
244        )),
245    }
246}
247
248/// What the client polled against what the server registered, for a launch that timed out: the
249/// suite root and service the client used, the record it would read and the endpoint it names,
250/// every service directory the app created under that suite, the last connect error, and the
251/// tail of the app's own log (stdout and stderr). Read before the app is stopped.
252fn launch_timeout_diagnostics(
253    suite: &Suite,
254    service: &str,
255    pid: u32,
256    raw_log: &Path,
257    last_error: &str,
258) -> String {
259    let record = rightkit_daemon::record_path(suite, service);
260    let polled = match rightkit_daemon::read_record(suite, service) {
261        Some(r) => format!(
262            "record present, endpoint {} (answers: {})",
263            r.endpoint,
264            endpoint_open(&r.endpoint)
265        ),
266        None => format!("no readable record at {}", record.display()),
267    };
268    let registered = {
269        let runtime = suite.root().join("runtime");
270        match fs::read_dir(&runtime) {
271            Ok(entries) => {
272                let mut names: Vec<String> = entries
273                    .flatten()
274                    .map(|e| {
275                        let has = e.path().join("service.json").is_file();
276                        format!(
277                            "{}{}",
278                            e.file_name().to_string_lossy(),
279                            if has {
280                                " (service.json)"
281                            } else {
282                                " (no service.json)"
283                            }
284                        )
285                    })
286                    .collect();
287                names.sort();
288                if names.is_empty() {
289                    format!("nothing under {}", runtime.display())
290                } else {
291                    names.join(", ")
292                }
293            }
294            Err(e) => format!("{} unreadable: {e}", runtime.display()),
295        }
296    };
297    let log = fs::read(raw_log)
298        .map(|b| crate::util::tail(&String::from_utf8_lossy(&b), 3000))
299        .unwrap_or_else(|e| format!("<log unreadable: {e}>"));
300    let log = if log.trim().is_empty() {
301        "<log is empty: the app printed nothing>".to_string()
302    } else {
303        log
304    };
305    format!(
306        "; client polled suite root {} service {service} (expected endpoint {}): {polled}; \
307         last attempt: {}; services the app registered under that suite: {registered}; \
308         app pid {pid} alive: {}; app log {} tail:\n{log}",
309        suite.root().display(),
310        rightkit_daemon::endpoint_for(suite, service),
311        if last_error.is_empty() {
312            "none"
313        } else {
314            last_error
315        },
316        is_alive(pid),
317        raw_log.display()
318    )
319}
320
321fn endpoint_open(endpoint: &str) -> bool {
322    rightkit_daemon::probe_endpoint(endpoint)
323}
324
325/// Foreground app as `name` (macOS) or `pid:hwnd` (Windows). `None` means the
326/// foreground could NOT be measured; it is never treated as "unchanged".
327pub fn frontmost() -> Option<String> {
328    #[cfg(target_os = "macos")]
329    {
330        rightkit_automation::macos::background::frontmost_name()
331    }
332    #[cfg(windows)]
333    {
334        use windows_sys::Win32::UI::WindowsAndMessaging::{
335            GetForegroundWindow, GetWindowThreadProcessId,
336        };
337        unsafe {
338            let h = GetForegroundWindow();
339            if h.is_null() {
340                return None;
341            }
342            let mut pid = 0u32;
343            if GetWindowThreadProcessId(h, &mut pid) == 0 || pid == 0 {
344                return None;
345            }
346            Some(format!("{pid}:{:x}", h as usize))
347        }
348    }
349    #[cfg(not(any(target_os = "macos", windows)))]
350    {
351        None
352    }
353}
354
355/// What the Windows foreground query found.
356#[derive(Debug, Clone, Copy, PartialEq, Eq)]
357#[cfg_attr(not(windows), allow(dead_code))]
358enum Foreground {
359    /// No foreground window at all (Windows reports this transiently, and on a desktop
360    /// with no active window). Nothing we own can be in front, so this is a measurement.
361    NoWindow,
362    /// The process that owns the foreground window.
363    Pid(u32),
364    /// A foreground window exists but its owner could not be read.
365    Unreadable,
366}
367
368/// Interpret `GetForegroundWindow` / `GetWindowThreadProcessId` results.
369#[cfg_attr(not(windows), allow(dead_code))]
370fn classify_foreground(window_present: bool, thread_id: u32, pid: u32) -> Foreground {
371    if !window_present {
372        Foreground::NoWindow
373    } else if thread_id == 0 || pid == 0 {
374        Foreground::Unreadable
375    } else {
376        Foreground::Pid(pid)
377    }
378}
379
380#[cfg(windows)]
381fn foreground_pid() -> Foreground {
382    use windows_sys::Win32::UI::WindowsAndMessaging::{
383        GetForegroundWindow, GetWindowThreadProcessId,
384    };
385    unsafe {
386        let h = GetForegroundWindow();
387        if h.is_null() {
388            return classify_foreground(false, 0, 0);
389        }
390        let mut pid = 0u32;
391        let thread_id = GetWindowThreadProcessId(h, &mut pid);
392        classify_foreground(true, thread_id, pid)
393    }
394}
395
396/// The Windows background invariant: no sample found a process this session started in
397/// the foreground, and every foreground owner that existed could be read. Process
398/// identity is the started pid only; helper processes (WebView2 renderers) own no
399/// top-level window, so they cannot be the foreground window's owner.
400#[cfg_attr(not(windows), allow(dead_code))]
401fn windows_owned_never_frontmost(
402    enforce_owned: bool,
403    unreadable_owners: usize,
404    observed: &[u32],
405) -> bool {
406    !enforce_owned || (unreadable_owners == 0 && observed.is_empty())
407}
408
409/// Why the background proof failed, most specific first: an owned process WAS in front, the
410/// foreground owner could not be read, the owned executable was never registered, or other samples
411/// were unmeasurable. It is only called when the proof already failed, so it never relaxes it.
412fn owned_failure_reason(
413    observed: &[u32],
414    unavailable: usize,
415    registered: bool,
416    unmeasured: usize,
417) -> String {
418    if !observed.is_empty() {
419        format!("an owned app process was frontmost (pid {observed:?})")
420    } else if unavailable > 0 {
421        format!("the foreground owner's pid could not be read in {unavailable} sample(s); the background proof fails closed")
422    } else if !registered {
423        "the owned executable was never registered before launch, so startup samples could not be attributed".to_string()
424    } else if unmeasured > 0 {
425        format!("{unmeasured} foreground sample(s) were unmeasurable (name or owned-process inventory unavailable); the background proof fails closed")
426    } else {
427        "the background proof did not hold".to_string()
428    }
429}
430
431/// Samples the foreground on its own thread for the whole life of a control session, so
432/// a transient steal (focus leaves and returns) is caught, and an unmeasurable
433/// foreground is reported as such instead of comparing equal.
434struct FocusMonitor {
435    stop: Arc<std::sync::atomic::AtomicBool>,
436    seen: Arc<Mutex<(usize, usize, Vec<String>)>>, // (measured, unavailable, distinct states in order)
437    owned: Arc<Mutex<(Vec<u32>, Option<String>)>>, // primary PID(s), exact executable prefix
438    enforce_owned: bool,
439    owned_frontmost: Arc<Mutex<Vec<u32>>>,
440    /// Foreground samples whose owning pid could not be read (Windows).
441    #[cfg_attr(not(windows), allow(dead_code))]
442    unreadable_owners: Arc<std::sync::atomic::AtomicUsize>,
443    thread: Option<std::thread::JoinHandle<()>>,
444}
445
446impl FocusMonitor {
447    fn start(enforce_owned: bool) -> FocusMonitor {
448        let seen: Arc<Mutex<(usize, usize, Vec<String>)>> = Arc::new(Mutex::new((0, 0, vec![])));
449        let owned: Arc<Mutex<(Vec<u32>, Option<String>)>> = Arc::new(Mutex::new((vec![], None)));
450        let owned_frontmost = Arc::new(Mutex::new(vec![]));
451        let unreadable_owners = Arc::new(std::sync::atomic::AtomicUsize::new(0));
452        let record = move |seen: &Mutex<(usize, usize, Vec<String>)>,
453                           _owned: &Mutex<(Vec<u32>, Option<String>)>,
454                           _owned_frontmost: &Mutex<Vec<u32>>,
455                           _unreadable: &std::sync::atomic::AtomicUsize| {
456            {
457                let mut g = seen.lock().unwrap_or_else(|e| e.into_inner());
458                match frontmost() {
459                    Some(f) => {
460                        g.0 += 1;
461                        if g.2.last() != Some(&f) {
462                            g.2.push(f);
463                        }
464                    }
465                    None => g.1 += 1,
466                }
467            }
468            #[cfg(windows)]
469            if enforce_owned {
470                match foreground_pid() {
471                    Foreground::Pid(pid) => {
472                        let owned = _owned.lock().unwrap_or_else(|e| e.into_inner()).0.clone();
473                        if owned.contains(&pid) {
474                            let mut observed =
475                                _owned_frontmost.lock().unwrap_or_else(|e| e.into_inner());
476                            if !observed.contains(&pid) {
477                                observed.push(pid);
478                            }
479                        }
480                    }
481                    Foreground::NoWindow => {}
482                    Foreground::Unreadable => {
483                        _unreadable.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
484                    }
485                }
486            }
487            #[cfg(target_os = "macos")]
488            if enforce_owned {
489                match rightkit_automation::macos::background::frontmost_pid() {
490                    Some(pid) => {
491                        let (primary, prefix) =
492                            _owned.lock().unwrap_or_else(|e| e.into_inner()).clone();
493                        let mut candidates = primary;
494                        let mut inventory_ok = true;
495                        if let Some(prefix) = prefix {
496                            match pids_with_command_prefix(&prefix) {
497                                Ok(pids) => candidates.extend(pids),
498                                Err(_) => inventory_ok = false,
499                            }
500                        }
501                        if !inventory_ok {
502                            let mut g = seen.lock().unwrap_or_else(|e| e.into_inner());
503                            g.1 += 1;
504                        }
505                        if candidates.into_iter().any(|p| p == pid as u32) {
506                            let mut observed =
507                                _owned_frontmost.lock().unwrap_or_else(|e| e.into_inner());
508                            if !observed.contains(&(pid as u32)) {
509                                observed.push(pid as u32);
510                            }
511                        }
512                    }
513                    None => {
514                        // An unmeasurable PID makes the strict background proof fail.
515                        _unreadable.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
516                        let mut g = seen.lock().unwrap_or_else(|e| e.into_inner());
517                        g.1 += 1;
518                    }
519                }
520            }
521        };
522        record(&seen, &owned, &owned_frontmost, &unreadable_owners);
523        let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
524        let (s2, seen2, owned2, observed2, unreadable2) = (
525            stop.clone(),
526            seen.clone(),
527            owned.clone(),
528            owned_frontmost.clone(),
529            unreadable_owners.clone(),
530        );
531        let thread = std::thread::spawn(move || {
532            while !s2.load(std::sync::atomic::Ordering::SeqCst) {
533                std::thread::sleep(Duration::from_millis(20));
534                record(&seen2, &owned2, &observed2, &unreadable2);
535            }
536        });
537        FocusMonitor {
538            stop,
539            seen,
540            owned,
541            enforce_owned,
542            owned_frontmost,
543            unreadable_owners,
544            thread: Some(thread),
545        }
546    }
547    fn track_primary(&self, pid: u32) {
548        let mut owned = self.owned.lock().unwrap_or_else(|e| e.into_inner());
549        if !owned.0.contains(&pid) {
550            owned.0.push(pid);
551        }
552    }
553    #[cfg(target_os = "macos")]
554    fn track_prefix(&self, prefix: String) {
555        self.owned.lock().unwrap_or_else(|e| e.into_inner()).1 = Some(prefix);
556    }
557    /// `(unchanged, states, owned_never_frontmost, owned_frontmost_pids, pid_unavailable_samples,
558    /// failure reason)`.
559    fn finish(&mut self) -> (bool, Vec<String>, bool, Vec<u32>, usize, Option<String>) {
560        self.stop.store(true, std::sync::atomic::Ordering::SeqCst);
561        if let Some(t) = self.thread.take() {
562            let _ = t.join();
563        }
564        let g = self.seen.lock().unwrap_or_else(|e| e.into_inner());
565        let unchanged = g.0 > 0 && g.1 == 0 && g.2.len() == 1;
566        let observed = self
567            .owned_frontmost
568            .lock()
569            .unwrap_or_else(|e| e.into_inner())
570            .clone();
571        #[cfg(target_os = "macos")]
572        // Unverified launch interval: if the executable identity was never registered
573        // before launch, startup samples could not be attributed, so never claim success.
574        let identity_registered = self
575            .owned
576            .lock()
577            .unwrap_or_else(|e| e.into_inner())
578            .1
579            .is_some();
580        #[cfg(target_os = "macos")]
581        let owned_never_frontmost =
582            !self.enforce_owned || (identity_registered && g.1 == 0 && observed.is_empty());
583        #[cfg(windows)]
584        let owned_never_frontmost = windows_owned_never_frontmost(
585            self.enforce_owned,
586            self.unreadable_owners
587                .load(std::sync::atomic::Ordering::SeqCst),
588            &observed,
589        );
590        #[cfg(not(any(target_os = "macos", windows)))]
591        // No native foreground-PID sampler exists on this target; background claims fail
592        // closed instead of treating name-only sampling as proof.
593        let owned_never_frontmost = !self.enforce_owned;
594        let unavailable = self
595            .unreadable_owners
596            .load(std::sync::atomic::Ordering::SeqCst);
597        #[cfg(target_os = "macos")]
598        let registered = identity_registered;
599        #[cfg(not(target_os = "macos"))]
600        let registered = true;
601        let failure = (!owned_never_frontmost)
602            .then(|| owned_failure_reason(&observed, unavailable, registered, g.1));
603        (
604            unchanged,
605            g.2.clone(),
606            owned_never_frontmost,
607            observed,
608            unavailable,
609            failure,
610        )
611    }
612}
613
614/// Caller environment must not put secrets in a process listing: on macOS
615/// `open --env K=V` exposes values in argv. Callers may supply only `RIGHTKIT_*` keys;
616/// harness-owned HOME/temp/profile paths are added separately.
617fn check_env(env: &[(String, String)]) -> Result<()> {
618    for (k, _) in env {
619        let up = k.to_ascii_uppercase();
620        if [
621            "TOKEN",
622            "SECRET",
623            "PASSWORD",
624            "PASSWD",
625            "API_KEY",
626            "APIKEY",
627            "PRIVATE_KEY",
628            "CREDENTIAL",
629        ]
630        .iter()
631        .any(|w| up.contains(w))
632        {
633            return err(format!("environment key {k} names a credential; secrets are never passed to a launched app through its command line or environment"));
634        }
635        #[cfg(target_os = "macos")]
636        if !up.starts_with("RIGHTKIT_") {
637            return err(format!("environment key {k} would be exposed in `open` arguments; macOS launches carry only RIGHTKIT_* keys"));
638        }
639    }
640    Ok(())
641}
642
643/// Absolute path of the executable that `open` will actually run for `bundle`, read from
644/// `Contents/Info.plist` `CFBundleExecutable`. `None` (fail closed) when the key is absent,
645/// names a path component trick, or the file is not a regular file inside `Contents/MacOS`.
646#[cfg(target_os = "macos")]
647#[doc(hidden)]
648pub fn resolve_bundle_executable(bundle: &Path) -> Option<String> {
649    let plist = bundle.join("Contents").join("Info.plist");
650    let out = crate::process::output_bounded(
651        Command::new("plutil")
652            .args(["-extract", "CFBundleExecutable", "raw", "-o", "-"])
653            .arg(&plist),
654        Duration::from_secs(30),
655    )
656    .ok()??;
657    if !out.status.success() {
658        return None;
659    }
660    let name = String::from_utf8(out.stdout).ok()?;
661    let name = name.trim();
662    if name.is_empty() || name == "." || name == ".." || name.contains('/') || name.contains('\0') {
663        return None;
664    }
665    let exe = bundle.join("Contents").join("MacOS").join(name);
666    let meta = fs::metadata(&exe).ok()?;
667    if !meta.is_file() {
668        return None;
669    }
670    Some(exe.to_string_lossy().into_owned())
671}
672
673/// Fixed bundle id suffix for a raw QA binary: one id per app, the same on every run
674/// (`app.rightkit.qa.<name>`). macOS keys privacy grants to the bundle id and path, so neither may change
675/// per run or per build.
676#[cfg(target_os = "macos")]
677fn qa_bundle_suffix(exe: &str) -> String {
678    exe.chars()
679        .map(|c| {
680            if c.is_ascii_alphanumeric() || c == '-' {
681                c
682            } else {
683                '-'
684            }
685        })
686        .collect()
687}
688
689/// The fixed per-app folder QA bundles are staged in: `<home base>/rightkit-qa-apps/<app>/`, outside the
690/// per-run home (which is deleted with the run) and outside the swept `rightkit-qa` homes. The same path
691/// on every run; bundles are replaced in place there and keep `<name>.app.previous`.
692#[cfg(target_os = "macos")]
693fn fixed_bundle_dir(name: &str) -> Result<PathBuf> {
694    let dir = crate::storage::home_base()
695        .join("rightkit-qa-apps")
696        .join(qa_bundle_suffix(name));
697    fs::create_dir_all(&dir)?;
698    Ok(dir)
699}
700
701/// Build the bundle for a raw binary at its fixed path `<dir>/<exe>.app` with the fixed id. Written to a
702/// staging bundle first (nothing runs from it) and renamed in place, keeping `<exe>.app.previous`; a build
703/// whose executable and Info.plist already match is reused untouched.
704#[cfg(target_os = "macos")]
705fn make_bundle(dir: &Path, binary: &Path, id: &str, background: bool) -> Result<PathBuf> {
706    let exe = binary
707        .file_name()
708        .and_then(|n| n.to_str())
709        .ok_or_else(|| Error("binary has no file name".into()))?;
710    let app = dir.join(format!("{exe}.app"));
711    let abs = fs::canonicalize(binary)
712        .map_err(|e| Error(format!("app binary not found: {}: {e}", binary.display())))?;
713    // Background fixtures must be non-activating before AppKit creates any window.
714    let ui_element = if background {
715        "<key>LSUIElement</key><true/>\n"
716    } else {
717        ""
718    };
719    let plist = format!(
720        "<?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"
721    );
722    let installed_exe = app.join("Contents").join("MacOS").join(exe);
723    let installed_plist = app.join("Contents").join("Info.plist");
724    if crate::inplace::same_bytes(&abs, &installed_exe)
725        && fs::read_to_string(&installed_plist).is_ok_and(|p| p == plist)
726    {
727        return Ok(app);
728    }
729    let staging = crate::inplace::staging_path(&app);
730    let _ = fs::remove_dir_all(&staging);
731    let macos = staging.join("Contents").join("MacOS");
732    fs::create_dir_all(&macos)?;
733    // Copy into internal storage; never launch a link back to the build volume.
734    fs::copy(&abs, macos.join(exe))?;
735    fs::write(staging.join("Contents").join("Info.plist"), plist)?;
736    crate::inplace::swap_in_place(&staging, &app)?;
737    Ok(app)
738}
739
740/// Stage the app bundle at its FIXED per-app path (never a per-run folder, never a per-run bundle id) and
741/// return it. Replaced in place with `<name>.app.previous` kept; nothing is ever launched from `.previous`.
742#[cfg(target_os = "macos")]
743fn stage_bundle(spec: &LaunchSpec, _ws: &QaWorkspace, _id: &str) -> Result<PathBuf> {
744    if spec.binary.extension().is_some_and(|e| e == "app") {
745        let source = fs::canonicalize(&spec.binary)?;
746        let file_name = source
747            .file_name()
748            .ok_or_else(|| Error("bundle has no file name".into()))?;
749        let dir = fixed_bundle_dir(file_name.to_string_lossy().trim_end_matches(".app"))?;
750        let bundle = dir.join(file_name);
751        let staging = crate::inplace::staging_path(&bundle);
752        let _ = fs::remove_dir_all(&staging);
753        // Preserve bundle resources, framework links, permissions & signatures.
754        let status = crate::process::status_bounded(
755            Command::new("/usr/bin/ditto").arg(&source).arg(&staging),
756            Duration::from_secs(300),
757        )?
758        .ok_or_else(|| Error("app bundle staging (ditto) did not finish within 300s".into()))?;
759        if !status.success() {
760            let _ = fs::remove_dir_all(&staging);
761            return err(format!("app bundle staging failed: {status}"));
762        }
763        if let Err(e) = check_bundle_links(&staging, &staging) {
764            let _ = fs::remove_dir_all(&staging);
765            return Err(e);
766        }
767        crate::inplace::swap_in_place(&staging, &bundle)?;
768        Ok(bundle)
769    } else {
770        let exe = spec
771            .binary
772            .file_name()
773            .and_then(|n| n.to_str())
774            .ok_or_else(|| Error("binary has no file name".into()))?;
775        let suffix = qa_bundle_suffix(exe);
776        let dir = fixed_bundle_dir(exe)?;
777        make_bundle(&dir, &spec.binary, &suffix, spec.mode != Mode::Visible)
778    }
779}
780
781#[cfg(target_os = "macos")]
782fn check_bundle_links(bundle: &Path, dir: &Path) -> Result<()> {
783    for entry in fs::read_dir(dir)? {
784        let entry = entry?;
785        let path = entry.path();
786        let kind = entry.file_type()?;
787        if kind.is_symlink() {
788            if !fs::canonicalize(&path)?.starts_with(bundle) {
789                return err(format!(
790                    "staged app bundle link escapes internal bundle: {}",
791                    path.display()
792                ));
793            }
794        } else if kind.is_dir() {
795            check_bundle_links(bundle, &path)?;
796        }
797    }
798    Ok(())
799}
800
801fn launch_env(
802    spec: &LaunchSpec,
803    ws: &QaWorkspace,
804    suite: &Suite,
805    service: &str,
806) -> Result<Vec<(String, String)>> {
807    // Caller values retain the credential/argv restrictions. Workspace values are
808    // harness-owned paths, so HOME, TMPDIR & profile keys can safely travel via open.
809    check_env(&spec.env)?;
810    let mut env = ws.env.clone();
811    env.insert("RIGHTKIT_CONTROL_SERVICE".into(), service.into());
812    env.insert(
813        "RIGHTKIT_SUITE_ROOT".into(),
814        suite.root().to_string_lossy().into(),
815    );
816    env.insert(
817        "RIGHTKIT_QA_HIDDEN".into(),
818        if spec.mode == Mode::Hidden { "1" } else { "0" }.into(),
819    );
820    env.insert(
821        "RIGHTKIT_QA_FOCUS".into(),
822        if spec.focus { "1" } else { "0" }.into(),
823    );
824    env.insert(
825        "RIGHTKIT_QA_BACKGROUND".into(),
826        if spec.mode != Mode::Visible { "1" } else { "0" }.into(),
827    );
828    for (key, value) in &spec.env {
829        let path = Path::new(value);
830        if path.is_absolute()
831            && (!path.starts_with(&ws.home)
832                || path
833                    .components()
834                    .any(|c| c == std::path::Component::ParentDir))
835        {
836            return err(format!(
837                "environment path {key} must be staged inside the QA app home"
838            ));
839        }
840        if let Some(owned) = env.get(key) {
841            let app_path = ws.env.contains_key(key)
842                && key != "RIGHTKIT_SUITE_ROOT"
843                && Path::new(owned).is_absolute();
844            let internal = Path::new(value).starts_with(&ws.home)
845                && !Path::new(value)
846                    .components()
847                    .any(|c| c == std::path::Component::ParentDir);
848            if value != owned && !(app_path && internal) {
849                return err(format!(
850                    "environment key {key} overrides a harness-owned launch value"
851                ));
852            }
853        }
854        env.insert(key.clone(), value.clone());
855    }
856    Ok(env.into_iter().collect())
857}
858
859#[cfg(target_os = "macos")]
860fn open_command(
861    spec: &LaunchSpec,
862    ws: &QaWorkspace,
863    env: &[(String, String)],
864    log_path: &Path,
865    bundle: &Path,
866) -> Command {
867    let mut cmd = Command::new("open");
868    cmd.current_dir(&ws.home);
869    match spec.mode {
870        Mode::Hidden => cmd.args(["-g", "-j", "-n"]),
871        Mode::Background => cmd.args(["-g", "-n"]),
872        Mode::Visible => cmd.args(["-n"]),
873    };
874    for (k, v) in env {
875        cmd.env(k, v).arg("--env").arg(format!("{k}={v}"));
876    }
877    cmd.arg("--stdout")
878        .arg(log_path)
879        .arg("--stderr")
880        .arg(log_path)
881        .arg(bundle);
882    cmd
883}
884
885#[cfg(target_os = "macos")]
886/// Pids whose command line STARTS with `prefix` (so `open`, which merely carries the
887/// bundle path as an argument, is never mistaken for the app).
888fn pids_with_command_prefix(prefix: &str) -> std::result::Result<Vec<u32>, String> {
889    let out = crate::process::output_bounded(
890        Command::new("ps").args(["-ax", "-o", "pid=,command="]),
891        Duration::from_secs(30),
892    )
893    .map_err(|e| format!("ps PID inventory failed: {e}"))?
894    .ok_or_else(|| "ps PID inventory did not finish within 30s".to_string())?;
895    if !out.status.success() {
896        return Err(format!("ps PID inventory exited with {}", out.status));
897    }
898    let mut pids = vec![];
899    for line in String::from_utf8_lossy(&out.stdout).lines() {
900        let line = line.trim_start();
901        let Some((pid, cmd)) = line.split_once(' ') else {
902            continue;
903        };
904        if cmd.trim_start().starts_with(prefix) {
905            pids.push(
906                pid.trim()
907                    .parse::<u32>()
908                    .map_err(|e| format!("invalid PID inventory row: {e}"))?,
909            );
910        }
911    }
912    Ok(pids)
913}
914
915pub fn launch(spec: &LaunchSpec, ws: &QaWorkspace, tracker: &Tracker) -> Result<Control> {
916    // An installed app is never driven unless installed-QA mode is on (see `installed`).
917    crate::installed::refuse_installed(&spec.binary, "UI app binary")?;
918    if !spec.binary.exists() {
919        return err(format!(
920            "UI app binary not found: {}",
921            spec.binary.display()
922        ));
923    }
924    // Before anything is created or started: the QA home must be a QA root this runner made, on the
925    // internal disk, and clear of every user-data location (see `userdata`). No opt-out.
926    let guard = crate::userdata::Guard::for_workspace(ws)?;
927    let id = new_id().replace('-', "");
928    let service = format!("control-{}", &id[..8]);
929    // Isolated per-run suite root: the app's service.json never collides with an installed app's.
930    let suite =
931        Suite::at(ws.home.join(format!("suite-{}", &id[..8]))).map_err(|e| Error(e.to_string()))?;
932    let log_path = ws.evidence_dir.join(format!("app-{}.log", &id[..8]));
933    // launchd-started apps cannot open log files on external volumes (`open` fails with
934    // -10810), so the live log goes to the system temp dir and is copied into the
935    // evidence dir when the app stops.
936    // Kept in a subdirectory: apps commonly list or watch HOME (file explorers, recents), and
937    // a live log written directly into it is a change source that can feed back into the app.
938    let log_dir = ws.home.join(".rightkit-qa");
939    fs::create_dir_all(&log_dir)?;
940    let raw_log = log_dir.join(format!("app-{}.log", &id[..8]));
941    let env = launch_env(spec, ws, &suite, &service)?;
942    guard.check_dir(&ws.home)?;
943    guard.check_env(&env)?;
944    // The app must be told its isolated data directory; otherwise it resolves its own default.
945    crate::userdata::check_app_data_dir(&guard, &spec.binary, &env)?;
946    let frontmost_before = frontmost();
947    let focus = FocusMonitor::start(spec.mode != Mode::Visible);
948    let deadline = Instant::now() + spec.startup_timeout;
949
950    // The executable identity is registered inside start_process BEFORE `open`, so
951    // startup foreground samples are attributed; the pid follows once known.
952    let (pid, child) = start_process(spec, ws, &env, &raw_log, &id, tracker, &focus, deadline)?;
953    focus.track_primary(pid);
954    tracker.register(pid, &spec.label);
955    // Disposal may end what this app started and left in the home, and nothing else.
956    crate::workspace::record_launched(ws, pid);
957
958    let mut control = Control {
959        pid,
960        service: service.clone(),
961        log_path,
962        raw_log,
963        suite: suite.clone(),
964        endpoint: String::new(),
965        client: Mutex::new(None),
966        child,
967        tracker: tracker.clone(),
968        frontmost_before,
969        focus: Some(focus),
970        request_timeout: Duration::from_secs(20),
971        worker: Mutex::new(None),
972        pace_ms: std::env::var("RIGHTKIT_QA_PACE_MS")
973            .ok()
974            .and_then(|v| v.trim().parse().ok())
975            .unwrap_or(0),
976        stopped: false,
977        focus_requested: spec.focus,
978    };
979    let mut last_error: String;
980    loop {
981        // Each attempt is bounded by the startup deadline: a hung host must not defeat it.
982        let left = deadline.saturating_duration_since(Instant::now());
983        match bounded_connect_health(&suite, &service, left.min(Duration::from_secs(5))) {
984            Ok(c) => {
985                control.endpoint = c.record().endpoint.clone();
986                *control.client.lock().unwrap() = Some(c);
987                break;
988            }
989            Err(e) => last_error = e,
990        }
991        if !is_alive(pid) {
992            let tail = fs::read(&control.raw_log)
993                .map(|b| crate::util::tail(&String::from_utf8_lossy(&b), 1500))
994                .unwrap_or_default();
995            return err(format!(
996                "app exited before its control server came up (pid {pid}); log tail: {tail}"
997            ));
998        }
999        if Instant::now() >= deadline {
1000            // Read everything while the app is still running, then stop it: `stop` copies the
1001            // live log into the evidence dir, which outlives the home that holds `raw_log`.
1002            let diagnostics =
1003                launch_timeout_diagnostics(&suite, &service, pid, &control.raw_log, &last_error);
1004            let kept = control.log_path.clone();
1005            let stopped = control.stop();
1006            let saved = match &stopped {
1007                Ok(_) if kept.is_file() => format!("; full app log kept at {}", kept.display()),
1008                Ok(_) => String::new(),
1009                Err(e) => format!("; stopping the app: {e}"),
1010            };
1011            return err(format!(
1012                "control service did not answer within {}ms; was the app built with its control feature and launched with RIGHTKIT_CONTROL_SERVICE?{}{diagnostics}{saved}",
1013                spec.startup_timeout.as_millis(),
1014                non_interactive_hint()
1015            ));
1016        }
1017        sleep_ms(100);
1018    }
1019    // The server comes up before the window exists (hidden launch): wait until the page
1020    // itself answers, so the first scenario step never races window creation.
1021    loop {
1022        // `about:blank` reports "complete" before the app's page has even started loading.
1023        if let Ok(Value::String(state)) =
1024            control.eval("return document.readyState + ' ' + location.href;")
1025        {
1026            let (ready, href) = state.split_once(' ').unwrap_or((&state, ""));
1027            if (ready == "complete" || ready == "interactive") && href != "about:blank" {
1028                break;
1029            }
1030        }
1031        if Instant::now() >= deadline {
1032            let alive = is_alive(pid);
1033            let _ = control.stop();
1034            return err(format!(
1035                "app window never became ready within {}ms (process alive: {}){}",
1036                spec.startup_timeout.as_millis(),
1037                alive,
1038                non_interactive_hint()
1039            ));
1040        }
1041        sleep_ms(20);
1042    }
1043    if spec.focus {
1044        if let Err(e) = control.focus_window() {
1045            let _ = control.stop();
1046            return err(format!(
1047                "focus requested but the window could not be focused: {}",
1048                e.0
1049            ));
1050        }
1051    }
1052    Ok(control)
1053}
1054
1055/// Extra explanation for a launch timeout when the harness itself runs outside the
1056/// interactive desktop (SSH or a service on Windows): a WebView2 app launched from there
1057/// never gets a ready window. Empty everywhere else.
1058fn non_interactive_hint() -> String {
1059    #[cfg(windows)]
1060    if let Some(why) = windows_non_interactive_reason() {
1061        return format!(
1062            ". 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)"
1063        );
1064    }
1065    String::new()
1066}
1067
1068/// `Some(reason)` when this process is in session 0 or on a window station that is not
1069/// visible (the shape of an SSH or service logon); `None` on an interactive desktop or when
1070/// it cannot be determined.
1071#[cfg(windows)]
1072fn windows_non_interactive_reason() -> Option<String> {
1073    use std::ffi::c_void;
1074    #[repr(C)]
1075    struct UserObjectFlags {
1076        inherit: i32,
1077        reserved: i32,
1078        flags: u32,
1079    }
1080    #[link(name = "user32")]
1081    extern "system" {
1082        fn GetProcessWindowStation() -> *mut c_void;
1083        fn GetUserObjectInformationW(
1084            h: *mut c_void,
1085            index: i32,
1086            info: *mut c_void,
1087            len: u32,
1088            needed: *mut u32,
1089        ) -> i32;
1090    }
1091    #[link(name = "kernel32")]
1092    extern "system" {
1093        fn GetCurrentProcessId() -> u32;
1094        fn ProcessIdToSessionId(pid: u32, session: *mut u32) -> i32;
1095    }
1096    const UOI_FLAGS: i32 = 1;
1097    const WSF_VISIBLE: u32 = 1;
1098    let mut session = 0u32;
1099    // SAFETY: plain Win32 queries on this process with correctly sized out-parameters.
1100    unsafe {
1101        if ProcessIdToSessionId(GetCurrentProcessId(), &mut session) != 0 && session == 0 {
1102            return Some("session 0".into());
1103        }
1104        let station = GetProcessWindowStation();
1105        if station.is_null() {
1106            return None;
1107        }
1108        let mut info = UserObjectFlags {
1109            inherit: 0,
1110            reserved: 0,
1111            flags: 0,
1112        };
1113        let mut needed = 0u32;
1114        if GetUserObjectInformationW(
1115            station,
1116            UOI_FLAGS,
1117            &mut info as *mut _ as *mut c_void,
1118            std::mem::size_of::<UserObjectFlags>() as u32,
1119            &mut needed,
1120        ) != 0
1121            && info.flags & WSF_VISIBLE == 0
1122        {
1123            return Some(format!("session {session}, non-visible window station"));
1124        }
1125    }
1126    None
1127}
1128
1129#[allow(clippy::too_many_arguments)]
1130fn start_process(
1131    spec: &LaunchSpec,
1132    ws: &QaWorkspace,
1133    env: &[(String, String)],
1134    log_path: &Path,
1135    id: &str,
1136    _tracker: &Tracker,
1137    focus: &FocusMonitor,
1138    deadline: Instant,
1139) -> Result<(u32, Option<OwnedChild>)> {
1140    #[cfg(target_os = "macos")]
1141    {
1142        let bundle = stage_bundle(spec, ws, id)?;
1143        let needle = format!("{}/Contents/MacOS/", bundle.display());
1144        // Register the REAL executable (CFBundleExecutable) before `open`. When it cannot be
1145        // resolved and validated, register nothing: the launch interval stays unverified and
1146        // `owned_never_frontmost` fails closed instead of matching a guessed name.
1147        if let Some(executable) = resolve_bundle_executable(&bundle) {
1148            focus.track_prefix(executable);
1149        }
1150        let mut cmd = open_command(spec, ws, env, log_path, &bundle);
1151        let st = crate::process::status_bounded(&mut cmd, Duration::from_secs(60))?
1152            .ok_or_else(|| Error("`open` did not return within 60s".into()))?;
1153        if !st.success() {
1154            return err(format!("open exited {st}"));
1155        }
1156        loop {
1157            if let Some(pid) = pids_with_command_prefix(&needle)
1158                .ok()
1159                .and_then(|pids| pids.into_iter().next())
1160            {
1161                return Ok((pid, None));
1162            }
1163            if Instant::now() >= deadline {
1164                return err("launched app pid not found");
1165            }
1166            sleep_ms(100);
1167        }
1168    }
1169    #[cfg(not(target_os = "macos"))]
1170    {
1171        let _ = (ws, id);
1172        let log = fs::File::create(log_path)?;
1173        let log2 = log.try_clone()?;
1174        let mut cmd = Command::new(crate::util::plain_path(&spec.binary));
1175        cmd.current_dir(&ws.home)
1176            .stdin(Stdio::null())
1177            .stdout(Stdio::from(log))
1178            .stderr(Stdio::from(log2));
1179        for (k, v) in env {
1180            cmd.env(k, v);
1181        }
1182        let mut owned = OwnedCommand::from_command(cmd);
1183        if spec.mode != Mode::Visible {
1184            owned.windows_hide();
1185        }
1186        let _ = deadline;
1187        let child = owned
1188            .spawn()
1189            .map_err(|e| Error(format!("failed to start {}: {e}", spec.binary.display())))?;
1190        // Attribute foreground samples to the app from its first instant.
1191        focus.track_primary(child.id());
1192        Ok((child.id(), Some(child)))
1193    }
1194}
1195
1196impl Control {
1197    /// Fail when a system permission dialog is known to be showing for this app. TODO: the
1198    /// automation layer cannot enumerate windows by owner (`UserNotificationCenter`,
1199    /// `CoreServicesUIAgent`), so this is a no-op that returns `Ok` today; see
1200    /// [`crate::userdata::live_permission_dialog_check`]. Use
1201    /// [`StopReport::permission_dialog`] for the evidence that does exist.
1202    pub fn assert_no_permission_dialog(&self) -> Result<()> {
1203        match crate::userdata::live_permission_dialog_check(self.pid) {
1204            crate::userdata::DialogCheck::Dialog(owner) => err(format!(
1205                "a system permission dialog ({owner}) appeared for the app under test"
1206            )),
1207            _ => Ok(()),
1208        }
1209    }
1210
1211    pub fn set_request_timeout(&mut self, t: Duration) {
1212        self.request_timeout = t;
1213    }
1214
1215    /// One bounded request. The deadline is real: when it passes the connection is
1216    /// aborted (unblocking the read) and the call fails. A request that may already have
1217    /// reached the app is replayed only when the method is a pure read; effects
1218    /// (click, key, type, command, eval, ...) are never silently executed twice.
1219    fn rpc(&self, method: &str, params: Value) -> Result<Value> {
1220        self.rpc_within(method, params, self.request_timeout)
1221    }
1222
1223    fn rpc_within(&self, method: &str, params: Value, request_timeout: Duration) -> Result<Value> {
1224        let replayable = matches!(method, "health" | "dom" | "ax" | "screenshot" | "move");
1225        let mut g = self.client.lock().unwrap();
1226        for attempt in 0..2 {
1227            let client = match g.as_ref() {
1228                Some(c) => c.clone(),
1229                None => {
1230                    // Not yet sent anything: reconnecting is always safe.
1231                    let c = Arc::new(connect_bounded(
1232                        &self.suite,
1233                        &self.service,
1234                        request_timeout,
1235                    )?);
1236                    *g = Some(c.clone());
1237                    c
1238                }
1239            };
1240            match call_bounded(
1241                &client,
1242                &self.worker,
1243                method,
1244                params.clone(),
1245                request_timeout,
1246            ) {
1247                Ok(v) => return Ok(v),
1248                Err(CallError::TimedOut) => {
1249                    *g = None;
1250                    return err(format!(
1251                        "{method}: no reply within {}ms; connection aborted",
1252                        request_timeout.as_millis()
1253                    ));
1254                }
1255                Err(CallError::Service(e)) if e.retryable && replayable && attempt == 0 => {
1256                    *g = None
1257                }
1258                Err(CallError::Service(e)) => {
1259                    if e.retryable {
1260                        *g = None;
1261                    }
1262                    let mut m = svc_err(e);
1263                    if !replayable {
1264                        m = Error(format!("{} (not replayed: {method} has effects and the first attempt may have been applied)", m.0));
1265                    }
1266                    return Err(m);
1267                }
1268            }
1269        }
1270        err("control rpc failed")
1271    }
1272
1273    fn json(&self, method: &str, params: Value) -> Result<Value> {
1274        let v = self.rpc(method, params)?;
1275        Self::ok_or_error(method, v)
1276    }
1277
1278    fn ok_or_error(method: &str, v: Value) -> Result<Value> {
1279        if v.get("ok") == Some(&Value::Bool(false)) {
1280            return err(format!(
1281                "{method}: {}",
1282                v.get("error")
1283                    .and_then(Value::as_str)
1284                    .unwrap_or("unknown error")
1285            ));
1286        }
1287        Ok(v)
1288    }
1289
1290    /// Add the session's input pacing (`paceMs`), when one is set, to an input request.
1291    fn paced(&self, mut p: Value) -> Value {
1292        if self.pace_ms > 0 {
1293            p["paceMs"] = json!(self.pace_ms);
1294        }
1295        p
1296    }
1297    /// Gap between native input events the app inserts (default 0, no gap). Also set from
1298    /// `RIGHTKIT_QA_PACE_MS` at launch. The former fixed pauses were about click 40-50, drag 16,
1299    /// key 20, type 8 ms; use this for a page that needs real time between events.
1300    pub fn set_pace_ms(&mut self, ms: u64) {
1301        self.pace_ms = ms;
1302    }
1303    /// Run `steps` (`(method, params)`) in ONE round trip, stopping at the first failure. Every step is
1304    /// checked against this app's allowlist and passes the effect gate and input lease on its own.
1305    /// Returns each step's result; the error names the failing step. A batch is never replayed.
1306    pub fn batch(&self, steps: &[(&str, Value)]) -> Result<Vec<Value>> {
1307        let steps: Vec<Value> = steps
1308            .iter()
1309            .map(|(m, p)| {
1310                let p = if matches!(*m, "click" | "drag" | "key" | "type") {
1311                    self.paced(p.clone())
1312                } else {
1313                    p.clone()
1314                };
1315                json!({"method": m, "params": p})
1316            })
1317            .collect();
1318        // Allow the whole sequence: the per-request bound applies to each step, not the sum.
1319        let budget = self.request_timeout * (steps.len().max(1) as u32).min(30);
1320        let v = self.rpc_within("batch", json!({"steps": steps}), budget)?;
1321        let v = Self::ok_or_error("batch", v)?;
1322        Ok(v.get("results")
1323            .and_then(Value::as_array)
1324            .cloned()
1325            .unwrap_or_default())
1326    }
1327
1328    pub fn health(&self) -> Result<Value> {
1329        self.rpc("health", json!({}))
1330    }
1331    /// Resize hidden native content without screen clamping or activation.
1332    /// Waits for two matching page measurements 500 ms apart, after shell placement.
1333    /// Returns measured CSS dimensions; Windows or visible windows may report OS size limits.
1334    /// Unsettled sizes or placement timeouts are errors; this effect is never replayed.
1335    pub fn set_viewport(&self, width: u32, height: u32) -> Result<(u32, u32)> {
1336        let v = self.json("set_viewport", json!({"width": width, "height": height}))?;
1337        settled_viewport_size(&v)
1338    }
1339    /// Make the window key and focused (see `LaunchSpec::focus`). Opt-in; also callable mid-run.
1340    pub fn focus_window(&self) -> Result<Value> {
1341        self.json("focus", json!({}))
1342    }
1343    /// True when this launch asked for a key/focused window, so the never-frontmost proof does not apply.
1344    pub fn focus_requested(&self) -> bool {
1345        self.focus_requested
1346    }
1347    /// Prove the allowlist: an app that is not on it is refused at hello. Returns the error code.
1348    pub fn unlisted_app_probe(&self) -> Result<String> {
1349        match Client::connect(&self.suite, &self.service, "not-allowlisted-probe") {
1350            Ok(_) => err("an unlisted app was accepted by the control service"),
1351            Err(e) => Ok(e.code),
1352        }
1353    }
1354    pub fn click(&self, x: f64, y: f64, button: &str, count: u32) -> Result<()> {
1355        self.json(
1356            "click",
1357            self.paced(json!({"x": x, "y": y, "button": button, "count": count})),
1358        )
1359        .map(|_| ())
1360    }
1361    /// [`click`](Self::click) holding modifiers (`"shift"`, `"cmd"`, `"alt"`, `"ctrl"`). They
1362    /// travel on the native mouse events, so the page sees `e.shiftKey` etc. as real input.
1363    pub fn click_with(
1364        &self,
1365        x: f64,
1366        y: f64,
1367        button: &str,
1368        count: u32,
1369        modifiers: &[&str],
1370    ) -> Result<()> {
1371        self.json(
1372            "click",
1373            self.paced(
1374                json!({"x": x, "y": y, "button": button, "count": count, "modifiers": modifiers}),
1375            ),
1376        )
1377        .map(|_| ())
1378    }
1379    pub fn move_to(&self, x: f64, y: f64) -> Result<()> {
1380        self.json("move", json!({"x": x, "y": y})).map(|_| ())
1381    }
1382    /// One native pointer phase (`down`, `drag`, `up`, `move`) in viewport CSS
1383    /// pixels. Use down/drag/up for a scrub split across requests; keep the same
1384    /// button and modifiers on each phase. Hidden launches use the embedded path.
1385    pub fn pointer(
1386        &self,
1387        phase: &str,
1388        x: f64,
1389        y: f64,
1390        button: &str,
1391        modifiers: &[&str],
1392    ) -> Result<()> {
1393        self.json(
1394            "pointer",
1395            json!({"phase": phase, "x": x, "y": y, "button": button, "modifiers": modifiers}),
1396        )
1397        .map(|_| ())
1398    }
1399    pub fn drag(&self, from: (f64, f64), to: (f64, f64), steps: u32) -> Result<()> {
1400        self.json(
1401            "drag",
1402            self.paced(json!({"x1": from.0, "y1": from.1, "x2": to.0, "y2": to.1, "steps": steps})),
1403        )
1404        .map(|_| ())
1405    }
1406    pub fn wheel(&self, x: f64, y: f64, dx: f64, dy: f64) -> Result<()> {
1407        self.json("wheel", json!({"x": x, "y": y, "dx": dx, "dy": dy}))
1408            .map(|_| ())
1409    }
1410    pub fn key(&self, spec: &str) -> Result<()> {
1411        self.json("key", self.paced(json!({"key": spec})))
1412            .map(|_| ())
1413    }
1414    pub fn type_text(&self, text: &str) -> Result<()> {
1415        self.json("type", self.paced(json!({"text": text})))
1416            .map(|_| ())
1417    }
1418    /// Evaluate a JS function body (`return ...`) in the page; returns its JSON value.
1419    pub fn eval(&self, js: &str) -> Result<Value> {
1420        Ok(self
1421            .json("eval", json!({"js": js}))?
1422            .get("value")
1423            .cloned()
1424            .unwrap_or(Value::Null))
1425    }
1426    pub fn dom(&self, selector: &str) -> Result<Vec<Value>> {
1427        let v = self.json("dom", json!({"selector": selector}))?;
1428        Ok(v.get("elements")
1429            .and_then(Value::as_array)
1430            .cloned()
1431            .unwrap_or_default())
1432    }
1433    pub fn ax(&self) -> Result<Vec<Value>> {
1434        let v = self.json("ax", json!({}))?;
1435        Ok(v.get("nodes")
1436            .and_then(Value::as_array)
1437            .cloned()
1438            .unwrap_or_default())
1439    }
1440    pub fn screenshot_png(&self) -> Result<Vec<u8>> {
1441        let v = self.json("screenshot", json!({"inline": true}))?;
1442        // The server masks credential elements before the image leaves the page; a server that
1443        // does not report `masked` predates redaction and its captures are refused.
1444        if v.get("masked").and_then(Value::as_u64).is_none() {
1445            return err("control server does not redact screenshots; capture refused");
1446        }
1447        let body = match v.get("data").and_then(Value::as_str) {
1448            Some(b64) => crate::util::base64_decode(b64)
1449                .ok_or_else(|| Error("screenshot data is not valid base64".into()))?,
1450            // A server older than the inline reply writes the PNG and returns its path.
1451            None => {
1452                let path = PathBuf::from(
1453                    v.get("path")
1454                        .and_then(Value::as_str)
1455                        .ok_or_else(|| Error("screenshot returned no data or path".into()))?,
1456                );
1457                let body = fs::read(&path)?;
1458                let _ = fs::remove_file(&path);
1459                body
1460            }
1461        };
1462        if body.len() < 8 || &body[1..4] != b"PNG" {
1463            return err("screenshot method did not return a PNG");
1464        }
1465        Ok(body)
1466    }
1467    pub fn screenshot_to(&self, path: &Path) -> Result<()> {
1468        if let Some(p) = path.parent() {
1469            fs::create_dir_all(p)?;
1470        }
1471        fs::write(path, self.screenshot_png()?)?;
1472        Ok(())
1473    }
1474    /// A typed app command registered with `Control::command(name, f)`. The server
1475    /// hands the command its `args` string verbatim.
1476    pub fn command(&self, name: &str, args: &Value) -> Result<Value> {
1477        let args = match args {
1478            Value::String(s) => s.clone(),
1479            Value::Null => String::new(),
1480            other => other.to_string(),
1481        };
1482        let v = self.json("command", json!({"name": name, "args": args}))?;
1483        Ok(v.get("result").cloned().unwrap_or(Value::Null))
1484    }
1485
1486    /// Centre of the first element matching `selector`, in viewport CSS px.
1487    pub fn center_of(&self, selector: &str) -> Result<(f64, f64)> {
1488        let els = self.dom(selector)?;
1489        let first = els
1490            .first()
1491            .ok_or_else(|| Error(format!("selector matched no element: {selector}")))?;
1492        let r: Vec<f64> = first
1493            .get("rect")
1494            .and_then(Value::as_array)
1495            .map(|a| a.iter().filter_map(Value::as_f64).collect())
1496            .unwrap_or_default();
1497        if r.len() < 4 || r[2] <= 0.0 || r[3] <= 0.0 {
1498            return err(format!("{selector} has no visible box"));
1499        }
1500        Ok((r[0] + r[2] / 2.0, r[1] + r[3] / 2.0))
1501    }
1502    pub fn click_selector(&self, selector: &str) -> Result<()> {
1503        let (x, y) = self.center_of(selector)?;
1504        self.click(x, y, "left", 1)
1505    }
1506
1507    /// Click `selector`, then wait for `done_js` (a JS body, see [`Control::wait_eval`]) to become
1508    /// truthy; when the wait times out, click again. For controls that drop the first click
1509    /// (an unfocused window, a control still mounting). Gives up after `attempts` clicks.
1510    pub fn click_until(
1511        &self,
1512        selector: &str,
1513        done_js: &str,
1514        attempts: u32,
1515        wait: Duration,
1516    ) -> Result<Value> {
1517        let mut last = String::new();
1518        for _ in 0..attempts.max(1) {
1519            self.click_selector(selector)?;
1520            match self.wait_eval(done_js, wait) {
1521                Ok(v) => return Ok(v),
1522                Err(e) => last = e.0,
1523            }
1524        }
1525        err(format!(
1526            "click_until `{selector}`: `{done_js}` never became true after {} click(s): {last}",
1527            attempts.max(1)
1528        ))
1529    }
1530
1531    /// The live app log (stdout and stderr) while the app runs. [`Control::stop`] copies it into
1532    /// the evidence directory; this path is the source.
1533    pub fn live_log_path(&self) -> &Path {
1534        &self.raw_log
1535    }
1536
1537    /// The last `max_chars` bytes of the live app log (empty when it is unreadable).
1538    pub fn log_tail(&self, max_chars: usize) -> String {
1539        fs::read(&self.raw_log)
1540            .map(|b| crate::util::tail(&String::from_utf8_lossy(&b), max_chars))
1541            .unwrap_or_default()
1542    }
1543
1544    /// Start recording uncaught page errors and unhandled promise rejections into
1545    /// `window.__rkPageErrors`. Idempotent. Errors raised before this call are not recorded.
1546    pub fn record_page_errors(&self) -> Result<()> {
1547        self.eval(
1548            "if (!window.__rkPageErrorsInstalled) { window.__rkPageErrorsInstalled = true; \
1549             window.__rkPageErrors = window.__rkPageErrors || []; \
1550             window.addEventListener('error', function (e) { window.__rkPageErrors.push({kind: 'error', \
1551             message: String(e.message || ''), source: String(e.filename || ''), line: e.lineno || 0}); }); \
1552             window.addEventListener('unhandledrejection', function (e) { var r = e.reason; \
1553             window.__rkPageErrors.push({kind: 'unhandledrejection', message: String((r && r.message) || r)}); }); } \
1554             return true;",
1555        )?;
1556        Ok(())
1557    }
1558
1559    /// Page errors recorded by [`Control::record_page_errors`], oldest first.
1560    pub fn page_errors(&self) -> Result<Vec<Value>> {
1561        let v = self.eval("return window.__rkPageErrors || [];")?;
1562        Ok(v.as_array().cloned().unwrap_or_default())
1563    }
1564
1565    /// Failure evidence for a scenario, written to `dir/<label>-failure.json`: the page URL, the
1566    /// recorded page errors, the live log tail and, when `screenshot` is true, a PNG next to it.
1567    /// Pass `screenshot = false` in a functional run: a capture is refused there. Returns the JSON path.
1568    pub fn failure_evidence(&self, dir: &Path, label: &str, screenshot: bool) -> Result<PathBuf> {
1569        fs::create_dir_all(dir)?;
1570        let url = self.eval("return location.href;").unwrap_or(Value::Null);
1571        let page_errors = self.page_errors().unwrap_or_default();
1572        let shot = if screenshot {
1573            let png = dir.join(format!("{label}-failure.png"));
1574            self.screenshot_to(&png)?;
1575            Some(png.display().to_string())
1576        } else {
1577            None
1578        };
1579        let body = json!({
1580            "url": url,
1581            "pageErrors": page_errors,
1582            "logTail": self.log_tail(4000),
1583            "screenshot": shot,
1584        });
1585        let path = dir.join(format!("{label}-failure.json"));
1586        fs::write(&path, serde_json::to_vec_pretty(&body).unwrap_or_default())?;
1587        Ok(path)
1588    }
1589
1590    /// Wait until a JS function body returns something truthy; bounded. One round trip: the app
1591    /// waits in the page (DOM mutations, events, a short timer) and replies the moment it holds.
1592    /// An app on an older driver without the `wait` method is polled every 25 ms instead.
1593    pub fn wait_eval(&self, js: &str, timeout: Duration) -> Result<Value> {
1594        let ms = timeout.as_millis().clamp(1, 600_000) as u64;
1595        let reply = self.rpc_within(
1596            "wait",
1597            json!({"js": js, "timeoutMs": ms}),
1598            timeout + self.request_timeout,
1599        );
1600        match reply {
1601            Ok(v) => {
1602                let v = Self::ok_or_error("wait", v)?;
1603                if v.get("matched") == Some(&Value::Bool(true)) {
1604                    return Ok(v.get("value").cloned().unwrap_or(Value::Null));
1605                }
1606                let last = match v.get("error") {
1607                    Some(e) if !e.is_null() => e.clone(),
1608                    _ => v.get("last").cloned().unwrap_or(Value::Null),
1609                };
1610                err(format!("timeout waiting for `{js}` (last: {last})"))
1611            }
1612            Err(e) if wait_unsupported(&e.0) => self.wait_eval_polling(js, timeout),
1613            Err(e) => Err(e),
1614        }
1615    }
1616
1617    fn wait_eval_polling(&self, js: &str, timeout: Duration) -> Result<Value> {
1618        let deadline = Instant::now() + timeout;
1619        let mut last;
1620        loop {
1621            match self.eval(js) {
1622                Ok(v) => {
1623                    let truthy = !matches!(v, Value::Null | Value::Bool(false))
1624                        && v != json!(0)
1625                        && v != json!("");
1626                    if truthy {
1627                        return Ok(v);
1628                    }
1629                    last = v;
1630                }
1631                Err(e) => last = json!(e.0),
1632            }
1633            if Instant::now() >= deadline {
1634                return err(format!("timeout waiting for `{js}` (last: {last})"));
1635            }
1636            sleep_ms(25);
1637        }
1638    }
1639
1640    /// Wait until `text` appears in the visible text (`innerText`) of the first element
1641    /// matching `selector` (`None` = the whole body); see [`wait_eval`](Self::wait_eval).
1642    pub fn wait_for_text(
1643        &self,
1644        selector: Option<&str>,
1645        text: &str,
1646        timeout: Duration,
1647    ) -> Result<()> {
1648        let sel = serde_json::to_string(selector.unwrap_or("body")).unwrap_or_default();
1649        let needle = serde_json::to_string(text).unwrap_or_default();
1650        let js = format!(
1651            "return ((document.querySelector({sel}) || {{}}).innerText || '').includes({needle});"
1652        );
1653        self.wait_eval(&js, timeout).map(|_| ())
1654    }
1655
1656    /// Stop the app: kill the recorded pid tree, prove the port closed, and check
1657    /// we did not change the frontmost app.
1658    pub fn stop(&mut self) -> Result<StopReport> {
1659        if self.stopped {
1660            return err("control session already stopped");
1661        }
1662        self.stopped = true;
1663        if let Some(mut c) = self.child.take() {
1664            if let (_, Some(note)) = crate::process::terminate_bounded(&mut c) {
1665                eprintln!("{note}");
1666            }
1667        }
1668        kill_tree(self.pid);
1669        let deadline = Instant::now() + Duration::from_secs(5);
1670        while Instant::now() < deadline && (is_alive(self.pid) || endpoint_open(&self.endpoint)) {
1671            sleep_ms(50);
1672        }
1673        // Keep the live log until workspace finish/disposal; failed evidence copies
1674        // must not destroy the only readable startup diagnostics.
1675        let log_copy = fs::copy(&self.raw_log, &self.log_path);
1676        let after = frontmost();
1677        let (
1678            steady,
1679            states,
1680            owned_never_frontmost,
1681            owned_frontmost_pids,
1682            frontmost_pid_unavailable_samples,
1683            owned_never_frontmost_failure,
1684        ) = self.focus.take().map(|mut f| f.finish()).unwrap_or((
1685            false,
1686            vec![],
1687            false,
1688            vec![],
1689            0,
1690            Some("no focus monitor ran".to_string()),
1691        ));
1692        let process_gone = !is_alive(self.pid);
1693        let endpoint_closed = self.endpoint.is_empty() || !endpoint_open(&self.endpoint);
1694        if process_gone {
1695            self.tracker.forget(self.pid);
1696        }
1697        log_copy.map_err(|e| {
1698            Error(format!(
1699                "cannot preserve app log {}: {e}",
1700                self.raw_log.display()
1701            ))
1702        })?;
1703        Ok(StopReport {
1704            pid: self.pid,
1705            endpoint_closed,
1706            process_gone,
1707            frontmost_unchanged: steady
1708                && self.frontmost_before.is_some()
1709                && self.frontmost_before == after,
1710            frontmost_states: states,
1711            frontmost_before: self.frontmost_before.clone(),
1712            frontmost_after: after,
1713            owned_never_frontmost,
1714            owned_frontmost_pids,
1715            frontmost_pid_unavailable_samples,
1716            owned_never_frontmost_failure,
1717        })
1718    }
1719}
1720
1721impl Drop for Control {
1722    fn drop(&mut self) {
1723        if !self.stopped {
1724            let _ = self.stop();
1725        }
1726    }
1727}
1728
1729// Keep wire validation shared by the public client and protocol regression tests.
1730fn settled_viewport_size(v: &Value) -> Result<(u32, u32)> {
1731    if v.get("settled").and_then(Value::as_bool) != Some(true)
1732        || v.get("placementTimedOut").and_then(Value::as_bool) != Some(false)
1733    {
1734        return err(format!("set_viewport did not settle after placement: {v}"));
1735    }
1736    let dimension = |key: &str| {
1737        v.get(key)
1738            .and_then(Value::as_u64)
1739            .and_then(|n| u32::try_from(n).ok())
1740            .filter(|n| *n > 0)
1741            .ok_or_else(|| Error(format!("set_viewport returned invalid {key}")))
1742    };
1743    Ok((dimension("innerWidth")?, dimension("innerHeight")?))
1744}
1745
1746#[cfg(test)]
1747mod launch_path_tests {
1748    use super::*;
1749
1750    #[test]
1751    fn viewport_requires_confirmed_settling_and_placement() {
1752        let good = json!({"innerWidth": 1440, "innerHeight": 789, "settled": true, "placementTimedOut": false});
1753        assert_eq!(settled_viewport_size(&good).unwrap(), (1440, 789));
1754        for (key, value) in [
1755            ("settled", json!(false)),
1756            ("settled", Value::Null),
1757            ("placementTimedOut", json!(true)),
1758            ("placementTimedOut", Value::Null),
1759            ("innerWidth", json!(0)),
1760            ("innerHeight", json!(4294967296u64)),
1761        ] {
1762            let mut reply = good.clone();
1763            reply[key] = value;
1764            assert!(settled_viewport_size(&reply).is_err(), "{reply}");
1765        }
1766    }
1767
1768    #[test]
1769    fn windows_foreground_is_classified_without_a_windows_host() {
1770        assert_eq!(classify_foreground(false, 0, 0), Foreground::NoWindow);
1771        assert_eq!(classify_foreground(true, 7, 4242), Foreground::Pid(4242));
1772        // A foreground window whose owner cannot be read is not "nothing in front".
1773        assert_eq!(classify_foreground(true, 0, 0), Foreground::Unreadable);
1774        assert_eq!(classify_foreground(true, 7, 0), Foreground::Unreadable);
1775        assert_eq!(classify_foreground(true, 0, 4242), Foreground::Unreadable);
1776    }
1777
1778    #[test]
1779    fn background_failure_reason_tells_frontmost_from_unreadable() {
1780        let r = owned_failure_reason(&[42], 3, true, 3);
1781        assert!(r.contains("was frontmost") && r.contains("42"), "{r}");
1782        let r = owned_failure_reason(&[], 2, true, 2);
1783        assert!(r.contains("pid could not be read in 2"), "{r}");
1784        assert!(!r.contains("was frontmost"), "{r}");
1785        assert!(owned_failure_reason(&[], 0, false, 0).contains("never registered"));
1786        assert!(owned_failure_reason(&[], 0, true, 4).contains("unmeasurable"));
1787    }
1788
1789    #[test]
1790    fn windows_background_invariant_fails_closed() {
1791        // Visible launches enforce nothing.
1792        assert!(windows_owned_never_frontmost(false, 3, &[9]));
1793        // Background: the owned pid in front, or an unreadable owner, both fail.
1794        assert!(windows_owned_never_frontmost(true, 0, &[]));
1795        assert!(!windows_owned_never_frontmost(true, 0, &[9]));
1796        assert!(!windows_owned_never_frontmost(true, 1, &[]));
1797    }
1798
1799    #[test]
1800    fn launch_timeout_diagnostics_name_the_suite_the_service_and_the_log() {
1801        let (ws, _spec) = fixture();
1802        let suite = Suite::at(ws.home.join("suite-aaaaaaaa")).unwrap();
1803        // The app registered under another service name than the one the client polled.
1804        let other = suite.service_dir("control-other");
1805        fs::create_dir_all(&other).unwrap();
1806        fs::write(other.join("service.json"), "{}").unwrap();
1807        let log = ws.home.join("app.log");
1808        fs::write(
1809            &log,
1810            format!("{}\nRIGHTKIT QA CONTROL SERVER ACTIVE", "x".repeat(5000)),
1811        )
1812        .unwrap();
1813        let text = launch_timeout_diagnostics(
1814            &suite,
1815            "control-aaaaaaaa",
1816            std::process::id(),
1817            &log,
1818            "connect: no service.json for control-aaaaaaaa",
1819        );
1820        assert!(text.contains(&suite.root().display().to_string()), "{text}");
1821        assert!(text.contains("control-aaaaaaaa"), "{text}");
1822        assert!(text.contains("no readable record"), "{text}");
1823        assert!(text.contains("control-other (service.json)"), "{text}");
1824        assert!(
1825            text.contains("no service.json for control-aaaaaaaa"),
1826            "{text}"
1827        );
1828        assert!(text.contains("RIGHTKIT QA CONTROL SERVER ACTIVE"), "{text}");
1829        assert!(text.contains(&log.display().to_string()), "{text}");
1830        // Only the tail of a long log is quoted.
1831        assert!(text.len() < 5000, "{}", text.len());
1832        // A log that was never written says so instead of printing nothing.
1833        let none = launch_timeout_diagnostics(&suite, "s", 1, &ws.home.join("missing.log"), "");
1834        assert!(none.contains("log unreadable"), "{none}");
1835        cleanup(&ws);
1836    }
1837
1838    fn fixture() -> (QaWorkspace, LaunchSpec) {
1839        let root = crate::storage::app_temp_dir().join(format!("rkqa-launch-{}", new_id()));
1840        let ws = crate::workspace::create(&root, None, "control-test-app").unwrap();
1841        let binary = ws.root.join("control-test-app");
1842        fs::write(&binary, "fixture binary").unwrap();
1843        let spec = LaunchSpec {
1844            binary,
1845            mode: Mode::Hidden,
1846            env: vec![],
1847            startup_timeout: Duration::from_secs(90),
1848            label: "fixture".into(),
1849            focus: false,
1850        };
1851        (ws, spec)
1852    }
1853
1854    fn cleanup(ws: &QaWorkspace) {
1855        let root = ws
1856            .root
1857            .parent()
1858            .unwrap()
1859            .parent()
1860            .unwrap()
1861            .parent()
1862            .unwrap()
1863            .to_path_buf();
1864        let report = crate::workspace::dispose(ws, false, 1, 0);
1865        assert!(report.retained.is_empty(), "{report:?}");
1866        fs::remove_dir_all(root).unwrap();
1867    }
1868
1869    #[test]
1870    fn launch_environment_keeps_all_owned_paths_internal_and_rejects_external_overrides() {
1871        let (ws, mut spec) = fixture();
1872        let suite = Suite::at(ws.home.join("suite-12345678")).unwrap();
1873        let env: std::collections::BTreeMap<_, _> =
1874            launch_env(&spec, &ws, &suite, "control-12345678")
1875                .unwrap()
1876                .into_iter()
1877                .collect();
1878        for (key, value) in &env {
1879            if Path::new(value).is_absolute() {
1880                assert!(Path::new(value).starts_with(&ws.home), "{key}={value}");
1881                assert!(!Path::new(value).starts_with(&ws.root), "{key}={value}");
1882            }
1883        }
1884        assert_eq!(env["HOME"], ws.home.to_string_lossy());
1885        assert_eq!(env["TMPDIR"], ws.home.join("tmp").to_string_lossy());
1886        assert_eq!(env["RIGHTKIT_SUITE_ROOT"], suite.root().to_string_lossy());
1887        assert_eq!(env["RIGHTKIT_QA_HIDDEN"], "1");
1888        assert_eq!(env["RIGHTKIT_QA_BACKGROUND"], "1");
1889        // Default launch is unchanged: no focus.
1890        assert!(!spec.focus);
1891        assert_eq!(env["RIGHTKIT_QA_FOCUS"], "0");
1892        let focused = LaunchSpec {
1893            focus: true,
1894            ..spec.clone()
1895        };
1896        let focused_env: std::collections::BTreeMap<_, _> =
1897            launch_env(&focused, &ws, &suite, "control-12345678")
1898                .unwrap()
1899                .into_iter()
1900                .collect();
1901        assert_eq!(focused_env["RIGHTKIT_QA_FOCUS"], "1");
1902        // Focus does not turn a hidden launch into a visible one.
1903        assert_eq!(focused_env["RIGHTKIT_QA_HIDDEN"], "1");
1904        // rightkit-daemon falls back to TMPDIR/rk-<suite-hash>-<service>.sock
1905        // when its runtime socket exceeds 100 bytes. App TMPDIR must fit too.
1906        #[cfg(target_os = "macos")]
1907        assert!(
1908            Path::new(&env["TMPDIR"])
1909                .join("rk-12345678-control-12345678.sock")
1910                .as_os_str()
1911                .len()
1912                <= 100
1913        );
1914        assert!(suite
1915            .service_dir("control-12345678")
1916            .join("service.json")
1917            .starts_with(&ws.home));
1918        for key in [
1919            "RIGHTKIT_SUITE_ROOT",
1920            "RIGHTKIT_QA_DATA_DIR",
1921            "RIGHTKIT_RUN_ROOT",
1922        ] {
1923            spec.env = vec![(
1924                key.into(),
1925                ws.root.join("external").to_string_lossy().into(),
1926            )];
1927            assert!(
1928                launch_env(&spec, &ws, &suite, "control-12345678").is_err(),
1929                "{key}"
1930            );
1931        }
1932        spec.env = vec![("RIGHTKIT_SECRET".into(), "redacted".into())];
1933        assert!(launch_env(&spec, &ws, &suite, "control-12345678").is_err());
1934        cleanup(&ws);
1935    }
1936
1937    #[cfg(target_os = "macos")]
1938    #[test]
1939    fn raw_binary_and_supplied_bundle_are_staged_at_a_fixed_path_before_open() {
1940        let (ws, mut spec) = fixture();
1941        let id = "1234567890abcdef";
1942        let bundle = stage_bundle(&spec, &ws, id).unwrap();
1943        // One fixed path and one fixed bundle id per app, never a per-run folder or id.
1944        assert_eq!(
1945            bundle,
1946            crate::storage::home_base()
1947                .join("rightkit-qa-apps/control-test-app/control-test-app.app")
1948        );
1949        assert!(!bundle.starts_with(&ws.home) && !bundle.starts_with(&ws.root));
1950        let again = stage_bundle(&spec, &ws, "fedcba0987654321").unwrap();
1951        assert_eq!(again, bundle);
1952        let plist_text = fs::read_to_string(bundle.join("Contents/Info.plist")).unwrap();
1953        assert!(plist_text.contains("<string>app.rightkit.qa.control-test-app</string>"));
1954        assert!(!bundle
1955            .with_file_name("control-test-app.app.previous")
1956            .exists());
1957        // A different build replaces the bundle in place and keeps exactly one `.previous`.
1958        fs::write(&spec.binary, "second build").unwrap();
1959        let next = stage_bundle(&spec, &ws, "0123456789abcdef").unwrap();
1960        assert_eq!(next, bundle);
1961        assert_eq!(
1962            fs::read(bundle.join("Contents/MacOS/control-test-app")).unwrap(),
1963            b"second build"
1964        );
1965        let previous = bundle.with_file_name("control-test-app.app.previous");
1966        assert_eq!(
1967            fs::read(previous.join("Contents/MacOS/control-test-app")).unwrap(),
1968            b"fixture binary"
1969        );
1970        fs::write(&spec.binary, "fixture binary").unwrap();
1971        let bundle = stage_bundle(&spec, &ws, id).unwrap();
1972        assert_eq!(
1973            fs::read(bundle.join("Contents/MacOS/control-test-app")).unwrap(),
1974            b"fixture binary"
1975        );
1976        let plist = fs::read_to_string(bundle.join("Contents/Info.plist")).unwrap();
1977        assert!(plist.contains("<key>LSUIElement</key><true/>"));
1978        let suite = Suite::at(ws.home.join("suite-12345678")).unwrap();
1979        let env = launch_env(&spec, &ws, &suite, "control-12345678").unwrap();
1980        let log = ws.home.join("app-12345678.log");
1981        let cmd = open_command(&spec, &ws, &env, &log, &bundle);
1982        assert_eq!(cmd.get_current_dir(), Some(ws.home.as_path()));
1983        let args: Vec<_> = cmd
1984            .get_args()
1985            .map(|v| v.to_string_lossy().into_owned())
1986            .collect();
1987        assert_eq!(&args[..3], &["-g", "-j", "-n"]);
1988        for (key, value) in &env {
1989            assert!(
1990                args.windows(2)
1991                    .any(|pair| pair[0] == "--env" && pair[1] == format!("{key}={value}")),
1992                "{key}"
1993            );
1994        }
1995        for flag in ["--stdout", "--stderr"] {
1996            assert!(args
1997                .windows(2)
1998                .any(|pair| pair[0] == flag && Path::new(&pair[1]) == log));
1999        }
2000        assert_eq!(args.last().unwrap(), &bundle.to_string_lossy());
2001        // A provided .app must be copied too; it may live on the external build volume.
2002        spec.binary = make_bundle(&ws.root, &spec.binary, "original", true).unwrap();
2003        fs::create_dir_all(spec.binary.join("Contents/Resources")).unwrap();
2004        fs::write(
2005            spec.binary.join("Contents/Resources/fixture.txt"),
2006            "resource",
2007        )
2008        .unwrap();
2009        let supplied = stage_bundle(&spec, &ws, "fedcba0987654321").unwrap();
2010        assert!(!supplied.starts_with(&ws.home));
2011        assert_ne!(supplied, spec.binary);
2012        assert_eq!(
2013            fs::read_to_string(supplied.join("Contents/Resources/fixture.txt")).unwrap(),
2014            "resource"
2015        );
2016        std::os::unix::fs::symlink(
2017            ws.root.join("control-test-app"),
2018            spec.binary.join("Contents/Resources/external"),
2019        )
2020        .unwrap();
2021        assert!(stage_bundle(&spec, &ws, "0123456789abcdef")
2022            .unwrap_err()
2023            .0
2024            .contains("escapes internal bundle"));
2025        let _ = fs::remove_dir_all(
2026            crate::storage::home_base().join("rightkit-qa-apps/control-test-app"),
2027        );
2028        cleanup(&ws);
2029    }
2030}