Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

tests/viz_remote.rs

//! Headless test matrix for the viz gRPC thin-client (`nornir::viz::remote`).
//!
//! Spins an **ephemeral `nornir-server`** over a temp `/tmp` warehouse with one
//! registered workspace, then drives the same client calls the viz makes — so the
//! workspace **picker**, **Warehouse** browser, and timeline data paths are
//! auto-tested without a GUI or a live server. Teardown removes the temp dir.
#![cfg(all(feature = "viz", feature = "server"))]

use std::io::Write;
use std::net::TcpListener;
use std::path::PathBuf;
use std::process::{Child, Command};
use std::time::{Duration, Instant};

fn free_port() -> u16 {
    TcpListener::bind("127.0.0.1:0")
        .unwrap()
        .local_addr()
        .unwrap()
        .port()
}

/// Owns the spawned server; kills it + cleans the temp dir on drop.
struct Server {
    child: Child,
    root: PathBuf,
}
impl Drop for Server {
    fn drop(&mut self) {
        let _ = self.child.kill();
        let _ = self.child.wait();
        let _ = std::fs::remove_dir_all(&self.root);
    }
}

#[test]
fn viz_remote_matrix() {
    let token = "viz-matrix-token-0123456789";
    let port = free_port();
    let endpoint = format!("http://127.0.0.1:{port}");
    let root = std::env::temp_dir().join(format!("nornir-viz-matrix-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&root);
    std::fs::create_dir_all(&root).unwrap();
    // The registry root is home-derived (`<home>/.nornir/workspaces`); isolate
    // the spawned server by giving it a temp HOME and pre-creating the registry
    // root so the monitored role's "registry exists" gate opens.
    let server_home = root.join("home");
    let registry_root = server_home.join(".nornir").join("workspaces");
    std::fs::create_dir_all(&registry_root).unwrap();

    // Monitored workspace with a single LOCAL path member — the fetch loop skips
    // path members (no clone, no scan), so the workspace registers instantly and
    // its `builds/` warehouse comes up with the standard table layout.
    let desc = root.join("ws.toml");
    {
        let mut f = std::fs::File::create(&desc).unwrap();
        write!(
            f,
            "[workspace]\nname = \"matrixws\"\n\n[repos.self]\npath = \"{}\"\n",
            env!("CARGO_MANIFEST_DIR")
        )
        .unwrap();
    }

    // 📦 Holger: point the server's embedded-holger data dir at an isolated temp
    // dir so `Viz.HolgerRegistry` reads a fresh dev pair (no real `~/.nornir`
    // archive). `read_registry` creates it on demand and lists the configured
    // dev-pair repos (crates-io / cache / sparring) even when empty.
    let holger_data = root.join("holger-cache");

    let _server = Server {
        child: Command::new(env!("CARGO_BIN_EXE_nornir-server"))
            .env("NORNIR_SERVER_ADDR", format!("127.0.0.1:{port}"))
            .env("NORNIR_SERVER_TOKEN", token)
            .env("HOME", &server_home)
            .env("NORNIR_POLL", "3600s") // don't poll during the test
            .env("NORNIR_HOLGER_DATA", &holger_data)
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .spawn()
            .expect("spawn nornir-server"),
        root: root.clone(),
    };
    register_when_up(&endpoint, token, &[("matrixws", &desc)]);

    // Readiness: retry the picker's own call until the server answers.
    let deadline = Instant::now() + Duration::from_secs(30);
    let mut last; // assigned on every loop iteration before the assert reads it
    let workspaces = loop {
        match nornir::viz::remote::list_workspaces(&endpoint, token) {
            Ok(ws) if !ws.is_empty() => break ws,
            Ok(_) => last = "empty list".into(),
            Err(e) => last = format!("{e:#}"),
        }
        assert!(
            Instant::now() < deadline,
            "server never became ready: {last}"
        );
        std::thread::sleep(Duration::from_millis(300));
    };

    // ── matrix ────────────────────────────────────────────────────────────────
    // 1. picker: Workspaces.List contains the registered workspace.
    assert!(
        workspaces.iter().any(|w| w == "matrixws"),
        "picker list missing matrixws: {workspaces:?}"
    );

    // 2. Warehouse tab: Warehouse.Tables for that workspace returns the layout.
    let tables = nornir::viz::remote::fetch_tables(&endpoint, token, "matrixws")
        .expect("fetch_tables(matrixws)");
    assert!(!tables.is_empty(), "no warehouse tables for matrixws");

    // 3. scanning a table is Ok (rows may be empty on a fresh warehouse).
    nornir::viz::remote::scan_table(&endpoint, token, &tables[0], 10, "matrixws")
        .expect("scan_table");

    // 4. NO implicit default route: an empty `nornir-workspace` header must ERROR
    //    (the server never auto-picks a workspace; the caller must select one).
    let err = nornir::viz::remote::fetch_tables(&endpoint, token, "")
        .expect_err("empty workspace header must error — no implicit default route");
    let msg = format!("{err:#}");
    assert!(
        msg.contains("no workspace selected"),
        "empty-header error should explain no workspace is selected, got: {msg}"
    );

    // 5. NEW remote read paths — Viz.ReleaseEvents / Viz.BakeoffResults /
    //    Viz.Knowledge must answer cleanly for the served workspace. The fresh
    //    warehouse has no release/bakeoff rows yet, so an EMPTY-but-Ok response is
    //    the correct outcome (proves the RPC + decode wire up, not a TODO note).
    let rev = nornir::viz::remote::fetch_release_events(&endpoint, token, "matrixws")
        .expect("Viz.ReleaseEvents RPC must succeed (empty is ok on a fresh warehouse)");
    assert!(
        rev.is_empty(),
        "fresh warehouse should have no release_events yet"
    );
    let bake = nornir::viz::remote::fetch_bakeoff_results(&endpoint, token, "matrixws")
        .expect("Viz.BakeoffResults RPC must succeed (empty is ok on a fresh warehouse)");
    assert!(
        bake.is_empty(),
        "fresh warehouse should have no agent_model_runs yet"
    );
    // TestResults: Viz.TestResults must answer cleanly — proves the RPC and the
    // wire decode are wired up (no "TODO" dead-end). Empty on a fresh warehouse.
    let test_rows = nornir::viz::remote::fetch_test_results(&endpoint, token, "matrixws")
        .expect("Viz.TestResults RPC must succeed (empty is ok on a fresh warehouse)");
    assert!(
        test_rows.is_empty(),
        "fresh warehouse should have no test_results yet"
    );
    // BenchTelemetry: Viz.BenchTelemetry must answer cleanly with BOTH tables —
    // proves the RPC + the {telemetry,runs} wire decode are wired up (kills the
    // last "RPC TODO"). Both empty on a fresh warehouse.
    let (telem, runs) = nornir::viz::remote::fetch_bench_live(&endpoint, token, "matrixws")
        .expect("Viz.BenchTelemetry RPC must succeed (empty ok on fresh warehouse)");
    assert!(
        telem.is_empty() && runs.is_empty(),
        "fresh warehouse has no bench telemetry yet"
    );
    // 📦 Holger registry (the fix): the thin viz has no local holger, so the
    // pane fetches the server's registry over Viz.HolgerRegistry. The server runs
    // `read_registry` on ITS holger-cache (isolated to the temp dir above) and
    // ships the `Vec<RepoSummary>`. This is the path that REPLACES the empty
    // `inject(Vec::new())` stub the remote pane used to show. With `embed-holger`
    // the dev pair lists crates-io / cache / sparring (0 crates on a fresh dir);
    // without it the server returns `[]` (the pane degrades, still no transport
    // error). Either way the RPC + decode must wire up cleanly.
    // The Holger registry RPC client (`fetch_holger_registry`) is compiled ONLY
    // under `embed-holger`; gate the whole check so the default `cargo test
    // --workspace` (no embed-holger) still compiles this test target.
    #[cfg(feature = "embed-holger")]
    {
        let holger = nornir::viz::remote::fetch_holger_registry(&endpoint, token, "matrixws")
            .expect("Viz.HolgerRegistry RPC must succeed (decodes a Vec<RepoSummary>)");
        let names: Vec<&str> = holger.iter().map(|r| r.name.as_str()).collect();
        assert_eq!(
            names,
            vec!["crates-io", "cache", "sparring"],
            "remote pane must list the dev-pair repos, got {names:?}"
        );
        let sparring = holger
            .iter()
            .find(|r| r.name == "sparring")
            .expect("/sparring row");
        assert!(
            sparring.writable,
            "/sparring is a writable rehearsal registry"
        );
    }

    // 📦 Holger APP-SIDE WIRING (the actual regression guard): build the REMOTE
    // viz app the same way `nornir-viz --server …` does and assert its 📦 pane is
    // wired to FETCH over Viz.HolgerRegistry — NOT the old empty
    // `inject(Vec::new())` stub that showed "data dir: <injected>" +
    // "no repositories configured". The honest label is "server <endpoint>", and
    // (with embed-holger) the pane lists the dev-pair repos after the background
    // fetch drains. Only meaningful when the pane is compiled in.
    #[cfg(feature = "embed-holger")]
    {
        use nornir::viz::UrdrThreadsApp;
        let mut app = UrdrThreadsApp::with_remote(
            endpoint.clone(),
            token.to_string(),
            "matrixws".to_string(),
            PathBuf::new(),
            Vec::new(),
        );
        // The pane kicks off its registry fetch on first render; drive a few
        // headless passes (with a short wait) until the background gRPC read
        // drains into the pane.
        let deadline = Instant::now() + Duration::from_secs(20);
        let h = loop {
            app.render_all_tabs_headless();
            let st = app.state_json();
            let h = st["holger"].clone();
            // data dir must be honest (server addr), never the inject sentinel.
            assert_eq!(
                h["data_dir"],
                format!("server {endpoint}"),
                "remote pane must label the server endpoint, not <injected>"
            );
            assert_ne!(
                h["data_dir"], "<injected>",
                "the empty-inject stub is the bug"
            );
            if h["repo_count"].as_u64().unwrap_or(0) > 0 {
                break h;
            }
            assert!(
                Instant::now() < deadline,
                "remote holger pane never populated: {h}"
            );
            std::thread::sleep(Duration::from_millis(200));
        };
        let repos: Vec<&str> = h["repositories"]
            .as_array()
            .expect("holger.repositories array")
            .iter()
            .filter_map(|r| r.as_str())
            .collect();
        assert_eq!(
            repos,
            vec!["crates-io", "cache", "sparring"],
            "remote pane lists the dev-pair repos (not 'no repositories configured'), got {repos:?}"
        );
    }
}

/// THE COMPLETE LIVE MATRIX — every viz function × every registered workspace
/// (the `workspace_*` set is the normal baseline) + an `unknown-ws` negative
/// column. Any ✗ cell fails the test with its reason. Run:
///   NORNIR_SERVER=http://oden:7878 NORNIR_SERVER_TOKEN=… \
///     cargo test --release --features server,viz --test viz_remote live_full_matrix \
///       -- --ignored --nocapture
#[test]
#[ignore = "live: full matrix needs NORNIR_SERVER + NORNIR_SERVER_TOKEN pointing at a running server"]
fn live_full_matrix() {
    use nornir::viz::remote as r;
    let ep = std::env::var("NORNIR_SERVER").expect("set NORNIR_SERVER");
    let tok = std::env::var("NORNIR_SERVER_TOKEN").expect("set NORNIR_SERVER_TOKEN");
    // The normal baseline: ALL workspace_* registrations. Override via
    // NORNIR_BASELINE=a,b,c when the fleet changes.
    let baseline: Vec<String> = std::env::var("NORNIR_BASELINE")
        .unwrap_or_else(|_| "holger,knut,njord,nornir-catalog,znippy".into())
        .split(',')
        .map(|s| s.trim().to_string())
        .collect();
    let mut grid: Vec<(String, String, Result<String, String>)> = Vec::new(); // (row, ws, cell)
    let mut cell = |row: &str, ws: &str, res: Result<String, String>| {
        grid.push((row.into(), ws.into(), res));
    };

    // Row 1 — picker baseline: Workspaces.List must equal the baseline exactly.
    let listed = r::list_workspaces(&ep, &tok).expect("Workspaces.List");
    {
        let mut want = baseline.clone();
        want.sort();
        let mut got = listed.clone();
        got.sort();
        cell(
            "1 picker=baseline",
            "*",
            if got == want {
                Ok(format!("{} ws", got.len()))
            } else {
                Err(format!("got {got:?} want {want:?}"))
            },
        );
    }

    for ws in &baseline {
        // Row 2 — Timeline (Timeline + TimeTravel tabs).
        cell(
            "2 timeline",
            ws,
            r::fetch_timeline(&ep, &tok, ws)
                .map(|t| format!("{} lanes", t.lanes.len()))
                .map_err(|e| format!("{e:#}")),
        );
        // Row 3 — Warehouse.Tables.
        let tables = r::fetch_tables(&ep, &tok, ws).unwrap_or_default();
        cell(
            "3 tables",
            ws,
            if tables.len() >= 30 {
                Ok(format!("{}", tables.len()))
            } else {
                Err(format!("only {} tables", tables.len()))
            },
        );
        // Row 4 — Warehouse.Scan over EVERY table.
        let mut errs = Vec::new();
        let mut nonempty = 0;
        for t in &tables {
            match r::scan_table(&ep, &tok, t, 5, ws) {
                Ok(p) => {
                    if !p.rows.is_empty() {
                        nonempty += 1;
                    }
                }
                Err(e) => errs.push(format!("{t}: {e:#}")),
            }
        }
        cell(
            "4 scan-all-tables",
            ws,
            if errs.is_empty() {
                Ok(format!("{nonempty} non-empty"))
            } else {
                Err(errs.join("; "))
            },
        );
        // (Row 5 — the Call-Graph data path over `symbol_facts` — left with the
        // table's producer, Mímir v2; the lens answers not-wired.)
        // Row 6 — Dep-Graph tab data.
        cell(
            "6 depgraph-data",
            ws,
            r::scan_table(&ep, &tok, "dep_graph_edges", 50, ws)
                .map(|p| format!("{} rows", p.rows.len()))
                .map_err(|e| format!("{e:#}")),
        );
        // Row 7 — chart numeric-column detection on git_heat_facts.
        cell(
            "7 chart-numeric",
            ws,
            r::scan_table(&ep, &tok, "git_heat_facts", 50, ws)
                .map_err(|e| format!("{e:#}"))
                .and_then(|p| {
                    let li = p.columns.iter().position(|c| c == "commits_total");
                    let ok = li
                        .map(|i| {
                            p.rows.iter().any(|row| {
                                row.get(i)
                                    .map(|c| c.trim().parse::<f64>().is_ok())
                                    .unwrap_or(false)
                            })
                        })
                        .unwrap_or(false);
                    if ok || p.rows.is_empty() {
                        Ok("numeric commits_total col".into())
                    } else {
                        Err("no numeric column detected".into())
                    }
                }),
        );
        // Row 12 — Workspaces.Get (info panel) per workspace.
        cell(
            "12 ws-get",
            ws,
            r::get_workspace(&ep, &tok, ws)
                .map(|i| format!("{} member(s), mode={}", i.members.len(), i.mode))
                .map_err(|e| format!("{e:#}")),
        );
        // Row 13 — Search.Query (BM25): a near-universal term must not error.
        cell(
            "13 search",
            ws,
            r::search(&ep, &tok, "fn", "", "", 5, ws)
                .map(|h| format!("{} hit(s)", h.len()))
                .map_err(|e| format!("{e:#}")),
        );
        // Row 14 — Knowledge.SymbolLookup over symbol_facts.
        cell(
            "14 knowledge-lookup",
            ws,
            r::knowledge_lookup(&ep, &tok, "", "new", 5, ws)
                .map(|s| format!("{} sym(s)", s.len()))
                .map_err(|e| format!("{e:#}")),
        );
        // Row 15 — Index.Stats (search status panel).
        cell(
            "15 index-stats",
            ws,
            r::index_stats(&ep, &tok, ws)
                .map(|(t, by)| format!("{t} docs, {} corpora", by.len()))
                .map_err(|e| format!("{e:#}")),
        );
        // Row 16 — Knowledge.Callers (call-relationship query) must not error.
        cell(
            "16 knowledge-callers",
            ws,
            r::knowledge_calls(&ep, &tok, "", "main", true, 5, ws)
                .map(|c| format!("{} caller-edge(s)", c.len()))
                .map_err(|e| format!("{e:#}")),
        );
        // Row 17 — Vector.Search: server may lack an embedder → UNIMPLEMENTED is
        // an ACCEPTABLE outcome (not a viz bug), real hits are a bonus.
        let vrepo = if ws == "njord" { "njord" } else { ws.as_str() };
        cell(
            "17 vector-search",
            ws,
            match r::vector_search(&ep, &tok, vrepo, "open a file", 3, ws) {
                Ok(h) => Ok(format!("{} hit(s)", h.len())),
                Err(e) if format!("{e:#}").to_lowercase().contains("unimpl") => {
                    Ok("no embedder (ok)".into())
                }
                Err(e) => Err(format!("{e:#}")),
            },
        );
        // Row 18 — Funnel.Show (the plan-DAG tab's data source); an empty funnel
        // is fine, we only assert the RPC + FunnelView flattening don't error.
        cell(
            "18 funnel-show",
            ws,
            r::funnel_show(&ep, &tok, ws)
                .map(|v| {
                    let nodes: usize = v.plans.iter().map(|p| p.nodes.len()).sum();
                    format!("{} plan(s), {} node(s)", v.plans.len(), nodes)
                })
                .map_err(|e| format!("{e:#}")),
        );
        // Row 19 — Viz.ReleaseEvents (🚀 Release tab + 📡 Live hydrate); a
        // workspace with release history MUST return rows.
        cell(
            "19 release-events",
            ws,
            r::fetch_release_events(&ep, &tok, ws)
                .map(|rows| format!("{} event(s)", rows.len()))
                .map_err(|e| format!("{e:#}")),
        );
        // Row 20 — Viz.BakeoffResults (🏆 Leaderboard); empty is fine (not every
        // workspace has run a bake-off), we assert the RPC + decode don't error.
        cell(
            "20 bakeoff-results",
            ws,
            r::fetch_bakeoff_results(&ep, &tok, ws)
                .map(|rows| format!("{} run-row(s)", rows.len()))
                .map_err(|e| format!("{e:#}")),
        );
    }

    // Row 8 — switching: walk every workspace in sequence re-running the core
    // calls (what switch_workspace() does), order-sensitive.
    {
        let mut errs = Vec::new();
        for ws in baseline.iter().chain(baseline.iter().rev()) {
            if let Err(e) = r::fetch_timeline(&ep, &tok, ws) {
                errs.push(format!("{ws}: tl {e:#}"));
            }
            if let Err(e) = r::fetch_tables(&ep, &tok, ws) {
                errs.push(format!("{ws}: tbl {e:#}"));
            }
        }
        cell(
            "8 switch-sequence",
            "*",
            if errs.is_empty() {
                Ok("2×walk".into())
            } else {
                Err(errs.join("; "))
            },
        );
    }

    // Row 9 — Live Run stream: must connect and stay open (no run = silence).
    {
        let (tx, rx) = std::sync::mpsc::channel::<String>();
        let (ep2, tok2) = (ep.clone(), tok.clone());
        std::thread::spawn(move || {
            if let Err(e) = r::stream_progress(&ep2, &tok2, "matrixws", |_| {}) {
                let _ = tx.send(format!("{e:#}"));
            }
        });
        cell(
            "9 live-run-stream",
            "*",
            match rx.recv_timeout(std::time::Duration::from_secs(3)) {
                Err(_) => Ok("open 3s".into()), // still streaming — good
                Ok(e) => Err(e),                // errored immediately
            },
        );
    }

    // Row 10 — CLI-driven (the same gRPC the viz uses, via the installed CLI).
    {
        let cli = std::env::var("NORNIR_CLI").unwrap_or_else(|_| "/usr/local/bin/nornir".into());
        let out = std::process::Command::new(&cli)
            .args(["workspace", "ls"])
            .env("NORNIR_SERVER", &ep)
            .env("NORNIR_SERVER_TOKEN", &tok)
            .output();
        cell(
            "10 cli-workspace-ls",
            "*",
            match out {
                Ok(o) if o.status.success() => {
                    let text = String::from_utf8_lossy(&o.stdout);
                    let missing: Vec<&String> = baseline
                        .iter()
                        .filter(|w| !text.contains(w.as_str()))
                        .collect();
                    if missing.is_empty() {
                        Ok("all listed".into())
                    } else {
                        Err(format!("missing {missing:?}"))
                    }
                }
                Ok(o) => Err(String::from_utf8_lossy(&o.stderr).to_string()),
                Err(e) => Err(format!("{e}")),
            },
        );
    }

    // Negative column — unknown workspace must fail/empty CLEANLY (no hang).
    {
        let res = r::fetch_tables(&ep, &tok, "no-such-workspace-xyz");
        cell(
            "11 unknown-ws",
            "no-such-workspace-xyz",
            match res {
                Ok(t) => Ok(format!("served default ({} tables)", t.len())),
                Err(e) if format!("{e:#}").len() < 500 => Ok("clean error".into()),
                Err(e) => Err(format!("ugly: {e:#}")),
            },
        );
    }

    // ── print the grid + verdict ────────────────────────────────────────────
    eprintln!("\n══ VIZ FULL MATRIX ══");
    let mut failures = 0;
    for (row, ws, res) in &grid {
        match res {
            Ok(note) => eprintln!("  ✓ {row:<22} {ws:<18} {note}"),
            Err(e) => {
                failures += 1;
                eprintln!("  ✗ {row:<22} {ws:<18} {e}");
            }
        }
    }
    eprintln!("══ {} cells, {} failures ══", grid.len(), failures);
    assert_eq!(failures, 0, "{failures} matrix cell(s) failed");
}

/// The server reads no `NORNIR_MONITOR` any more: once it answers, register each
/// workspace through the real `Workspaces.Register` RPC so it is served.
fn register_when_up(endpoint: &str, token: &str, descs: &[(&str, &std::path::Path)]) {
    let deadline = Instant::now() + Duration::from_secs(45);
    while nornir::viz::remote::list_workspaces(endpoint, token).is_err() {
        assert!(Instant::now() < deadline, "empty server never came up");
        std::thread::sleep(Duration::from_millis(200));
    }
    for (name, desc) in descs {
        nornir::viz::remote::register_workspace(endpoint, token, name, &desc.to_string_lossy(), "monitored", "3600s")
            .unwrap_or_else(|e| panic!("register {name}: {e:#}"));
    }
}