//! `wai doctor` through the `wai` binary (`--features quickstart --test cli_doctor`), on a machine //! made up for each test: its own `PATH`, home, temp directory and environment, with `wai` hard //! linked into a directory of its own so that what is "beside this binary" is what the test put //! there. //! //! - a `wai` earlier on `PATH` is a finding, whatever it turns out to be: another program, an older //! WAI's, another copy, one that hangs (the timeout holds, and what it started is stopped), or one //! reached through a relative entry of `PATH`, which is not run; a shim is the program its //! `.shim` file names, this one or another; //! - the relay and the room service are found as `wai dev` finds them, or not, and their versions //! agree with this `wai`'s, or do not; each has the time a first start can take, and one that hangs //! is stopped with what it started; //! - every row of the quickstart's port table is a check, free or held, and held by this test says //! this test's process; //! - a key directory or a key file another account can reach fails, its fix makes it pass, and a //! seed is never opened or printed; //! - a check that cannot be run says "not checked" with the reason, and the result tells that from //! all passed and from a failure; //! - nothing is written anywhere, in the temp directory included. //! //! The ports, the keychain and the machine's real browsers are not the test's to arrange, so a test //! holds them to what it arranged and no further. (macOS's keychain tool reads its search list from //! the home directory, so under a made-up home it lists none.) #![cfg(feature = "quickstart")] use serde_json::Value; use std::collections::BTreeMap; use std::ffi::OsString; use std::net::{TcpListener, UdpSocket}; use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; use std::time::{Duration, Instant}; // A program a test runs is written by a process of its own (ETXTBSY: support/program.rs). #[path = "support/program.rs"] mod program; const VERSION: &str = env!("CARGO_PKG_VERSION"); /// A made-up machine. struct Machine { root: PathBuf, /// Holds `wai`, and what the test puts beside it. bin: PathBuf, env: BTreeMap, cwd: PathBuf, } impl Machine { fn new(name: &str) -> Machine { let root = Path::new(env!("CARGO_TARGET_TMPDIR")).join("cli_doctor").join(name); let _ = std::fs::remove_dir_all(&root); let (bin, home, tmp, cwd) = (root.join("bin"), root.join("home"), root.join("tmp"), root.join("cwd")); for d in [&bin, &home, &tmp, &cwd] { std::fs::create_dir_all(d).unwrap(); } let wai = bin.join(format!("wai{}", std::env::consts::EXE_SUFFIX)); if std::fs::hard_link(env!("CARGO_BIN_EXE_wai"), &wai).is_err() { program::copy_program(Path::new(env!("CARGO_BIN_EXE_wai")), &wai); } let mut env = BTreeMap::new(); // Windows needs these to start anything at all, and to reach its sockets. for keep in ["SystemRoot", "SystemDrive", "windir", "ComSpec", "PATHEXT"] { if let Some(v) = std::env::var_os(keep) { env.insert(keep.to_string(), v); } } env.insert("PATH".into(), bin.clone().into_os_string()); env.insert("HOME".into(), home.clone().into_os_string()); for k in ["TMPDIR", "TEMP", "TMP"] { env.insert(k.into(), tmp.clone().into_os_string()); } if cfg!(windows) { env.insert("USERPROFILE".into(), home.clone().into_os_string()); env.insert("APPDATA".into(), home.join("AppData").into_os_string()); env.insert("LOCALAPPDATA".into(), home.join("LocalAppData").into_os_string()); } Machine { root, bin, env, cwd } } fn set(&mut self, k: &str, v: impl Into) -> &mut Machine { self.env.insert(k.to_string(), v.into()); self } fn unset(&mut self, k: &str) -> &mut Machine { self.env.remove(k); self } fn path(&mut self, dirs: &[&Path]) -> &mut Machine { let p = std::env::join_paths(dirs).unwrap(); self.set("PATH", p) } fn dir(&self, name: &str) -> PathBuf { let d = self.root.join(name); std::fs::create_dir_all(&d).unwrap(); d } /// `wai` with this machine's environment alone, no input and no terminal. fn wai(&self, args: &[&str]) -> Output { let mut c = Command::new(self.bin.join(format!("wai{}", std::env::consts::EXE_SUFFIX))); c.current_dir(&self.cwd).args(args).env_clear().stdin(Stdio::null()); for (k, v) in &self.env { c.env(k, v); } c.output().expect("wai runs") } /// The envelope of `wai doctor --json`, with its exit status. fn doctor(&self, args: &[&str]) -> (i32, Report) { let mut a = vec!["doctor", "--json"]; a.extend_from_slice(args); let o = self.wai(&a); let v: Value = serde_json::from_slice(&o.stdout).unwrap_or_else(|e| panic!("not one envelope ({e}): {}", text(&o))); assert_eq!((v["wai"].as_str(), v["op"].as_str()), (Some("envelope/1"), Some("toolchain.doctor")), "{v}"); (o.status.code().expect("an exit status"), Report(v)) } /// A program that answers its version as `name`, or as `wai` does. On Windows, a script found by /// the name of its file, so [`Machine::healthy`] names each in an environment variable. fn stand_in(&self, dir: &Path, name: &str, answer: &str) -> PathBuf { program(dir, name, &format!("printf '%s\\n' '{answer}'"), &format!("echo {answer}")) } /// Everything a healthy machine has but its ports: the relay and the room service, their files, /// a browser the harnesses are told to drive, and this `wai` first on `PATH`. fn healthy(&mut self) -> &mut Machine { let (relay, room) = (self.sibling_answer("wai-relay", VERSION), self.sibling_answer("wai-room", VERSION)); let beside = self.bin.clone(); let (r, m) = (self.stand_in(&beside, "wai-relay", &relay), self.stand_in(&beside, "wai-room", &room)); if cfg!(windows) { self.set("WAI_RELAY_BIN", r).set("WAI_ROOM_BIN", m); } let web = self.dir("share/web/room"); std::fs::write(web.join("index.html"), "x").unwrap(); let studio = self.dir("share/studio"); std::fs::write(studio.join("index.html"), "x").unwrap(); // A browser that names its version when asked it alone, and notes anything else asked of it. let b = self.dir("browser"); let ran = self.root.join("browser-ran"); let browser = program( &b, "browser", &format!("if [ \"$*\" = --version ]; then echo 'FakeBrowser 1.2.3'; else echo \"$*\" > '{}'; fi", ran.display()), &format!("if \"%~1\"==\"--version\" if \"%~2\"==\"\" (echo FakeBrowser 1.2.3& exit /b 0)\r\necho %*> \"{}\"", ran.display()), ); self.set("WAI_WEB", web.parent().unwrap()).set("WAI_STUDIO", studio).set("BROWSER_BIN", browser); self } fn sibling_answer(&self, name: &str, toolchain: &str) -> String { format!(r#"{{"wai":"envelope/1","op":"toolchain.show","ok":true,"data":{{"program":"{name}","version":"0.1.0","toolchain":"{toolchain}"}}}}"#) } /// A keystore of this machine's, with the access a test sets below. fn keystore(&self) -> PathBuf { let k = self.root.join("ks"); std::fs::create_dir_all(&k).unwrap(); k } /// Every name under the machine, with its size, but what `wai` is linked from. fn snapshot(&self) -> BTreeMap { fn walk(d: &Path, out: &mut BTreeMap) { for e in std::fs::read_dir(d).unwrap().flatten() { let p = e.path(); let m = std::fs::symlink_metadata(&p).unwrap(); out.insert(p.clone(), m.len()); if m.is_dir() { walk(&p, out); } } } let mut out = BTreeMap::new(); walk(&self.root, &mut out); out } } fn text(o: &Output) -> String { format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)) } /// A script at `dir/name` (`name.cmd` on Windows), which has had its first start ([`started_once`]) /// before the test hands it to `wai doctor`. fn program(dir: &Path, name: &str, unix: &str, windows: &str) -> PathBuf { let p = if cfg!(windows) { let p = dir.join(format!("{name}.cmd")); std::fs::write(&p, format!("@echo off\r\n{FIRST_START_CMD}\r\n{windows}\r\n")).unwrap(); p } else { let p = dir.join(name); #[cfg(unix)] program::write_program(&p, &format!("#!/bin/sh\n{FIRST_START}\n{unix}\n")); #[cfg(not(unix))] let _ = unix; p }; started_once(&p); p } /// The line a script here begins with: started with `WAI_TEST_FIRST_START` set, it ends before /// anything else it holds is run. `wai doctor` never has it set: [`Machine::wai`] gives it this /// machine's environment alone. #[cfg(unix)] const FIRST_START: &str = r#"[ -z "$WAI_TEST_FIRST_START" ] || exit 0"#; /// [`FIRST_START`] in a `.cmd` script, the line after `@echo off`. const FIRST_START_CMD: &str = "if defined WAI_TEST_FIRST_START exit /b 0"; /// The script at `p`, which begins with [`FIRST_START`] (on Windows [`FIRST_START_CMD`]), started /// once and waited for to its end. /// /// macOS checks a program it has not run before, before the program runs, and macOS 15 checks one /// program at a time for the whole machine. These tests, run at once, write dozens of programs, so /// on an x86_64 Mac (macOS 15.8, 2026-11-13) a program's first start waited behind the others' /// checks past the 3 s `wai doctor` gives a program: a stand-in that answers read "did not answer", /// and one that hangs was stopped before it had started. A script started once started again in /// 6 to 7 ms there, against 220 to 228 ms the first time. Windows, too, scans a file it has not run /// before when it is first run, and the tests there write as many. Here the first start is had, and /// waited on to its end, before the doctor asks: the doctor then asks a program the system has /// checked, as it asks one a person installed and has run, and what it does is the test's /// stand-in's doing. fn started_once(p: &Path) { let s = Command::new(p).env("WAI_TEST_FIRST_START", "1").stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).status(); assert!(s.as_ref().is_ok_and(|s| s.success()), "{} did not start: {s:?}", p.display()); } #[cfg(unix)] fn mode(p: &Path, m: u32) { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(p, std::fs::Permissions::from_mode(m)).unwrap(); } #[cfg(not(unix))] fn mode(_: &Path, _: u32) {} /// A program that hangs with its standard output held open by a child, as a stuck wrapper does. On /// Unix it writes its own and its child's process numbers to `pids`. On Windows its child is a copy of /// the system's ping under a name of its own, [`grandchild_image`], by which a test finds it. fn hanging(dir: &Path, name: &str, pids: &Path) -> PathBuf { let mut windows = String::new(); if cfg!(windows) { let copy = ping_copy(dir, &grandchild_image(name)); windows = format!("\"{}\" -n 300 -w 1000 127.0.0.1", copy.display()); } program(dir, name, &format!("/bin/sleep 300 &\necho \"$$ $!\" > '{}'\nwait", pids.display()), &windows) } /// The image name of [`hanging`]'s child on Windows, one of this test process's own. fn grandchild_image(name: &str) -> String { format!("{name}-grandchild-{}.exe", std::process::id()) } /// A copy of the system's ping at `dir/image`, which has had its first start, as [`program`]'s /// scripts have: it pings this machine once and has ended before this returns. (Windows only.) fn ping_copy(dir: &Path, image: &str) -> PathBuf { let copy = dir.join(image); let ping = Path::new(&std::env::var_os("SystemRoot").expect("SystemRoot")).join("System32").join("PING.EXE"); std::fs::copy(ping, ©).unwrap(); let s = Command::new(©).args(["-n", "1", "-w", "1000", "127.0.0.1"]).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).status(); assert!(s.is_ok(), "{} did not start: {s:?}", copy.display()); copy } /// `wai doctor`, while a program it asks, made by [`hanging`] as `name`, hangs; with /// whether that program's child was seen running. On Windows a child inherits its parent's handles, /// the run's own output among them, so a grandchild left running would keep the run from ending: the /// child, [`grandchild_image`], is looked for while the run waits, and if the run has not ended /// within 45 s it is ended, so that the run and this test can end, and the test fails. On Unix the /// program notes its own number and its child's, and the test reads those instead. fn doctor_while_hanging(m: &Machine, name: &str) -> (bool, (i32, Report)) { std::thread::scope(|s| { let run = s.spawn(|| m.doctor(&[])); #[cfg(windows)] let seen = { let image = grandchild_image(name); let mut seen = false; while !seen && !run.is_finished() { seen = running(&image); std::thread::sleep(Duration::from_millis(100)); } let waiting = Instant::now(); while !run.is_finished() { if waiting.elapsed() > Duration::from_secs(45) { let left = running(&image); end(&image); assert!(!left, "{image} is still running, and holds the run's output open"); panic!("the run did not end within 45 s"); } std::thread::sleep(Duration::from_millis(200)); } seen }; #[cfg(not(windows))] let seen = { let _ = name; true }; (seen, run.join().expect("the run")) }) } /// On Windows, that the child of the program made by [`hanging`] as `name` was seen running, and /// is gone within 15 s of the run's end: it is ended, and the test fails, if it is not. #[cfg(windows)] fn grandchild_gone(name: &str, seen: bool) { let image = grandchild_image(name); assert!(seen, "{image} was never seen running, so this shows nothing"); let started = Instant::now(); while running(&image) { if started.elapsed() > Duration::from_secs(15) { end(&image); panic!("{image} is still running"); } std::thread::sleep(Duration::from_millis(200)); } } /// Every process of this image name ended, by the system's own tool. #[cfg(windows)] fn end(image: &str) { let tool = Path::new(&std::env::var_os("SystemRoot").expect("SystemRoot")).join("System32").join("taskkill.exe"); let _ = Command::new(tool).args(["/F", "/IM", image]).stdin(Stdio::null()).output(); } /// Whether a process of this image name runs, as the system's own tool lists it. #[cfg(windows)] fn running(image: &str) -> bool { let tool = Path::new(&std::env::var_os("SystemRoot").expect("SystemRoot")).join("System32").join("tasklist.exe"); let o = Command::new(tool).args(["/FI", &format!("IMAGENAME eq {image}"), "/FO", "CSV", "/NH"]).stdin(Stdio::null()).output().expect("tasklist runs"); String::from_utf8_lossy(&o.stdout).to_ascii_lowercase().contains(&format!("\"{}\"", image.to_ascii_lowercase())) } /// The report's envelope. struct Report(Value); impl Report { fn data(&self) -> &Value { &self.0["data"] } fn result(&self) -> &str { self.data()["result"].as_str().unwrap_or_else(|| panic!("no result: {}", self.0)) } fn checks(&self) -> Vec { self.data()["checks"].as_array().unwrap_or_else(|| panic!("no checks: {}", self.0)).clone() } fn check(&self, id: &str) -> Value { self.checks().into_iter().find(|c| c["id"] == id).unwrap_or_else(|| panic!("no check {id}: {}", self.0)) } fn state(&self, id: &str) -> String { self.check(id)["state"].as_str().unwrap().to_string() } fn message(&self, id: &str) -> String { self.check(id)["message"].as_str().unwrap().to_string() } fn fix(&self, id: &str) -> String { self.check(id)["fix"].as_str().unwrap_or_else(|| panic!("no fix for {id}: {}", self.0)).to_string() } /// The checks that failed but the machine's own (the ports and the keychain), which the test does /// not arrange. fn arranged_failed(&self) -> Vec { self.checks().iter().filter(|c| c["state"] == "fail" && !machines(c)).map(|c| c["id"].as_str().unwrap().to_string()).collect() } /// Whether a check of the machine's own failed. fn machine_failed(&self) -> bool { self.checks().iter().any(|c| c["state"] == "fail" && machines(c)) } } /// Whether a check looks at what the machine has and the test does not arrange: a port, the keychain. fn machines(c: &Value) -> bool { let id = c["id"].as_str().unwrap(); id.starts_with("port.") || id.starts_with("keychain.") } /// The port table's rows as a person reads them: the label, the number, whether UDP, whether off /// until set. fn table() -> Vec<(String, u16, bool, bool)> { let readme = std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("../quickstart/README.md")).unwrap(); let body = readme.split("\n## Ports\n").nth(1).expect("a port table"); let mut rows = Vec::new(); for line in body.lines().take_while(|l| !l.starts_with("## ")) { let cells: Vec<&str> = line.split('|').map(str::trim).collect(); if cells.len() < 6 || !cells[1].starts_with(|c: char| c.is_ascii_digit()) { continue; } let (n, udp) = cells[1].strip_suffix("/udp").map_or((cells[1], false), |n| (n, true)); rows.push((cells[1].to_string(), n.parse().unwrap(), udp, cells[3].contains("off until set"))); } rows } // ─────────────────────────────── the result ─────────────────────────────── #[test] fn a_healthy_machine_passes_and_what_is_not_the_tests_to_arrange_stays_out_of_it() { let mut m = Machine::new("healthy"); m.healthy(); let (code, r) = m.doctor(&[]); assert_eq!(r.arranged_failed(), Vec::::new(), "{}", r.0); assert_eq!(r.state("install.version"), "pass"); let path = r.message("install.version"); assert!(path.contains(&format!("wai {VERSION} at ")) && path.contains("bin"), "{path}"); assert_eq!(r.state("install.path"), "pass", "{}", r.0); for id in ["stack.wai-relay", "stack.wai-room", "stack.web", "stack.studio", "browser.studio", "browser.conformance", "keystore.directory"] { assert_eq!(r.state(id), "pass", "{id}: {}", r.0); } assert_eq!(r.state("keystore.files"), "not-applicable", "no keystore yet: {}", r.0); assert!(r.message("browser.studio").contains("runs (FakeBrowser 1.2.3)"), "{}", r.0); assert!(!m.root.join("browser-ran").exists(), "a browser is asked its version, and nothing else"); // The result is what the checks say, and the exit is the table's. On macOS whether the keychain is // unlocked is never asked. let keychain_unknown = cfg!(target_os = "macos"); if r.machine_failed() { assert_eq!((code, r.result(), r.0["ok"].as_bool()), (1, "failed", Some(false)), "{}", r.0); } else { let want = if keychain_unknown { "passed-with-not-checked" } else { "passed" }; assert_eq!((code, r.result(), r.0["ok"].as_bool()), (0, want, Some(true)), "{}", r.0); } if cfg!(target_os = "macos") { assert_eq!(r.state("keychain.unlocked"), "not-checked", "{}", r.0); } else { assert_eq!(r.state("keychain.reachable"), "not-applicable", "{}", r.0); } // Text, one line to a check and one for the result, each saying its result. This is another run, // and a port may have changed hands since (another test holds some), so a port's line is held to // its id alone, and the exit to this run's own lines. let o = m.wai(&["doctor"]); let t = String::from_utf8_lossy(&o.stdout).into_owned(); let lines: Vec<&str> = t.lines().collect(); assert_eq!(lines.len(), r.checks().len() + 1, "{t}"); for (l, c) in lines.iter().zip(r.checks()) { let id = c["id"].as_str().unwrap(); let word = match c["state"].as_str().unwrap() { "pass" => "pass", "fail" => "FAIL", "not-checked" => "not checked", _ => "n/a", }; assert!(l.contains(id) && (id.starts_with("port.") || l.starts_with(word)), "{l}"); } assert!(lines.last().unwrap().starts_with("result: "), "{t}"); for l in lines.iter().filter(|l| l.starts_with("FAIL")) { assert!(l.contains("; fix: "), "{l}"); } assert_eq!(o.status.code(), Some(i32::from(lines.iter().any(|l| l.starts_with("FAIL")))), "{}", text(&o)); // The mechanical form runs the same operation. assert!(String::from_utf8_lossy(&m.wai(&["toolchain", "doctor"]).stdout).lines().last().unwrap().starts_with("result: ")); } #[test] fn a_command_line_it_does_not_take_is_a_usage_error() { let m = Machine::new("usage"); for args in [&["doctor", "extra"][..], &["doctor", "--nope"], &["doctor", "--keystore"]] { assert_eq!(m.wai(args).status.code(), Some(2), "{args:?}"); } let h = m.wai(&["doctor", "--help"]); assert!(h.status.success() && String::from_utf8_lossy(&h.stdout).contains("toolchain.doctor"), "{}", text(&h)); } #[test] fn nothing_is_written_not_in_the_temp_directory_either() { let mut m = Machine::new("nothing-written"); m.healthy(); let shadow = m.dir("shadow"); program(&shadow, "wai", "echo wai 9.9.9", "echo wai 9.9.9"); let bin = m.bin.clone(); m.path(&[&shadow, &bin]); let k = m.keystore(); std::fs::write(k.join("a.seed"), "0").unwrap(); let before = m.snapshot(); let (code, r) = m.doctor(&["--keystore", k.to_str().unwrap()]); assert_eq!(code, 1, "{}", r.0); assert_eq!(m.snapshot(), before, "nothing changes under the machine, home and temp included"); } // ─────────────────────────────── PATH ─────────────────────────────── #[test] fn another_program_first_on_path_is_a_finding_with_a_fix() { let mut m = Machine::new("shadow-other"); m.healthy(); let shadow = m.dir("shadow"); let other = program(&shadow, "wai", "echo wai 9.9.9, of another project", "echo wai 9.9.9, of another project"); let bin = m.bin.clone(); m.path(&[&shadow, &bin]); let (code, r) = m.doctor(&[]); assert_eq!(r.state("install.path"), "fail", "{}", r.0); assert_eq!(code, 1, "{}", r.0); assert_eq!((r.result(), r.0["ok"].as_bool(), r.0["exit"].as_u64(), r.0["error"]["code"].as_str()), ("failed", Some(false), Some(1), Some("check-failed")), "{}", r.0); let c = r.check("install.path"); let before = c["facts"]["before"].as_array().unwrap(); assert_eq!(before[0]["answer"], "other-program", "{c}"); assert_eq!(before[0]["version"], Value::Null, "what it printed is not taken for a version: {c}"); assert!(r.message("install.path").contains("is another program"), "{c}"); let fix = r.fix("install.path"); assert!(fix.contains(&*other.display().to_string()) && fix.contains("first on PATH"), "{fix}"); // The text says the same in one line. let t = String::from_utf8_lossy(&m.wai(&["doctor"]).stdout).into_owned(); let l = t.lines().find(|l| l.contains("install.path")).unwrap(); assert!(l.starts_with("FAIL") && l.contains("; fix: "), "{l}"); } #[test] fn an_older_wai_and_another_copy_are_told_apart_from_another_program() { let mut m = Machine::new("shadow-wai"); m.healthy(); // The envelope an older `wai` prints for `--version`: it knows no such operation. let older = m.dir("older"); m.stand_in(&older, "wai", r#"{"wai":"envelope/1","op":null,"ok":false,"exit":5,"error":{"code":"operation-not-found","message":"m","hint":"h","retryable":false}}"#); let bin = m.bin.clone(); m.path(&[&older, &bin]); let (code, r) = m.doctor(&[]); assert_eq!((code, r.state("install.path")), (1, "fail".to_string()), "{}", r.0); assert_eq!(r.check("install.path")["facts"]["before"][0]["answer"], "older-wai", "{}", r.0); // A wai of some version, and both come first: each is named. let copy = m.dir("copy"); m.stand_in(©, "wai", r#"{"wai":"envelope/1","op":"toolchain.show","ok":true,"data":{"toolchain":"0.3.9"}}"#); m.path(&[©, &older, &bin]); let (_, r) = m.doctor(&[]); let c = r.check("install.path"); assert_eq!(c["state"], "fail", "{c}"); assert_eq!((c["facts"]["before"][0]["answer"].as_str(), c["facts"]["before"][0]["version"].as_str(), c["facts"]["before"][1]["answer"].as_str()), (Some("wai"), Some("0.3.9"), Some("older-wai")), "{c}"); assert!(r.message("install.path").contains("toolchain 0.3.9"), "{c}"); } #[test] fn a_program_that_hangs_is_a_finding_and_the_timeout_holds() { let mut m = Machine::new("shadow-hang"); m.healthy(); let shadow = m.dir("shadow"); let pids = m.root.join("pids"); hanging(&shadow, "wai", &pids); let bin = m.bin.clone(); m.path(&[&shadow, &bin]); let started = Instant::now(); // On Windows, the stand-in's child is watched for while the run waits on it. let (seen, (code, r)) = doctor_while_hanging(&m, "wai"); let took = started.elapsed(); // It sleeps for 300 s, holding its standard output through its child. A few seconds on a quiet // machine, and this allows for a loaded one. assert!(took < Duration::from_secs(45), "{took:?}"); assert_eq!(code, 1, "{}", r.0); let c = r.check("install.path"); assert_eq!(c["state"], "fail", "{c}"); assert_eq!(c["facts"]["before"][0]["answer"], "no-answer", "{c}"); assert!(r.message("install.path").contains("did not answer"), "{c}"); // What it started is stopped too. #[cfg(unix)] { let _ = seen; let pids = std::fs::read_to_string(&pids).expect("the stand-in ran"); for pid in pids.split_whitespace() { assert!(gone(pid), "pid {pid} is still running"); } } // On Windows the stand-in is cmd.exe running the script, and its child, the grandchild of the // run, holds the pipe. #[cfg(windows)] grandchild_gone("wai", seen); } /// A program asked that answers, and leaves a child running behind it, has that child ended too: it /// is in the program's process group (on Windows, its job), ended once the program has exited and /// before it is reaped. #[test] fn what_a_program_that_answered_left_running_is_ended() { let mut m = Machine::new("shadow-leaves"); m.healthy(); let shadow = m.dir("shadow"); let pids = m.root.join("left-pids"); let answer = r#"{"wai":"envelope/1","op":"toolchain.show","ok":true,"data":{"toolchain":"0.3.9"}}"#; let mut windows = String::new(); if cfg!(windows) { let copy = ping_copy(&shadow, &grandchild_image("left")); windows = format!("start \"\" /b \"{}\" -n 300 -w 1000 127.0.0.1 >nul\r\necho {answer}", copy.display()); } program(&shadow, "wai", &format!("/bin/sleep 300 > /dev/null 2>&1 &\necho \"$!\" > '{}'\nprintf '%s\\n' '{answer}'", pids.display()), &windows); let bin = m.bin.clone(); m.path(&[&shadow, &bin]); let started = Instant::now(); // On Windows a child inherits its parent's handles, the run's own output among them, so a // grandchild left running would keep the run from ending: it is looked for while the run waits. let (_, r) = std::thread::scope(|s| { let run = s.spawn(|| m.doctor(&[])); #[cfg(windows)] { let image = grandchild_image("left"); while !run.is_finished() { if started.elapsed() > Duration::from_secs(45) { let left = running(&image); end(&image); assert!(!left, "{image} is still running, and holds the run's output open"); panic!("the run did not end within 45 s"); } std::thread::sleep(Duration::from_millis(200)); } } run.join().expect("the run") }); assert!(started.elapsed() < Duration::from_secs(45), "{:?}", started.elapsed()); let c = r.check("install.path"); assert_eq!((c["facts"]["before"][0]["answer"].as_str(), c["facts"]["before"][0]["version"].as_str()), (Some("wai"), Some("0.3.9")), "it answered: {c}"); #[cfg(unix)] { let pid = std::fs::read_to_string(&pids).expect("the stand-in ran"); assert!(gone(pid.trim()), "pid {} is still running", pid.trim()); } #[cfg(windows)] { let image = grandchild_image("left"); let started = Instant::now(); while running(&image) { if started.elapsed() > Duration::from_secs(15) { end(&image); panic!("{image} is still running"); } std::thread::sleep(Duration::from_millis(200)); } } } /// Whether a process is gone or a zombie nobody reaped, within a while (in a container with no init, /// an orphan is never reaped). #[cfg(unix)] fn gone(pid: &str) -> bool { let Some(ps) = ["/bin/ps", "/usr/bin/ps"].iter().find(|p| Path::new(p).is_file()) else { return true; }; let started = Instant::now(); while started.elapsed() < Duration::from_secs(15) { let o = Command::new(ps).args(["-o", "stat=", "-p", pid]).output().unwrap(); let s = String::from_utf8_lossy(&o.stdout); if s.trim().is_empty() || s.trim().starts_with('Z') { return true; } std::thread::sleep(Duration::from_millis(100)); } false } /// A relative entry of `PATH` is whatever directory a person stands in, so what is there is named, /// and not run. #[test] fn a_wai_reached_through_a_relative_entry_of_path_is_not_run() { let mut m = Machine::new("shadow-relative"); m.healthy(); let here = m.dir("cwd").join("sub"); std::fs::create_dir_all(&here).unwrap(); let ran = m.root.join("ran"); program(&here, "wai", &format!("echo ran > '{}'", ran.display()), &format!("echo ran> \"{}\"", ran.display())); let bin = m.bin.clone(); m.path(&[Path::new("sub"), &bin]); let (code, r) = m.doctor(&[]); assert_eq!(code, 1, "{}", r.0); let c = r.check("install.path"); assert_eq!(c["state"], "fail", "{c}"); assert_eq!(c["facts"]["before"][0]["answer"], "not-asked", "{c}"); assert!(!ran.exists(), "it was run"); } #[test] fn further_along_path_and_off_path_are_said_as_they_are() { let mut m = Machine::new("path-others"); m.healthy(); // Later on PATH: not reached, so not asked and no failure. let later = m.dir("later"); let ran = m.root.join("later-ran"); let found = program(&later, "wai", &format!("echo ran > '{}'", ran.display()), &format!("echo ran> \"{}\"", ran.display())); let bin = m.bin.clone(); m.path(&[&bin, &later]); let (_, r) = m.doctor(&[]); let c = r.check("install.path"); assert_eq!(c["state"], "pass", "{c}"); assert_eq!(c["facts"]["after"][0].as_str(), Some(&*found.display().to_string()), "{c}"); assert!(!ran.exists(), "what is not reached is not asked"); // Off PATH, and nothing else there either. let empty = m.dir("empty"); m.path(&[&empty]); let (code, r) = m.doctor(&[]); assert_eq!((code, r.state("install.path")), (1, "fail".to_string()), "{}", r.0); assert!(r.message("install.path").contains("not on PATH") && r.fix("install.path").contains("to PATH"), "{}", r.0); // Off PATH, and another is reached instead. m.path(&[&later]); let (_, r) = m.doctor(&[]); assert_eq!(r.state("install.path"), "fail", "{}", r.0); assert!(r.message("install.path").contains("reaches another"), "{}", r.0); assert!(ran.exists(), "what a prompt reaches is asked"); // Through a symbolic link, it is this binary. #[cfg(unix)] { let links = m.dir("links"); std::os::unix::fs::symlink(bin.join("wai"), links.join("wai")).unwrap(); m.path(&[&links]); let (_, r) = m.doctor(&[]); assert_eq!(r.state("install.path"), "pass", "{}", r.0); } } #[test] fn a_shim_is_the_program_its_shim_file_names() { // WAI's bucket puts a generic program first on PATH (shims/wai.exe) and, beside it, // shims/wai.shim, whose `path` line names what it starts: this binary, under another directory. let mut m = Machine::new("path-shim"); m.healthy(); let shims = m.dir("shims"); let ran = m.root.join("shim-ran"); let shim = program(&shims, "wai", &format!("echo ran > '{}'", ran.display()), &format!("echo ran> \"{}\"", ran.display())); let file = shim.with_extension("shim"); let this = m.bin.join(format!("wai{}", std::env::consts::EXE_SUFFIX)); std::fs::write(&file, format!("path = \"{}\"\nargs = \n", this.display())).unwrap(); m.path(&[&shims]); let (_, r) = m.doctor(&[]); assert_eq!(r.state("install.path"), "pass", "{}", r.0); assert_eq!(r.check("install.path")["facts"]["on_path"], true, "{}", r.0); assert!(!ran.exists(), "a shim of this binary is not asked what it is"); // Unquoted, and from the shim's directory. std::fs::write(&file, format!("path = ../bin/wai{}\n", std::env::consts::EXE_SUFFIX)).unwrap(); let (_, r) = m.doctor(&[]); assert_eq!(r.state("install.path"), "pass", "{}", r.0); // A shim of another program comes first as that program would: asked, and a finding. let elsewhere = m.dir("elsewhere"); let other = program(&elsewhere, "wai", "echo wai 9.9.9, of another project", "echo wai 9.9.9, of another project"); std::fs::write(&file, format!("path = \"{}\"\n", other.display())).unwrap(); let (code, r) = m.doctor(&[]); assert_eq!((code, r.state("install.path")), (1, "fail".to_string()), "{}", r.0); assert_eq!(r.check("install.path")["facts"]["before"][0]["path"].as_str(), Some(&*shim.display().to_string()), "{}", r.0); assert!(ran.exists(), "what a prompt reaches is asked"); // With no `path` line, or no file, it is a program of its own, as any other is. std::fs::write(&file, "args = --quiet\n").unwrap(); let (_, r) = m.doctor(&[]); assert_eq!(r.state("install.path"), "fail", "{}", r.0); std::fs::remove_file(&file).unwrap(); let (_, r) = m.doctor(&[]); assert_eq!(r.state("install.path"), "fail", "{}", r.0); } // ─────────────────────────────── what wai dev starts ─────────────────────────────── #[test] fn a_missing_sibling_fails_with_where_it_belongs() { let mut m = Machine::new("sibling-missing"); m.healthy(); std::fs::remove_file(if cfg!(windows) { m.bin.join("wai-relay.cmd") } else { m.bin.join("wai-relay") }).unwrap(); m.unset("WAI_RELAY_BIN"); let (code, r) = m.doctor(&[]); assert_eq!(code, 1, "{}", r.0); assert_eq!(r.state("stack.wai-relay"), "fail", "{}", r.0); assert!(r.message("stack.wai-relay").contains("wai-relay is not beside"), "{}", r.0); let fix = r.fix("stack.wai-relay"); assert!(fix.contains(&format!("wai {VERSION}")) && fix.contains("WAI_RELAY_BIN"), "{fix}"); assert_eq!(r.state("stack.wai-room"), "pass", "the other is unaffected: {}", r.0); // Named, and not there. m.set("WAI_RELAY_BIN", m.root.join("gone")); let (_, r) = m.doctor(&[]); assert_eq!(r.state("stack.wai-relay"), "fail", "{}", r.0); assert!(r.message("stack.wai-relay").contains("is not a file"), "{}", r.0); } #[test] fn a_sibling_of_another_toolchain_fails() { let mut m = Machine::new("sibling-version"); m.healthy(); // Another version of the toolchain. let d = m.dir("others"); let other = m.sibling_answer("wai-relay", "0.3.0"); let p = m.stand_in(&d, "wai-relay", &other); m.set("WAI_RELAY_BIN", p); let (code, r) = m.doctor(&[]); assert_eq!(code, 1, "{}", r.0); assert_eq!(r.state("stack.wai-relay"), "fail", "{}", r.0); assert!(r.message("stack.wai-relay").contains("0.3.0") && r.message("stack.wai-relay").contains(VERSION), "{}", r.0); assert_eq!(r.check("stack.wai-relay")["facts"]["toolchain"], "0.3.0", "{}", r.0); // One from before the daemons stated a version, which says how it is run and exits 2; and another program. let usage = program(&d, "old-relay", "echo 'usage: wai-relay (--config | --dev)' >&2\nexit 2", "echo usage: wai-relay 1>&2\r\nexit /b 2"); m.set("WAI_RELAY_BIN", usage); let (_, r) = m.doctor(&[]); assert_eq!(r.state("stack.wai-relay"), "fail", "{}", r.0); assert!(r.message("stack.wai-relay").contains("does not state its toolchain"), "{}", r.0); // The room service's answer is not the relay's. let room = m.sibling_answer("wai-room", VERSION); let p = m.stand_in(&d, "wrong-name", &room); m.set("WAI_RELAY_BIN", p); let (_, r) = m.doctor(&[]); assert_eq!(r.state("stack.wai-relay"), "fail", "{}", r.0); } #[test] fn a_sibling_that_hangs_is_not_checked_and_one_that_cannot_start_fails() { let mut m = Machine::new("sibling-hang"); m.healthy(); let d = m.dir("others"); let pids = m.root.join("pids"); let p = hanging(&d, "stuck", &pids); m.set("WAI_ROOM_BIN", p); // It is stopped when its 30 s are up; it sleeps for 300 s, holding its standard output through // its child, and this allows a loaded machine 15 s more. let started = Instant::now(); let (seen, (code, r)) = doctor_while_hanging(&m, "stuck"); assert!(started.elapsed() < Duration::from_secs(45), "{:?}", started.elapsed()); let c = r.check("stack.wai-room"); assert_eq!(c["state"], "not-checked", "{c}"); assert!(r.message("stack.wai-room").contains("did not answer for its version within 30 s") && r.message("stack.wai-room").contains("was not checked"), "{c}"); assert_eq!(c["fix"], Value::Null, "{c}"); // It was stopped with what it started: it had its 30 s, so it had started and noted its own // number and its child's long before. #[cfg(unix)] { let _ = seen; let noted = std::fs::read_to_string(&pids).expect("the stand-in ran"); assert_eq!(noted.split_whitespace().count(), 2, "{noted}"); for pid in noted.split_whitespace() { assert!(gone(pid), "pid {pid} of the room service that hung is still running"); } } // On Windows, its child was seen running, and is gone. #[cfg(windows)] grandchild_gone("stuck", seen); // Alone, it does not fail the run. if r.arranged_failed().is_empty() && !r.machine_failed() { assert_eq!((code, r.result(), r.0["ok"].as_bool()), (0, "passed-with-not-checked", Some(true)), "{}", r.0); } #[cfg(unix)] { let p = d.join("not-executable"); std::fs::write(&p, "#!/bin/sh\n").unwrap(); mode(&p, 0o644); m.set("WAI_ROOM_BIN", p); let (_, r) = m.doctor(&[]); assert_eq!(r.state("stack.wai-room"), "fail", "{}", r.0); assert!(r.message("stack.wai-room").contains("could not be started"), "{}", r.0); } } /// The relay and the room service have the time a first start can take, as a browser has: on the /// first run after the archive is installed each is a program the system has not run, asked at the /// same moment as the browsers, and on macOS 15 its first start waits on their checks /// (wai/changes/2026-11-13-doctor-stack-first-start.md). Each stand-in here answers after 7 s: more /// than the 3 s a program has, and more than the 6 s of a group that asks no program in its first /// start, so either reads it as not checked; with a first start's 30 s it passes. A sleep does not /// end early, so no clock can pass what should fail, and what should pass has 23 s to spare. #[cfg(unix)] #[test] fn a_program_of_the_stack_has_the_time_a_first_start_can_take() { let mut m = Machine::new("sibling-first-start"); m.healthy(); let slow = m.dir("slow"); for (name, env) in [("wai-relay", "WAI_RELAY_BIN"), ("wai-room", "WAI_ROOM_BIN")] { let answer = m.sibling_answer(name, VERSION); let p = program(&slow, name, &format!("/bin/sleep 7\nprintf '%s\\n' '{answer}'"), ""); m.set(env, p); } let (_, r) = m.doctor(&[]); for id in ["stack.wai-relay", "stack.wai-room"] { assert_eq!(r.state(id), "pass", "{id}: {}", r.0); assert!(r.message(id).contains(&format!("of the toolchain {VERSION}, as this wai is")), "{id}: {}", r.0); } } // ─────────────────────────────── the ports ─────────────────────────────── /// One table: every row of the quickstart's README is a check, and no port is checked that is not. #[test] fn every_row_of_the_port_table_is_a_check_and_only_those() { let m = Machine::new("ports"); let (_, r) = m.doctor(&[]); let checked: Vec = r.checks().iter().filter_map(|c| c["id"].as_str().unwrap().strip_prefix("port.").map(str::to_string)).collect(); let rows: Vec = table().into_iter().map(|(label, ..)| label).collect(); assert!(!rows.is_empty()); assert_eq!(checked, rows); for c in r.checks().iter().filter(|c| c["id"].as_str().unwrap().starts_with("port.")) { let state = c["state"].as_str().unwrap(); assert!(["pass", "fail", "not-checked"].contains(&state), "{c}"); assert_eq!(state == "fail", c["fix"].is_string(), "{c}"); } } /// The first port of the rows that this test can take and that `pick` selects. fn hold_tcp(pick: impl Fn(&(String, u16, bool, bool)) -> bool) -> Option<(String, TcpListener)> { table().into_iter().filter(|r| !r.2 && pick(r)).find_map(|r| TcpListener::bind(("127.0.0.1", r.1)).ok().map(|l| (r.0, l))) } #[test] fn a_port_this_test_holds_is_said_to_be_held_by_this_test() { let m = Machine::new("port-held"); let (label, held) = hold_tcp(|r| !r.3).expect("a row of the table free to hold"); let (off_label, off_held) = hold_tcp(|r| r.3).expect("a row off until set free to hold"); let udp = table().into_iter().filter(|r| r.2 && !r.3).find_map(|r| UdpSocket::bind(("127.0.0.1", r.1)).ok().map(|s| (r.0, s))); let id = format!("port.{label}"); let (code, r) = m.doctor(&[]); assert_eq!(code, 1, "{}", r.0); assert_eq!(r.state(&id), "fail", "{}", r.0); let c = r.check(&id); assert_eq!(c["facts"]["state"], "held", "{c}"); let msg = r.message(&id); // On every system this test runs on it holds the port under its own account, so the system // names it, unless the system's own tool ran out of time on a loaded machine. if !msg.contains("did not answer") { assert_eq!(c["facts"]["holder"]["pid"].as_u64(), Some(u64::from(std::process::id())), "{c}"); assert!(msg.contains(&format!("pid {}", std::process::id())), "{msg}"); } let fix = r.fix(&id); assert!(fix.contains("move") && fix.contains(c["facts"]["setting"].as_str().unwrap()), "{fix}"); // A listener off until set needs nothing yet: said held, and no failure. let c = r.check(&format!("port.{off_label}")); assert_eq!((c["state"].as_str(), c["facts"]["state"].as_str()), (Some("pass"), Some("held")), "{c}"); assert!(c["message"].as_str().unwrap().contains("only once"), "{c}"); // A UDP row is tried over UDP. if let Some((l, _)) = &udp { let c = r.check(&format!("port.{l}")); assert_eq!((c["state"].as_str(), c["facts"]["protocol"].as_str(), c["facts"]["state"].as_str()), (Some("fail"), Some("udp"), Some("held")), "{c}"); } drop((held, off_held)); // Let go of, it is free again. let (_, r) = m.doctor(&[]); assert_ne!(r.check(&id)["facts"]["state"], "unknown", "{}", r.0); } // ─────────────────────────────── browsers ─────────────────────────────── /// Each browser found is asked its version, `--version` alone, with the time a browser has (30 s, /// what a browser's first start after it is installed can take), and the checks pass only on one /// that runs: one that answers, one that hangs, one that exits with a failure, and one that cannot /// be started. #[test] fn browsers_are_asked_their_version_and_pass_only_when_one_runs() { let mut m = Machine::new("browsers"); m.healthy(); let none = m.dir("no-automation"); m.set("PLAYWRIGHT_BROWSERS_PATH", &none); // A BROWSER_BIN that is not there. let no_browser = m.root.join("no-browser"); m.set("BROWSER_BIN", &no_browser); let (_, r) = m.doctor(&[]); assert_eq!(r.state("browser.conformance"), "fail", "{}", r.0); assert!(r.message("browser.conformance").contains("not a program here") && r.fix("browser.conformance").contains("playwright install"), "{}", r.0); // One that is there and cannot be started. let inert = m.root.join(if cfg!(windows) { "inert.exe" } else { "inert" }); std::fs::write(&inert, "#!/bin/sh\necho 'Chromium 1.0'\n").unwrap(); mode(&inert, 0o644); m.set("BROWSER_BIN", &inert); let (_, r) = m.doctor(&[]); assert_eq!(r.state("browser.conformance"), "fail", "{}", r.0); assert!(r.message("browser.conformance").contains("does not run: it could not be started"), "{}", r.0); m.unset("BROWSER_BIN"); // On PATH: one that answers, one that hangs, one that exits 3. let cmds = m.dir("commands"); let answers = program(&cmds, "chromium", "echo 'Chromium 141.0.1'", "echo Chromium 141.0.1"); let hang_pids = m.root.join("firefox-pids"); let hangs = hanging(&cmds, "firefox", &hang_pids); let fails = program(&cmds, "chromium-browser", "exit 3", "exit /b 3"); let bin = m.bin.clone(); m.path(&[&bin, &cmds]); // The one that hangs is stopped when its 30 s are up; it sleeps for 300 s, holding its standard // output through its child, and this allows a loaded machine 15 s more. let started = Instant::now(); let (seen, (_, r)) = doctor_while_hanging(&m, "firefox"); assert!(started.elapsed() < Duration::from_secs(45), "{:?}", started.elapsed()); let state_of = |r: &Report, p: &Path| -> String { let rows = r.check("browser.studio")["facts"]["browsers"].as_array().unwrap().clone(); let row = rows.iter().find(|b| b["path"].as_str() == Some(&*p.display().to_string())).unwrap_or_else(|| panic!("{} is not listed: {}", p.display(), r.0)); row["state"].as_str().unwrap().to_string() }; assert_eq!(r.state("browser.studio"), "pass", "{}", r.0); assert_eq!((state_of(&r, &answers), state_of(&r, &hangs), state_of(&r, &fails)), ("runs".into(), "no-answer".into(), "does-not-run".into()), "{}", r.0); let msg = r.message("browser.studio"); assert!(msg.contains("runs (Chromium 141.0.1)") && msg.contains("no answer to --version within 30 s") && msg.contains("it exited 3"), "{msg}"); // The one that hung was stopped with what it started: it had its 30 s, so it had started and // noted its own number and its child's long before. #[cfg(unix)] { let _ = seen; let pids = std::fs::read_to_string(&hang_pids).expect("the browser that hangs ran"); assert_eq!(pids.split_whitespace().count(), 2, "{pids}"); for pid in pids.split_whitespace() { assert!(gone(pid), "pid {pid} of the browser that hung is still running"); } } #[cfg(windows)] grandchild_gone("firefox", seen); assert_eq!(r.state("browser.conformance"), "pass", "{}", r.0); assert!(r.message("browser.conformance").contains("once it is given as BROWSER_BIN"), "{}", r.0); // The machine's own installed browsers are looked at on macOS, so these stop here there. if !cfg!(target_os = "macos") { // None runs, one did not answer: not checked. The browser of the Chromium family does not run. std::fs::remove_file(&answers).unwrap(); let (seen, (code, r)) = doctor_while_hanging(&m, "firefox"); assert_eq!((r.state("browser.studio"), r.state("browser.conformance")), ("not-checked".into(), "fail".into()), "{}", r.0); assert_eq!(code, 1, "{}", r.0); #[cfg(windows)] grandchild_gone("firefox", seen); #[cfg(not(windows))] let _ = seen; // None runs, and each was seen not to. std::fs::remove_file(&hangs).unwrap(); let (_, r) = m.doctor(&[]); assert_eq!((r.state("browser.studio"), r.state("browser.conformance")), ("fail".into(), "fail".into()), "{}", r.0); assert!(r.message("browser.studio").contains("none runs"), "{}", r.0); } // The automation package's browser, by its program in the directory it is told to use. #[cfg(unix)] { let auto = m.dir("automation"); let chrome = auto.join("chromium-1000/chrome-linux/chrome"); std::fs::create_dir_all(chrome.parent().unwrap()).unwrap(); program::write_program(&chrome, &format!("#!/bin/sh\n{FIRST_START}\necho 'Chromium 9.9'\n")); started_once(&chrome); std::fs::write(auto.join("chromium-1000/INSTALLATION_COMPLETE"), "").unwrap(); m.set("PLAYWRIGHT_BROWSERS_PATH", &auto); let (_, r) = m.doctor(&[]); assert_eq!(r.state("browser.conformance"), "pass", "{}", r.0); let msg = r.message("browser.conformance"); assert!(msg.contains("automation package") && msg.contains("runs (Chromium 9.9)") && msg.contains("npx playwright install webkit firefox"), "{msg}"); } } // ─────────────────────────────── the key store ─────────────────────────────── /// A fix as a person runs it, in this system's shell. fn run_fix(fix: &str) { #[cfg(windows)] let o = { use std::os::windows::process::CommandExt; Command::new("cmd").raw_arg(format!("/S /C \"{fix}\"")).output().unwrap() }; #[cfg(not(windows))] let o = Command::new("/bin/sh").args(["-c", fix]).output().unwrap(); assert!(o.status.success(), "{fix}: {}", text(&o)); } #[test] fn a_key_directory_and_key_files_too_open_fail_and_their_fix_makes_them_pass() { let m = Machine::new("key-open"); let k = m.keystore(); let seed = k.join("signer.seed"); std::fs::write(&seed, "0").unwrap(); // A directory and a file made the plain way have a wide mode on Unix, and on Windows take their // folder's access list, which lets more than this account in. mode(&k, 0o755); mode(&seed, 0o644); let at = k.to_str().unwrap(); let (code, r) = m.doctor(&["--keystore", at]); assert_eq!(code, 1, "{}", r.0); assert_eq!(r.state("keystore.directory"), "fail", "{}", r.0); assert_eq!(r.state("keystore.files"), "fail", "{}", r.0); assert!(r.message("keystore.files").contains("signer.seed"), "{}", r.0); assert_eq!(r.check("keystore.files")["facts"]["exposed"][0].as_str(), Some(&*seed.display().to_string()), "{}", r.0); let (fix_dir, fix_files) = (r.fix("keystore.directory"), r.fix("keystore.files")); if cfg!(unix) { assert!(r.message("keystore.directory").contains("mode is 755"), "{}", r.0); assert!(fix_dir.starts_with("chmod 700 ") && fix_files.starts_with("chmod 600 "), "{fix_dir}; {fix_files}"); } else { assert!(fix_dir.starts_with("icacls ") && fix_files.starts_with("icacls "), "{fix_dir}; {fix_files}"); } run_fix(&fix_files); let (_, r) = m.doctor(&["--keystore", at]); assert_eq!(r.state("keystore.files"), "pass", "the file's fix: {}", r.0); assert_eq!(r.state("keystore.directory"), "fail", "and only the file's: {}", r.0); run_fix(&fix_dir); let (_, r) = m.doctor(&["--keystore", at]); assert_eq!(r.state("keystore.directory"), "pass", "the directory's fix: {}", r.0); assert_eq!(r.state("keystore.files"), "pass", "{}", r.0); } #[test] fn a_keystore_wai_key_made_is_private_and_where_wai_key_keeps_it() { let mut m = Machine::new("key-made"); let k = m.root.join("made"); m.set("WAI_KEYSTORE", &k); let (_, r) = m.doctor(&[]); assert_eq!(r.state("keystore.directory"), "pass", "{}", r.0); assert!(r.message("keystore.directory").contains("no keystore yet"), "{}", r.0); assert_eq!(r.state("keystore.files"), "not-applicable", "{}", r.0); let o = m.wai(&["key", "create", "signer", "--for", "signer", "--store", "file"]); assert!(o.status.success(), "{}", text(&o)); let (_, r) = m.doctor(&[]); assert_eq!(r.state("keystore.directory"), "pass", "{}", r.0); assert_eq!(r.state("keystore.files"), "pass", "{}", r.0); assert_eq!(r.check("keystore.files")["facts"]["files"], 3, "the record, the seed and the record's receipt, each this account's alone: {}", r.0); assert!(r.message("keystore.directory").contains(&*k.display().to_string()), "{}", r.0); // A seed's bytes are in no output, in either form. let seed = std::fs::read_to_string(k.join("signer.seed")).unwrap(); let (j, t) = (m.wai(&["doctor", "--json"]), m.wai(&["doctor"])); for o in [j, t] { assert!(!text(&o).contains(seed.trim()), "a seed was printed"); } // --keystore names another, as on wai key. let other = m.keystore(); mode(&other, 0o700); let (_, r) = m.doctor(&["--keystore", other.to_str().unwrap()]); assert!(r.message("keystore.directory").contains(&*other.display().to_string()), "{}", r.0); } /// A seed is asked about and never opened: a named pipe blocks whoever opens it, so a run that opened /// this one would read as not checked, or not return. #[cfg(unix)] #[test] fn a_seed_is_never_opened() { let m = Machine::new("key-unopened"); let k = m.keystore(); mode(&k, 0o700); let pipe = k.join("piped.seed"); let o = Command::new("mkfifo").arg(&pipe).output().expect("mkfifo runs"); assert!(o.status.success(), "{}", text(&o)); mode(&pipe, 0o600); let secret = k.join("kept.seed"); let canary = "c0".repeat(32); std::fs::write(&secret, &canary).unwrap(); mode(&secret, 0o600); let at = k.to_str().unwrap(); let (_, r) = m.doctor(&["--keystore", at]); assert_eq!(r.state("keystore.directory"), "pass", "{}", r.0); assert_eq!(r.state("keystore.files"), "pass", "{}", r.0); assert!(!r.0.to_string().contains(&canary), "a seed was printed"); } #[test] fn a_file_where_the_keystore_belongs_is_said_so() { let m = Machine::new("key-odd"); let f = m.root.join("a-file"); std::fs::write(&f, "x").unwrap(); let (code, r) = m.doctor(&["--keystore", f.to_str().unwrap()]); assert_eq!(code, 1, "{}", r.0); assert_eq!(r.state("keystore.directory"), "fail", "{}", r.0); assert!(r.message("keystore.directory").contains("not a directory"), "{}", r.0); assert_eq!(r.state("keystore.files"), "not-checked", "{}", r.0); } // ─────────────────────────────── not checked ─────────────────────────────── #[test] fn a_check_that_cannot_run_says_not_checked_never_pass() { let mut m = Machine::new("not-checked"); m.healthy(); // No home, and no other way to say where keys are kept. m.unset("HOME").unset("APPDATA"); let (code, r) = m.doctor(&[]); for id in ["keystore.directory", "keystore.files"] { assert_eq!(r.state(id), "not-checked", "{id}: {}", r.0); assert!(!r.message(id).is_empty(), "{id} says why: {}", r.0); assert_eq!(r.check(id)["fix"], Value::Null, "{id}: {}", r.0); } assert!(r.message("keystore.directory").contains("home directory"), "{}", r.0); assert!(r.data()["counts"]["not_checked"].as_u64().unwrap() >= 2, "{}", r.0); // It reads as what it is, and a run with nothing failed still says so. if !r.machine_failed() { assert_eq!(r.arranged_failed(), Vec::::new(), "{}", r.0); assert_eq!((code, r.result(), r.0["ok"].as_bool()), (0, "passed-with-not-checked", Some(true)), "{}", r.0); } let t = String::from_utf8_lossy(&m.wai(&["doctor"]).stdout).into_owned(); assert!(t.lines().any(|l| l.starts_with("not checked") && l.contains("keystore.directory")), "{t}"); if !r.machine_failed() { assert!(t.lines().last().unwrap().contains("not checked"), "{t}"); } // PATH not set: what a prompt reaches cannot be told. m.unset("PATH"); let (_, r) = m.doctor(&[]); assert_eq!(r.state("install.path"), "not-checked", "{}", r.0); assert!(r.message("install.path").contains("PATH is not set"), "{}", r.0); } /// A directory this account may not list: the files in it are not a pass. #[cfg(unix)] #[test] fn a_keystore_that_cannot_be_listed_is_not_checked() { let m = Machine::new("not-listed"); let k = m.keystore(); mode(&k, 0o000); if std::fs::read_dir(&k).is_ok() { // Root reads anything, so this test has nothing to show there. mode(&k, 0o700); return; } let (_, r) = m.doctor(&["--keystore", k.to_str().unwrap()]); mode(&k, 0o700); assert_eq!(r.state("keystore.directory"), "pass", "its mode lets no one else in: {}", r.0); assert_eq!(r.state("keystore.files"), "not-checked", "{}", r.0); assert!(r.message("keystore.files").contains("could not be listed"), "{}", r.0); }