main
Drew Owens 0.11.3 source 7364e19 5d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558
//! Test harness: boots the REAL compiled binary and drives it over HTTP,
//! exactly as ab does.
//!
//! Rules this harness exists to enforce:
//!   * no test may assert by reading source files as strings — every claim is
//!     made against a running process;
//!   * everything the bridge writes goes to a temp dir that is deleted
//!     afterwards (`SWX_SCRATCH_DIR`), so a test can check what landed on disk
//!     and nothing leaks into the developer's real cache;
//!   * every response is checked for the invariants (200, parses as JSON,
//!     carries a boolean `success`) on EVERY call, so those cannot rot;
//!   * every command that goes over the wire is RECORDED, which is what makes
//!     the verb-coverage gate honest — it measures calls actually made, not a
//!     hand-maintained list.

#![allow(dead_code)]

use serde_json::{json, Value};
use std::collections::BTreeSet;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use swx_bridge::httpc;

pub const BIN: &str = env!("CARGO_BIN_EXE_swx-bridge");

/// A temp directory that cleans itself up — unless it is a BORROWED view of
/// one somebody else owns.
///
/// The distinction matters for exactly one kind of test: "a DIFFERENT server
/// process reads back what this one persisted". Two owners of the same path
/// means the first drop deletes the state the second was meant to read, and the
/// test then proves the opposite of what it claims.
pub struct TempDir(pub PathBuf, bool);

impl TempDir {
    pub fn new(tag: &str) -> TempDir {
        static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
        let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
        let dir = std::env::temp_dir().join(format!("swx-test-{}-{tag}-{n}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).expect("create temp dir");
        TempDir(dir, true)
    }

    /// A view of a directory owned elsewhere: usable, never deleted.
    pub fn borrowed(dir: &std::path::Path) -> TempDir {
        TempDir(dir.to_path_buf(), false)
    }
    pub fn join(&self, name: &str) -> PathBuf {
        self.0.join(name)
    }
    pub fn path(&self) -> String {
        self.0.to_string_lossy().to_string()
    }
}

impl Drop for TempDir {
    fn drop(&mut self) {
        if self.1 {
            let _ = std::fs::remove_dir_all(&self.0);
        }
    }
}

/// An OS-assigned free port. (The listener closes before the bridge binds it —
/// the same small race the Python harness lived with, and the bridge's own
/// single-instance guard turns a collision into a clean failure, not a hang.)
pub fn free_port() -> u16 {
    let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind ephemeral");
    l.local_addr().expect("addr").port()
}

pub struct Bridge {
    child: Child,
    pub port: u16,
    pub scratch: TempDir,
    log_path: PathBuf,
    _log_dir: TempDir,
    called: Mutex<BTreeSet<String>>,
    /// Verbs that have been CALLED and then ASSERTED ON. The coverage gate uses
    /// this rather than `called`, because "the suite POSTed swx_kill once and
    /// looked at nothing" is not coverage — it is a line in a log that makes a
    /// gate go green.
    asserted: Mutex<BTreeSet<String>>,
    /// The verb the next `check!` will be attributed to.
    last_verb: Mutex<Option<String>>,
    call_count: std::sync::atomic::AtomicUsize,
}

impl Bridge {
    pub fn start() -> Bridge {
        Bridge::start_with(&[])
    }

    /// Boot the real server in mock mode on a free port, with its own scratch
    /// dir, and wait until /status answers.
    pub fn start_with(extra_env: &[(&str, &str)]) -> Bridge {
        Bridge::start_in(TempDir::new("scratch"), extra_env)
    }

    /// Boot with a scratch dir the caller owns — the only way to prove that
    /// something the bridge PERSISTS (the ownership registry, the route that
    /// worked) is read back by a DIFFERENT server process. A test that reboots
    /// into a fresh temp dir proves the opposite of what it claims to.
    pub fn start_in(scratch: TempDir, extra_env: &[(&str, &str)]) -> Bridge {
        // THE PORT RACE, AND WHY THIS LOOPS.
        //
        // `free_port()` binds a listener, reads the port the OS assigned, and
        // CLOSES it — so between that close and the child's bind, another test
        // binary in the parallel suite can be handed the same port and take it.
        // The loser prints "port already in use — exiting cleanly" and dies,
        // which is the BRIDGE BEHAVING CORRECTLY: it refuses to steal a port
        // somebody else owns. The defect is in this harness's allocation, not in
        // the product, and it made
        // `the_type_mismatch_that_named_the_argument_reaches_the_caller` fail
        // about one run in five under the full suite.
        //
        // So a collision on THAT EXACT CAUSE takes the next port. Every other
        // startup failure still panics with the log, because a harness that
        // retries everything hides real breakage.
        // OWNERSHIP OF THE SCRATCH DIR TRAVELS WITH EACH ATTEMPT and comes back
        // on failure. Handing the inner call a BORROWED view instead left the
        // owned `TempDir` to drop at the end of this function — which DELETES the
        // directory the bridge is using. Three launch tests caught that
        // immediately, which is the system working.
        let mut scratch = scratch;
        for attempt in 0..8 {
            match Self::start_on_a_free_port(scratch, extra_env) {
                Ok(b) => return b,
                Err((returned, log)) if log.contains("already in use") && attempt < 7 => {
                    scratch = returned;
                    continue;
                }
                Err((_, log)) => panic!(
                    "the bridge did not come up on attempt {}: {log}",
                    attempt + 1
                ),
            }
        }
        unreachable!("the loop above either returns or panics")
    }

    fn start_on_a_free_port(
        scratch: TempDir,
        extra_env: &[(&str, &str)],
    ) -> Result<Bridge, (TempDir, String)> {
        let port = free_port();
        let log_dir = TempDir::new("log");
        let log_path = log_dir.join("bridge.log");
        let log = std::fs::File::create(&log_path).expect("create log");
        let log_err = log.try_clone().expect("clone log handle");

        let mut cmd = Command::new(BIN);
        cmd.arg("--port")
            .arg(port.to_string())
            .env("SWX_MOCK", "1")
            .env("ADOM_BIND_HOST", "127.0.0.1")
            .env("SWX_SCRATCH_DIR", scratch.path())
            // The fresh-attach settle (0.11.3) is 20 s in production; a test that
            // is ABOUT it sets its own.
            .env("SWX_READY_SETTLE_SECS", "0")
            // ...and so is the effect re-read of a fresh session (1.5 s).
            .env("SWX_EFFECT_RECHECK_MS", "0")
            // ab injects these; absent here on purpose so the callback path is
            // exercised in its "ab unreachable" shape.
            .env_remove("ADOM_DIRECT_API_URL")
            .env_remove("ADOM_BRIDGE_TOKEN")
            .stdout(Stdio::from(log))
            .stderr(Stdio::from(log_err));
        for (k, v) in extra_env {
            cmd.env(k, v);
        }
        let child = cmd.spawn().unwrap_or_else(|e| panic!("spawn {BIN}: {e}"));

        let bridge = Bridge {
            child,
            port,
            scratch,
            log_path,
            _log_dir: log_dir,
            called: Mutex::new(BTreeSet::new()),
            asserted: Mutex::new(BTreeSet::new()),
            last_verb: Mutex::new(None),
            call_count: std::sync::atomic::AtomicUsize::new(0),
        };

        let deadline = Instant::now() + Duration::from_secs(20);
        loop {
            if let Ok(r) = httpc::get(&bridge.url("/status"), Duration::from_secs(2)) {
                if r.status == 200 {
                    return Ok(bridge);
                }
            }
            // THE CHILD IS GONE. Say so from its log rather than waiting out the
            // deadline — the port collision exits in milliseconds, and the caller
            // decides whether that log means "try another port" or "fail".
            if bridge.is_gone() || Instant::now() > deadline {
                let mut bridge = bridge;
                let log = bridge.log();
                // Hand the directory back so the next attempt uses the SAME one:
                // the caller's test wrote its fixtures there. The failed Bridge is
                // then dropped holding only a BORROWED view, so its Drop kills the
                // child without deleting the directory.
                let scratch = bridge.take_scratch();
                return Err((scratch, log));
            }
            std::thread::sleep(Duration::from_millis(50));
        }
    }

    /// Move the scratch directory OUT, leaving a borrowed view behind — so this
    /// Bridge's `Drop` stops owning it and the caller can hand it to another
    /// attempt.
    fn take_scratch(&mut self) -> TempDir {
        let p = self.scratch.0.clone();
        std::mem::replace(&mut self.scratch, TempDir::borrowed(&p))
    }

    /// Has the child gone? Non-blocking. `try_wait` needs `&mut`, and the child
    /// is behind an immutable borrow here, so this asks the OS about the pid —
    /// which is also how the rest of this project answers liveness questions.
    fn is_gone(&self) -> bool {
        !std::path::Path::new(&format!("/proc/{}", self.child.id())).exists()
    }

    pub fn url(&self, path: &str) -> String {
        format!("http://127.0.0.1:{}{path}", self.port)
    }

    pub fn get(&self, path: &str) -> (u16, Value) {
        let r = httpc::get(&self.url(path), Duration::from_secs(10))
            .unwrap_or_else(|e| panic!("GET {path}: {e}"));
        let body = r.json().unwrap_or_else(|e| panic!("GET {path} body: {e}"));
        (r.status, body)
    }

    /// POST a verb the way ab does, recording it for the coverage gate and
    /// checking the response invariants that every verb must satisfy.
    pub fn call(&self, command: &str, args: Value) -> Value {
        self.call_with_headers(command, args, &[])
    }

    pub fn call_with_headers(
        &self,
        command: &str,
        args: Value,
        headers: &[(String, String)],
    ) -> Value {
        self.called.lock().unwrap().insert(command.to_string());
        *self.last_verb.lock().unwrap() = Some(command.to_string());
        self.call_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
        let body = json!({"command": command, "args": args}).to_string();
        // 120s, not 30: `swx_stop` WAITS for the exit it asked for, up to the
        // rung's own budget (30s for rung (a)), because a real `ExitApp` returns
        // before the process is gone. A client that gives up before the verb does
        // proves nothing about the verb.
        let r = httpc::post_json(&self.url("/command"), headers, &body, Duration::from_secs(120))
            .unwrap_or_else(|e| panic!("POST {command}: {e}\nlog:\n{}", self.log()));
        assert_eq!(
            r.status,
            200,
            "{command} must answer 200 on a well-formed request, got {} / {}",
            r.status,
            r.body
        );
        let value: Value = r
            .json()
            .unwrap_or_else(|e| panic!("{command} response is not JSON: {e}\nlog:\n{}", self.log()));
        assert!(
            value.get("success").map(|s| s.is_boolean()).unwrap_or(false),
            "{command} response must carry a boolean `success`: {value}"
        );
        value
    }

    /// A raw body, for malformed-input tests.
    pub fn post_raw(&self, body: &str) -> (u16, String) {
        let r = httpc::post_json(&self.url("/command"), &[], body, Duration::from_secs(10))
            .expect("post raw");
        (r.status, r.body)
    }

    /// How many verb POSTs this harness has made — the yardstick for the
    /// "one log line per verb" volume check.
    pub fn call_count(&self) -> usize {
        self.call_count.load(std::sync::atomic::Ordering::SeqCst)
    }

    pub fn covered(&self) -> BTreeSet<String> {
        self.called.lock().unwrap().clone()
    }

    /// Called by `check!`: credit the assertion to the verb its NAME mentions,
    /// falling back to the most recent call.
    ///
    /// Crediting the last call unconditionally was foolable in the other
    /// direction: a check named "swx_export_step writes step" would be credited
    /// to whatever happened to be POSTed last, so the gate could be satisfied by
    /// assertions that were never about the verb they covered. Binding to the
    /// named verb makes the credit mean what it says.
    pub fn note_assertion(&self, name: &str) {
        let named = name
            .split(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
            .find(|t| t.starts_with("swx_") && t.len() > 4)
            .map(|t| t.to_string());
        // A check may only credit a verb it NAMES if that verb has actually been
        // called — otherwise a check named after a verb nobody ever POSTed would
        // satisfy the gate on its own. The `covered` gate backstops this, but
        // the two should not be able to disagree.
        let credit = match named {
            Some(v) if self.called.lock().unwrap().contains(&v) => Some(v),
            _ => self.last_verb.lock().unwrap().clone(),
        };
        if let Some(v) = credit {
            self.asserted.lock().unwrap().insert(v);
        }
    }

    /// Verbs with at least one assertion made against their response.
    pub fn asserted(&self) -> BTreeSet<String> {
        self.asserted.lock().unwrap().clone()
    }

    pub fn log(&self) -> String {
        std::fs::read_to_string(&self.log_path).unwrap_or_default()
    }

    pub fn pid(&self) -> u32 {
        self.child.id()
    }
}

impl Drop for Bridge {
    fn drop(&mut self) {
        let _ = self.child.kill();
        let _ = self.child.wait();
    }
}

/// A stand-in for ab's direct API, so the HALF OF THE BRIDGE THAT NEVER SPEAKS
/// TO THE COM HOST can be tested at all.
///
/// `swx_kill`, `swx_close_window`, `swx_dismiss_blocking_dialogs`,
/// `swx_screenshot` and `swx_set_driving_badge` all go out through ab. With no
/// ab reachable they only ever exercised their "ab is unreachable" branch — so
/// a guard on that side could be removed and every test would stay green, which
/// is exactly how round 7's two Criticals survived six reviews.
///
/// It records every request, so a test can assert what was DISPATCHED rather
/// than only what was returned — "the refusal returned an error" and "nothing
/// was sent" are different claims, and the second is the one that matters.
pub struct AbStub {
    pub url: String,
    calls: std::sync::Arc<Mutex<Vec<Value>>>,
    windows: std::sync::Arc<Mutex<Value>>,
    /// Per-ab-verb replies, for the verbs whose ANSWER is what the bridge
    /// branches on. `desktop_find_control` is the gate between "a window looked
    /// like a crash reporter" and "we clicked a button on it", so a stub that
    /// answered every verb with a window list could not tell those apart.
    replies: std::sync::Arc<Mutex<std::collections::BTreeMap<String, Value>>>,
    /// One-shot triggers: when `.0` arrives, `.1` answers `.2` from then on —
    /// swapped BEFORE `.0` is answered, so a test can model "the page changed as
    /// a result of this call" without racing the bridge.
    triggers: std::sync::Arc<Mutex<Vec<(String, String, Value)>>>,
    /// Replies chosen by the REQUEST'S ARGS (0.9.23): `desktop_find_control`
    /// answers by the name / automation id asked for. First match wins; every
    /// key of the rule's `when` must equal the request's arg.
    rules: std::sync::Arc<Mutex<Vec<(String, Value, Value)>>>,
    _thread: std::thread::JoinHandle<()>,
}

impl AbStub {
    /// `windows` is what `desktop_list_windows` reports.
    pub fn start(windows: Value) -> AbStub {
        let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind ab stub");
        let port = listener.local_addr().unwrap().port();
        let calls = std::sync::Arc::new(Mutex::new(Vec::new()));
        let sink = std::sync::Arc::clone(&calls);
        let windows = std::sync::Arc::new(Mutex::new(windows));
        let wins = std::sync::Arc::clone(&windows);
        let replies: std::sync::Arc<Mutex<std::collections::BTreeMap<String, Value>>> =
            std::sync::Arc::new(Mutex::new(std::collections::BTreeMap::new()));
        let reps = std::sync::Arc::clone(&replies);
        let triggers: std::sync::Arc<Mutex<Vec<(String, String, Value)>>> = std::sync::Arc::new(Mutex::new(Vec::new()));
        let trig = std::sync::Arc::clone(&triggers);
        let rules: std::sync::Arc<Mutex<Vec<(String, Value, Value)>>> = std::sync::Arc::new(Mutex::new(Vec::new()));
        let rls = std::sync::Arc::clone(&rules);
        let thread = std::thread::spawn(move || {
            for stream in listener.incoming() {
                let Ok(mut s) = stream else { break };
                use std::io::{Read, Write};
                let mut buf = Vec::new();
                let mut chunk = [0u8; 4096];
                let mut command = String::new();
                let mut req_args = Value::Null;
                // Read headers, then exactly Content-Length bytes.
                loop {
                    let n = match s.read(&mut chunk) {
                        Ok(0) | Err(_) => break,
                        Ok(n) => n,
                    };
                    buf.extend_from_slice(&chunk[..n]);
                    let text = String::from_utf8_lossy(&buf).to_string();
                    if let Some(hdr_end) = text.find("\r\n\r\n") {
                        let len: usize = text
                            .to_lowercase()
                            .split("content-length:")
                            .nth(1)
                            .and_then(|t| t.split(['\r', '\n']).next())
                            .and_then(|t| t.trim().parse().ok())
                            .unwrap_or(0);
                        if buf.len() >= hdr_end + 4 + len {
                            let body = &text[hdr_end + 4..];
                            if let Ok(v) = serde_json::from_str::<Value>(body) {
                                command = v
                                    .get("command")
                                    .and_then(|c| c.as_str())
                                    .unwrap_or("")
                                    .to_string();
                                req_args = v.get("args").cloned().unwrap_or(Value::Null);
                                sink.lock().unwrap().push(v);
                            }
                            break;
                        }
                    }
                }
                {
                    let mut t = trig.lock().unwrap();
                    let fired: Vec<(String, String, Value)> = t.iter().filter(|(c, _, _)| *c == command).cloned().collect();
                    t.retain(|(c, _, _)| *c != command);
                    for (_, target, payload) in fired {
                        reps.lock().unwrap().insert(target, payload);
                    }
                }
                let ruled = rls.lock().unwrap().iter().find(|(c, when, _)| {
                    *c == command && when.as_object().map(|w| w.iter().all(|(k, v)| req_args.get(k) == Some(v))).unwrap_or(false)
                }).map(|(_, _, p)| p.clone());
                let payload = match ruled.as_ref().or(reps.lock().unwrap().get(&command)) {
                    Some(custom) => custom.clone(),
                    None => json!({
                        "success": true,
                        "status": "ok",
                        "exitCode": 0,
                        "stdout": "SUCCESS",
                        "output": json!({"data": {"windows": wins.lock().unwrap().clone()}}).to_string(),
                    }),
                }
                .to_string();
                let _ = write!(
                    s,
                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
                    payload.len(),
                    payload
                );
            }
        });
        AbStub { url: format!("http://127.0.0.1:{port}"), calls, windows, replies, triggers, rules, _thread: thread }
    }

    /// When `trigger` arrives (once), `target` answers `payload` from then on.
    pub fn on_call_set(&self, trigger: &str, target: &str, payload: Value) {
        self.triggers.lock().unwrap().push((trigger.to_string(), target.to_string(), payload));
    }

    /// Answer `command` with `payload` when the request's args include `when`.
    pub fn when(&self, command: &str, when: Value, payload: Value) {
        self.rules.lock().unwrap().push((command.to_string(), when, payload));
    }

    /// Drop every args-matched rule for `command`.
    pub fn clear_rules(&self, command: &str) {
        self.rules.lock().unwrap().retain(|(c, _, _)| c != command);
    }

    /// Answer one ab verb with a specific payload.
    pub fn set_response(&self, command: &str, payload: Value) {
        self.replies.lock().unwrap().insert(command.to_string(), payload);
    }

    /// Replace the window list. Needed because attribution matches on OWNER PID,
    /// which is only knowable once the bridge is up and has answered
    /// `swx_get_app_state`.
    pub fn set_windows(&self, windows: Value) {
        *self.windows.lock().unwrap() = windows;
    }

    /// Every request ab received, in order.
    pub fn calls(&self) -> Vec<Value> {
        self.calls.lock().unwrap().clone()
    }

    /// Requests for one ab verb.
    pub fn calls_to(&self, command: &str) -> Vec<Value> {
        self.calls()
            .into_iter()
            .filter(|c| c.get("command").and_then(|v| v.as_str()) == Some(command))
            .collect()
    }

    /// Did anything DESTRUCTIVE get dispatched?
    pub fn destructive_calls(&self) -> Vec<Value> {
        self.calls()
            .into_iter()
            .filter(|c| {
                matches!(
                    c.get("command").and_then(|v| v.as_str()),
                    Some("shell_execute") | Some("desktop_ui_window")
                )
            })
            .collect()
    }
}

/// One JSON protocol line from a com-host child, skipping event lines.
pub fn read_line_json(stdout: &mut std::io::BufReader<std::process::ChildStdout>) -> Value {
    loop {
        let mut line = String::new();
        std::io::BufRead::read_line(stdout, &mut line).expect("read com-host line");
        if line.is_empty() {
            return Value::Null;
        }
        let v: Value = serde_json::from_str(line.trim()).unwrap_or(Value::Null);
        if v.get("event").is_none() && !v.is_null() {
            return v;
        }
    }
}

/// bridge.json, as shipped.
pub fn manifest() -> Value {
    let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("bridge.json");
    let text = std::fs::read_to_string(&path).expect("read bridge.json");
    serde_json::from_str(&text).expect("bridge.json must be valid JSON")
}

pub fn manifest_verbs() -> BTreeSet<String> {
    manifest()["verbs"]
        .as_array()
        .expect("bridge.json verbs[]")
        .iter()
        .map(|v| v.as_str().expect("verb name").to_string())
        .collect()
}

/// Assert `cond`, printing the response (and the server log) when it fails —
/// a failing test should never send anyone hunting for context.
#[macro_export]
macro_rules! check {
    ($bridge:expr, $name:expr, $cond:expr, $ctx:expr) => {
        // Credit the assertion to the verb its NAME mentions (else the most
        // recent call), so the coverage gate requires that every shipped verb
        // was actually LOOKED AT — by a check that is about it.
        $bridge.note_assertion($name);
        assert!($cond, "{}: {}\n--- server log ---\n{}", $name, $ctx, $bridge.log());
    };
}