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 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 pub frontmost_states: Vec<String>,
74 pub owned_never_frontmost: bool,
82 pub owned_frontmost_pids: Vec<u32>,
84 pub frontmost_pid_unavailable_samples: usize,
88 pub owned_never_frontmost_failure: Option<String>,
92}
93
94impl StopReport {
95 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 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 pace_ms: u64,
119 stopped: bool,
120 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
135fn 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
150struct 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 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
208fn 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
226fn 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
248fn 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
325pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
357#[cfg_attr(not(windows), allow(dead_code))]
358enum Foreground {
359 NoWindow,
362 Pid(u32),
364 Unreadable,
366}
367
368#[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#[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
409fn 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
431struct FocusMonitor {
435 stop: Arc<std::sync::atomic::AtomicBool>,
436 seen: Arc<Mutex<(usize, usize, Vec<String>)>>, owned: Arc<Mutex<(Vec<u32>, Option<String>)>>, enforce_owned: bool,
439 owned_frontmost: Arc<Mutex<Vec<u32>>>,
440 #[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 _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 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 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 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
614fn 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#[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#[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#[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#[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 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 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#[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 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 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")]
886fn 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 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 let guard = crate::userdata::Guard::for_workspace(ws)?;
927 let id = new_id().replace('-', "");
928 let service = format!("control-{}", &id[..8]);
929 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 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 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 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 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 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 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 loop {
1022 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
1055fn 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#[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 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 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 focus.track_primary(child.id());
1192 Ok((child.id(), Some(child)))
1193 }
1194}
1195
1196impl Control {
1197 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 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 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 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 pub fn set_pace_ms(&mut self, ms: u64) {
1301 self.pace_ms = ms;
1302 }
1303 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 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 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 pub fn focus_window(&self) -> Result<Value> {
1341 self.json("focus", json!({}))
1342 }
1343 pub fn focus_requested(&self) -> bool {
1345 self.focus_requested
1346 }
1347 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 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 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 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 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 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 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 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 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 pub fn live_log_path(&self) -> &Path {
1534 &self.raw_log
1535 }
1536
1537 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 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 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 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 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 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 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 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
1729fn 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 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 assert!(windows_owned_never_frontmost(false, 3, &[9]));
1793 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 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 assert!(text.len() < 5000, "{}", text.len());
1832 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 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 assert_eq!(focused_env["RIGHTKIT_QA_HIDDEN"], "1");
1904 #[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 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 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 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}