1use crate::process::{is_alive, kill_tree, Tracker};
10use crate::util::{err, new_id, sleep_ms, Error, Result};
11use crate::workspace::QaWorkspace;
12use rightkit_child_process::OwnedChild;
13#[cfg(not(target_os = "macos"))]
14use rightkit_child_process::OwnedCommand;
15use rightkit_daemon::{Client, ServiceError, Suite};
16use serde_json::{json, Value};
17use std::fs;
18use std::path::{Path, PathBuf};
19use std::process::Command;
20#[cfg(not(target_os = "macos"))]
21use std::process::Stdio;
22use std::sync::{Arc, Mutex};
23use std::time::{Duration, Instant};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Mode {
27 Hidden,
29 Background,
31 Visible,
32}
33
34impl Mode {
35 pub fn parse(s: &str) -> Result<Self> {
36 match s {
37 "hidden" => Ok(Mode::Hidden),
38 "background" => Ok(Mode::Background),
39 "visible" => Ok(Mode::Visible),
40 other => err(format!(
41 "unknown ui mode '{other}' (hidden|background|visible)"
42 )),
43 }
44 }
45}
46
47#[derive(Debug, Clone)]
48pub struct LaunchSpec {
49 pub binary: PathBuf,
51 pub mode: Mode,
52 pub env: Vec<(String, String)>,
53 pub startup_timeout: Duration,
54 pub label: String,
55}
56
57#[derive(Debug, Clone)]
58pub struct StopReport {
59 pub pid: u32,
60 pub endpoint_closed: bool,
61 pub process_gone: bool,
62 pub frontmost_before: Option<String>,
63 pub frontmost_after: Option<String>,
64 pub frontmost_unchanged: bool,
65 pub frontmost_states: Vec<String>,
67 pub owned_never_frontmost: bool,
75 pub owned_frontmost_pids: Vec<u32>,
77}
78
79impl StopReport {
80 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 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
115fn 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
170pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202#[cfg_attr(not(windows), allow(dead_code))]
203enum Foreground {
204 NoWindow,
207 Pid(u32),
209 Unreadable,
211}
212
213#[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#[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
254struct FocusMonitor {
258 stop: Arc<std::sync::atomic::AtomicBool>,
259 seen: Arc<Mutex<(usize, usize, Vec<String>)>>, owned: Arc<Mutex<(Vec<u32>, Option<String>)>>, enforce_owned: bool,
262 owned_frontmost: Arc<Mutex<Vec<u32>>>,
263 #[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 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 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 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 let owned_never_frontmost = !self.enforce_owned;
415 (unchanged, g.2.clone(), owned_never_frontmost, observed)
416 }
417}
418
419fn 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#[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 fs::copy(&abs, &target)?;
491 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 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 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")]
631fn 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 let guard = crate::userdata::Guard::for_workspace(ws)?;
668 let id = new_id().replace('-', "");
669 let service = format!("control-{}", &id[..8]);
670 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 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 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 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 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 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 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
764fn 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#[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 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 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 focus.track_primary(child.id());
900 Ok((child.id(), Some(child)))
901 }
902}
903
904impl Control {
905 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(windows_owned_never_frontmost(false, 3, &[9]));
1265 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 #[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 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}