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

nordisk/nornir

tests/viz_atom_walk.rs

//! **S2 — the ATOM-WALK (L0 oracle): drive the REAL viz through a kittest
//! `Harness` against an EMPTY remote server and assert NO error atom leaks into
//! the rendered AccessKit tree — on EVERY tab, including the 🧬 Nornir tab whose
//! CloneEvents/Jobs SUB-PANELS no per-button/per-tab enumeration covers.**
//!
//! Why remote (`--server`): the `workspace `` is not served` NotFound is a gRPC
//! error — it only fires in THIN mode (local mode never calls `Viz.CloneEvents` /
//! `Viz.Jobs`). So the walk spawns an empty `nornir-server`, points a remote
//! `Source` viz at it, and walks the live tree with `robotui::error_atoms`.
//!
//! This SUPERSEDES the `state_json`-based `viz_empty_registry_onboarding` test: it
//! sees the rendered SUB-PANEL atoms that `state_json` slices are blind to. It was
//! RED before the empty-workspace guard in `nornir_tab.rs` (the Nornir tab's
//! populate-status + jobs panels rendered the `is not served` error atom) and is
//! GREEN after.
//!
//! Run:
//!   cargo test --features "viz,server" --test viz_atom_walk -- --test-threads=1
#![cfg(all(feature = "viz", feature = "server"))]

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

use egui_kittest::Harness;
use nornir::viz::UrdrThreadsApp;
use nornir::viz::control::VizCommand;
use nornir::viz::surfaces::viz_surfaces;
use nornir_robotui::error_atoms;
use uuid::Uuid;

// Every viz tab, by its `Tab::from_name` / `apply_command` name (app.rs Debug).
// Superseded as the walk enumerator by `viz_surfaces()` (S3c) but kept as a
// human-readable cross-check of the tab names.
#[allow(dead_code)]
const TABS: &[&str] = &[
    "Nornir",
    "Timeline",
    "DepGraph",
    "CallGraph",
    "Architecture",
    "Funnel",
    "TimeTravel",
    "LiveRun",
    "Release",
    "Knowledge",
    "Warehouse",
    "Mcp",
    "Search",
    "Gates",
    "Security",
    "Holger",
    "Manual",
];

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

/// An ephemeral `nornir-server` with a FRESH EMPTY registry (no monitors, no
/// seeded workspace) — the exact fresh-server state that makes a remote viz call
/// `Viz.CloneEvents`/`Viz.Jobs` with an empty workspace.
struct EmptyServer {
    child: Child,
    root: PathBuf,
    endpoint: String,
    token: String,
}
impl Drop for EmptyServer {
    fn drop(&mut self) {
        let _ = self.child.kill();
        let _ = self.child.wait();
        let _ = std::fs::remove_dir_all(&self.root);
    }
}

fn spawn_empty_server() -> EmptyServer {
    let token = "atom-walk-token-0123456789abcdef";
    let port = free_port();
    let endpoint = format!("http://127.0.0.1:{port}");
    let root = std::env::temp_dir().join(format!(
        "nornir-atom-walk-{}-{}",
        std::process::id(),
        Uuid::new_v4()
    ));
    let _ = std::fs::remove_dir_all(&root);
    let server_home = root.join("home");
    // An empty registry dir — no workspaces served.
    std::fs::create_dir_all(server_home.join(".nornir").join("workspaces")).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)
        // No NORNIR_MONITOR ⇒ the registry stays empty.
        .env("NORNIR_POLL", "3600s")
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .spawn()
        .expect("spawn nornir-server");

    let srv = EmptyServer {
        child,
        root,
        endpoint: endpoint.clone(),
        token: token.to_string(),
    };

    // Wait until the server answers (list_workspaces returns Ok — empty is fine).
    let deadline = Instant::now() + Duration::from_secs(45);
    loop {
        if nornir::viz::remote::list_workspaces(&endpoint, token).is_ok() {
            break;
        }
        assert!(Instant::now() < deadline, "empty server never came up");
        std::thread::sleep(Duration::from_millis(200));
    }
    srv
}

/// Build the REAL viz pointed at the empty remote server (thin `Source`), with NO
/// workspace selected — the fresh-server resting state.
fn remote_empty_app(srv: &EmptyServer) -> UrdrThreadsApp {
    UrdrThreadsApp::with_remote(
        srv.endpoint.clone(),
        srv.token.clone(),
        String::new(), // EMPTY workspace — the fresh-server state
        PathBuf::new(),
        Vec::new(),
    )
}

#[test]
fn empty_server_every_tab_has_no_error_atom_in_the_live_tree() {
    let srv = spawn_empty_server();

    // Host the real viz in a kittest Harness — `draw_ui` is the same tick the live
    // window runs. A generous size so every panel lays out (the Nornir tab's
    // populate-status + jobs sub-panels in particular).
    let mut harness: Harness<'_, UrdrThreadsApp> = Harness::builder()
        .with_size(egui::Vec2::new(1400.0, 1000.0))
        .build_ui_state(
            |ui, app: &mut UrdrThreadsApp| app.draw_ui(ui),
            remote_empty_app(&srv),
        );

    let mut offenders: Vec<String> = Vec::new();
    // S3c: drive the PANE DISCOVERY REGISTRY (`viz_surfaces()`) instead of a
    // hand-maintained tab list — one entry per `Tab::ALL` variant PLUS the 🧬 tab's
    // `populate_status` / `jobs` SUB-PANELS, which no per-tab list covered before.
    // Each surface carries its tab (where the atom-walk drives) and its stable id
    // (the AccessKit `id_salt` at its render root) so the walk is error-clean per
    // addressable surface in the empty state.
    for surface in viz_surfaces() {
        // ROBOT: drive to the surface's tab exactly like a live click, then pump a
        // few frames so the remote RPCs resolve and the sub-panels render. (Sub-
        // panels render on their parent tab — `populate_status`/`jobs` on 🧬 Nornir.)
        harness.state_mut().apply_command(&VizCommand {
            tab: Some(format!("{:?}", surface.tab)),
            ..Default::default()
        });
        for _ in 0..4 {
            let _ = harness.run_ok();
        }
        let atoms = error_atoms(&harness);
        if !atoms.is_empty() {
            let kind = if surface.is_subpanel {
                "sub-panel"
            } else {
                "tab"
            };
            offenders.push(format!(
                "{kind} `{}` (tab {:?}): {atoms:?}",
                surface.id, surface.tab
            ));
        }
    }

    // Belt-and-braces: the registry MUST carry the two sub-panels (the whole reason
    // S3c exists) — assert they were among the surfaces we just walked.
    let walked: Vec<&str> = viz_surfaces().iter().map(|s| s.id).collect();
    assert!(
        walked.contains(&"Nornir.populate_status"),
        "registry missing populate_status sub-panel"
    );
    assert!(
        walked.contains(&"Nornir.jobs"),
        "registry missing jobs sub-panel"
    );

    assert!(
        offenders.is_empty(),
        "empty-server viz leaked error atoms (each is a `is not served`/`Failed to \
         load`/`unavailable` node the user would see):\n{}",
        offenders.join("\n")
    );
}

/// SENSITIVITY: the walk MUST be able to go RED. We never assert against a
/// non-empty workspace here (the empty server has none), so instead prove the
/// oracle catches a planted error atom — a UI that renders the exact NotFound text
/// makes `error_atoms` non-empty. This is the git-stash equivalent: it proves the
/// green above is not vacuous (the walk genuinely sees `is not served`).
#[test]
fn atom_walk_catches_a_planted_not_served_error_atom() {
    let mut harness = Harness::new_ui_state(
        |ui, _state: &mut ()| {
            ui.label("everything is fine");
            ui.colored_label(egui::Color32::RED, "✗ NotFound: workspace `` is not served");
        },
        (),
    );
    let _ = harness.run_ok();
    let atoms = error_atoms(&harness);
    assert!(
        atoms.iter().any(|a| a.contains("is not served")),
        "the walk MUST catch a planted `is not served` atom (proves it can go red): {atoms:?}"
    );
}