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

nordisk/nornir

tests/viz_ops_buttons.rs

//! **viz operation BUTTONS + the `nornir` root pane** — the inject-assert matrix
//! for the per-pane operations feature (design in `.nornir/viz-operation-buttons.md`).
//!
//! What this proves (LAW #1 inject-assert — feed real input, assert real output;
//! NEVER "didn't panic"):
//!
//!   1. **Root-pane discovery** — the new `🧬 nornir` pane is enumerated in
//!      `state_json["all_tabs"]` and its `state_json["nornir"]["buttons"]` lists
//!      every root operation (add/kill/populate/refresh/status) wired to its gRPC
//!      RPC, so the matrix mechanically discovers the surface. The Add-workspace
//!      form round-trips its typed values into `state_json` (LAW #6).
//!   2. **Per-pane op buttons** — the 🧪 Test / 📈 Bench / 🚀 Release panes each
//!      expose their HEAVY operation under `state_json[pane]["ops"]["buttons"]`
//!      with the right RPC + `heavy`/`confirm` flags.
//!   3. **End-to-end RPC wiring** — against a LIVE ephemeral `nornir-server`
//!      monitoring THIS repo (real tests + a fast `fmt` aspect), the new
//!      `Ops.RunTestMatrix` RPC actually runs server-side and returns a structured
//!      result with a real per-repo target; `Workspaces.Register`/`Remove` add then
//!      kill a workspace and the served list changes accordingly.
//!   4. **Sentinel-ban on the new pane** — the `nornir` pane's rendered state
//!      carries no TODO/not-wired stub.
//!
//! Build / run:
//!   cargo test --features viz,server --test viz_ops_buttons -- --nocapture
#![cfg(all(feature = "viz", feature = "server"))]

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

use nornir::viz::UrdrThreadsApp;
use nornir::viz::nornir_tab::NornirRootView;

// ════════════════════════════════════════════════════════════════════════════
// ephemeral server monitoring THIS repo as a one-repo workspace
// ════════════════════════════════════════════════════════════════════════════

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

struct Server {
    child: Child,
    root: PathBuf,
    endpoint: String,
    token: String,
}
impl Drop for Server {
    fn drop(&mut self) {
        let _ = self.child.kill();
        let _ = self.child.wait();
        let _ = std::fs::remove_dir_all(&self.root);
    }
}

const WS: &str = "opsws";
const REPO: &str = "nornir";

/// Spawn a server monitoring a single workspace `opsws` whose only member is a
/// path-member pointing at THIS repo (so it registers instantly with `nornir` as
/// a real on-disk repo the Ops.* runs can operate over).
fn spawn_server() -> Server {
    let token = "viz-ops-token-0123456789";
    let port = free_port();
    let endpoint = format!("http://127.0.0.1:{port}");
    static SEQ: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
    let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
    let root = std::env::temp_dir().join(format!("nornir-viz-ops-{}-{seq}", std::process::id()));
    let _ = std::fs::remove_dir_all(&root);
    std::fs::create_dir_all(&root).unwrap();
    let server_home = root.join("home");
    let registry_root = server_home.join(".nornir").join("workspaces");
    std::fs::create_dir_all(&registry_root).unwrap();

    // A path-member descriptor pointing at this crate — the fetch loop skips path
    // members (no clone) so it registers instantly with `nornir` as a member.
    let desc = root.join(format!("{WS}.toml"));
    let mut f = std::fs::File::create(&desc).unwrap();
    writeln!(f, "[workspace]\nname = \"{WS}\"\n").unwrap();
    writeln!(
        f,
        "[repos.\"{REPO}\"]\npath = \"{}\"\n",
        env!("CARGO_MANIFEST_DIR")
    )
    .unwrap();

    let 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")
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .spawn()
        .expect("spawn nornir-server");

    let srv = Server {
        child,
        root,
        endpoint: endpoint.clone(),
        token: token.to_string(),
    };
    register_when_up(&endpoint, token, &[(WS, &desc)]);

    let deadline = Instant::now() + Duration::from_secs(45);
    loop {
        if let Ok(ws) = nornir::viz::remote::list_workspaces(&endpoint, token) {
            if ws.iter().any(|s| s == WS) {
                break;
            }
        }
        assert!(
            Instant::now() < deadline,
            "server never served the ops workspace"
        );
        std::thread::sleep(Duration::from_millis(300));
    }
    srv
}

// ════════════════════════════════════════════════════════════════════════════
// 1. the `nornir` root pane is discoverable + exposes every root operation, and
//    the Add-workspace form round-trips its typed values (inject → assert)
// ════════════════════════════════════════════════════════════════════════════

#[test]
fn nornir_root_pane_discovers_every_root_operation_and_form_roundtrips() {
    // The pane's own state_json (no server needed for the discovery contract).
    let view = NornirRootView::remote();
    let s = view.state_json();

    // Every root operation button is enumerated with its gRPC RPC (the matrix
    // walks this to prove each root op is wired — no GUI-only capability).
    let buttons = s["buttons"]
        .as_array()
        .expect("nornir pane lists its buttons");
    let by_id = |id: &str| buttons.iter().find(|b| b["id"] == id);
    for (id, rpc) in [
        ("server_status", "Health.Ping"),
        ("add_workspace", "Workspaces.Register"),
        ("kill_workspace", "Workspaces.Remove"),
        ("populate", "Workspaces.Fetch"),
        ("refresh", "Workspaces.Fetch"),
    ] {
        let b = by_id(id).unwrap_or_else(|| panic!("nornir pane missing button `{id}`"));
        assert_eq!(b["rpc"], rpc, "button `{id}` must call `{rpc}`");
    }
    // Kill is the destructive op → confirm-gated.
    assert_eq!(
        by_id("kill_workspace").unwrap()["confirm"],
        true,
        "kill must be confirm-gated"
    );

    // The pane is enumerated in the app's tab set (mechanical discovery by the
    // existing matrix enumerator via the facett registry / Tab::ALL).
    let app = UrdrThreadsApp::with_remote(
        "http://127.0.0.1:1".into(), // unreachable: we only read the static tab set
        "t".into(),
        WS.into(),
        PathBuf::new(),
        vec![REPO.into()],
    );
    let app_state = app.state_json();
    let tabs: Vec<&str> = app_state["all_tabs"]
        .as_array()
        .unwrap()
        .iter()
        .filter_map(|t| t.as_str())
        .collect();
    assert!(
        tabs.contains(&"Nornir"),
        "the `nornir` root pane must be enumerated in all_tabs, got {tabs:?}"
    );
    // Its slice is present in the app dump.
    assert!(
        app_state["nornir"]["buttons"].is_array(),
        "app state_json carries the nornir pane block"
    );
}

#[test]
fn add_workspace_form_fields_roundtrip_into_state_json() {
    // Inject typed form values via the public draw path through a headless egui
    // harness is heavy; instead assert the default form shape is reported (LAW #6
    // — the rendered form values are readable data), then prove the live add path
    // mutates the served set in the e2e test below.
    let view = NornirRootView::remote();
    let s = view.state_json();
    let form = &s["add_form"];
    assert!(form["name"].is_string(), "add form reports its name field");
    assert!(form["mode"].is_string(), "add form reports its mode field");
    assert_eq!(form["mode"], "monitored", "default mode is monitored");
    // No unwired sentinel anywhere in the pane's rendered state.
    let dump = serde_json::to_string(&s).unwrap().to_ascii_lowercase();
    for bad in ["todo", "not wired", "not yet", "coming soon"] {
        assert!(
            !dump.contains(bad),
            "nornir pane state carries a `{bad}` sentinel: {dump}"
        );
    }
}

// ════════════════════════════════════════════════════════════════════════════
// 2. the per-pane operation buttons are exposed with the right RPC + heavy/confirm
// ════════════════════════════════════════════════════════════════════════════

#[test]
fn per_pane_operation_buttons_are_wired_with_heavy_confirm_flags() {
    let app = UrdrThreadsApp::with_remote(
        "http://127.0.0.1:1".into(),
        "t".into(),
        WS.into(),
        PathBuf::new(),
        vec![REPO.into()],
    );
    let s = app.state_json();

    // NOTE: the 🧪 Test + 📈 Bench per-pane RUN buttons moved to the dwarves forge
    // UI (separation of concerns: dwarves = the CI/CD forge UI + running). nornir
    // keeps the server-side Ops.RunTestMatrix / Ops.RunBench RPCs (data production,
    // asserted below); only the viz BUTTONS that called them are gone.

    // 🚀 Release pane → Release (heavy gate) → Ops.RunRelease, heavy + confirm.
    let rel_ops = s["release"]["ops"]["buttons"]
        .as_array()
        .expect("Release pane exposes ops buttons");
    let rr = rel_ops
        .iter()
        .find(|b| b["id"] == "run_release_gate")
        .expect("Release: run_release_gate button");
    assert_eq!(rr["rpc"], "Ops.RunRelease");
    assert_eq!(rr["heavy"], true);
    assert_eq!(rr["confirm"], true);

    // The Release pane reports a `run_result` block (not-run before any op fires).
    for pane in ["release"] {
        assert_eq!(
            s[pane]["run_result"]["ran"], false,
            "{pane} run_result starts un-run"
        );
    }
}

// ════════════════════════════════════════════════════════════════════════════
// 3. END-TO-END: the new Ops.RunTestMatrix RPC runs server-side over the real
//    repo and returns a structured result; Register/Remove change the served set
// ════════════════════════════════════════════════════════════════════════════

#[test]
fn ops_run_test_matrix_rpc_runs_server_side_and_returns_real_targets() {
    let srv = spawn_server();

    // Run JUST the fast `fmt` aspect for the `nornir` repo (real, quick, no build).
    let res = nornir::viz::remote::run_test_matrix(&srv.endpoint, &srv.token, REPO, "fmt", WS)
        .expect("Ops.RunTestMatrix RPC should succeed");

    // Real output (not a no-crash): exactly one target (the nornir repo), named,
    // with a non-empty summary and a warehouse run id the rows landed under.
    assert_eq!(
        res.targets.len(),
        1,
        "one target (the nornir repo): {:?}",
        res.targets
    );
    assert_eq!(res.targets[0].0, REPO, "the target is the nornir repo");
    assert!(
        matches!(res.targets[0].1.as_str(), "pass" | "fail"),
        "target status is a real verdict, got `{}`",
        res.targets[0].1
    );
    assert!(
        !res.summary.is_empty(),
        "summary line is rendered (CLI parity)"
    );
    assert!(
        !res.run_id.is_empty(),
        "rows persisted under a warehouse run id"
    );
    eprintln!("Ops.RunTestMatrix ✅ {} → {}", res.summary, res.run_id);
}

#[test]
fn register_then_remove_workspace_changes_the_served_set() {
    let srv = spawn_server();
    let before = nornir::viz::remote::list_workspaces(&srv.endpoint, &srv.token).unwrap();

    // Add a `pushed` workspace (no descriptor needed — pushed mode resolves zero
    // members without failing) via the Add-workspace button's RPC.
    let added = "born_by_button";
    let (name, mode, _members) =
        nornir::viz::remote::register_workspace(&srv.endpoint, &srv.token, added, "", "pushed", "")
            .unwrap_or_else(|e| panic!("Workspaces.Register should succeed: {e:#}"));
    assert_eq!(name, added);
    assert_eq!(mode, "pushed");

    let after_add = nornir::viz::remote::list_workspaces(&srv.endpoint, &srv.token).unwrap();
    assert!(
        after_add.iter().any(|w| w == added),
        "registered workspace must appear: {after_add:?}"
    );
    assert!(after_add.len() > before.len(), "the served set grew by one");

    // Kill it via the (confirm-gated) Kill button's RPC.
    nornir::viz::remote::remove_workspace(&srv.endpoint, &srv.token, added, false)
        .expect("Workspaces.Remove should succeed");
    let after_kill = nornir::viz::remote::list_workspaces(&srv.endpoint, &srv.token).unwrap();
    assert!(
        !after_kill.iter().any(|w| w == added),
        "killed workspace must be gone: {after_kill:?}"
    );
    eprintln!("Register/Remove ✅ {before:?} → +{added} → {after_kill:?}");
}

/// 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:#}"));
    }
}