open-standards/wai/wai-rs/tests/cli_doctor.rs
Transaction Science 2a381712f3 cli_doctor: the Windows stand-ins have their first start, and the hang runs there end what they started
bb3e3fcb0 gave each stand-in script a first start before wai doctor asks it, on Unix only: on
Windows every .cmd stand-in was asked cold, within the 3 s a program has, while Windows scans a
file it has not run before on its first run; and since 2423bb1bb the doctor is run once. Each
.cmd now begins, after @echo off, with "if defined WAI_TEST_FIRST_START exit /b 0" and is started
so, once, and waited for, before the doctor is given it; the ping copies the hang stand-ins start
as their child ping once first.

Every run of the doctor while a stand-in hangs goes through one watcher: on Windows the child is
looked for while the run waits, ended (and the test failed) if the run has not ended in 45 s, and
must have been seen and be gone within 15 s after, as the noted pids must be on Unix. The room
service's and the browser's hang runs had neither on Windows. No time allowed and no retry changes.

On the Windows CI host the whole file passed four times (20 tests); planted, a first-start line
that exits 7 fails all 14 tests that write a stand-in, and a doctor that leaves a stopped
program's tree running fails the four whose stand-in starts a child, each within 45 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-13 21:09:04 -05:00

1193 lines
57 KiB
Rust

//! `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<String, OsString>,
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<OsString>) -> &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<PathBuf, u64> {
fn walk(d: &Path, out: &mut BTreeMap<PathBuf, u64>) {
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, &copy).unwrap();
let s = Command::new(&copy).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<Value> {
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<String> {
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::<String>::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(&copy, "wai", r#"{"wai":"envelope/1","op":"toolchain.show","ok":true,"data":{"toolchain":"0.3.9"}}"#);
m.path(&[&copy, &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 <file.toml> | --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<String> = r.checks().iter().filter_map(|c| c["id"].as_str().unwrap().strip_prefix("port.").map(str::to_string)).collect();
let rows: Vec<String> = 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::<String>::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);
}