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

nordisk/nornir

tests/viz_build_info_live.rs

//! Inject-and-assert + robot proof for the 📡 **build-info LIVE-TAIL** pane
//! (slice-5 capstone — `.nornir/build-info-design.md` §f5, the deferred viz
//! tail). LAW #1: feed real §f3 build-info rows and assert real rendered output
//! (the growing tail + per-sub-job status transitions), never "didn't panic".
//!
//! The pane subscribes the build-info Flight stream **per sub-job `step_id`** via
//! the already-tested `build::flight_sink::subscribe` consumer seam and folds the
//! rows into per-step status + a log/metric tail. Here we drive the fold directly
//! (inject the exact rows `subscribe` would return, in TWO batches) and assert
//! `state_json()["build_info_live"]` shows the tail GROW and each step's status
//! transition — the inject-and-assert shape the rest of the viz uses.
#![cfg(feature = "viz")]

use std::path::PathBuf;

use nornir::viz::UrdrThreadsApp;
use nornir_build_thing::build_info::{BuildInfoKind, BuildInfoRow};

fn build_app() -> UrdrThreadsApp {
    let warehouse_root =
        std::env::temp_dir().join(format!("nornir-viz-buildinfo-{}", std::process::id()));
    let _ = std::fs::create_dir_all(&warehouse_root);
    UrdrThreadsApp::with_repos(
        warehouse_root,
        "nornir".to_string(),
        PathBuf::new(),
        vec!["holger".to_string()],
    )
}

fn step_row(build: &str, step: &str, ts: i64, status: &str) -> BuildInfoRow {
    BuildInfoRow::new(build, ts, BuildInfoKind::Step)
        .step(step)
        .level(status)
        .kv("cargo build", "full")
}

fn log_row(build: &str, step: &str, ts: i64, msg: &str) -> BuildInfoRow {
    BuildInfoRow::new(build, ts, BuildInfoKind::Log)
        .step(step)
        .level("info")
        .kv("cargo", msg)
}

#[test]
fn build_info_pane_tails_feed_per_subjob() {
    let build = "b-viz";
    let mut app = build_app();

    // ── Batch 1: the "compile" sub-job starts running and logs a line ──────────
    app.inject_build_info_for_test(vec![
        // a manifest row (empty step_id) — counted, not tailed
        BuildInfoRow::new(build, 0, BuildInfoKind::Provenance).kv("repo", "holger"),
        step_row(build, "compile", 1, "running"),
        log_row(build, "compile", 2, "Compiling holger v0.1"),
    ]);

    let s1 = app.state_json();
    // Selecting the pane put us on the 📡 Live Run tab.
    assert_eq!(s1["tab"].as_str(), Some("LiveRun"), "tab must be LiveRun");
    let bi1 = &s1["build_info_live"];
    assert!(bi1["error"].is_null(), "no error, got {}", bi1["error"]);
    assert_eq!(bi1["build_id"].as_str(), Some("b-viz"), "build_id folded");
    assert_eq!(bi1["manifest_rows"].as_u64(), Some(1), "manifest counted");
    assert_eq!(bi1["step_count"].as_u64(), Some(1), "one sub-job so far");
    assert_eq!(
        bi1["tail_total"].as_u64(),
        Some(2),
        "step + log tailed (not manifest)"
    );
    let compile1 = &bi1["steps"][0];
    assert_eq!(compile1["step_id"].as_str(), Some("compile"));
    assert_eq!(compile1["status"].as_str(), Some("running"), "first status");

    // ── Batch 2: compile finishes (a transition) + a second sub-job "test" ─────
    app.inject_build_info_for_test(vec![
        log_row(build, "compile", 3, "Finished release"),
        step_row(build, "compile", 4, "done"),
        step_row(build, "test", 5, "running"),
    ]);

    let s2 = app.state_json();
    let bi2 = &s2["build_info_live"];
    assert_eq!(
        bi2["step_count"].as_u64(),
        Some(2),
        "second sub-job appeared"
    );
    assert_eq!(
        bi2["tail_total"].as_u64(),
        Some(5),
        "tail GREW to 5 across the two batches, not reset"
    );

    let steps = bi2["steps"].as_array().unwrap();
    // compile: status transitioned running → done, recorded in order.
    let compile2 = steps.iter().find(|s| s["step_id"] == "compile").unwrap();
    assert_eq!(compile2["status"].as_str(), Some("done"), "latest status");
    let hist: Vec<&str> = compile2["status_history"]
        .as_array()
        .unwrap()
        .iter()
        .map(|s| s.as_str().unwrap())
        .collect();
    assert_eq!(hist, vec!["running", "done"], "status transitions in order");
    assert_eq!(
        compile2["tail_len"].as_u64(),
        Some(4),
        "compile tail grew to 4"
    );

    // test: newly running.
    let test = steps.iter().find(|s| s["step_id"] == "test").unwrap();
    assert_eq!(test["status"].as_str(), Some("running"));
    assert_eq!(test["tail_len"].as_u64(), Some(1));

    // Land the verdict as a functional_status matrix row (green matrix ⟺ the
    // build-info tail folds the feed per sub-job with growing tail + transitions).
    #[cfg(feature = "testmatrix")]
    nornir_testmatrix::functional_status(
        "viz/build-info-live",
        "tails_feed_per_subjob",
        bi2["tail_total"].as_u64() == Some(5) && hist == vec!["running", "done"],
        &format!(
            "steps={} tail={} compile_hist={hist:?}",
            bi2["step_count"], bi2["tail_total"]
        ),
    );
}

// ─── auto-refresh (LAW #4 — the pane AUTO-UPDATES on the poll timer) ──────────

/// ROBOT proof of the auto-refresh timer: drive the REAL app through kittest
/// frames, deliver freshly-`pull`ed batches to the pane's inbox (what the
/// background poller does each interval) BETWEEN frames, and assert the drawn
/// `state_json` ADVANCES — the tail grows + `poll_count` ticks — WITHOUT any
/// re-inject. This is the follow-up that closes the "deferred → auto-refresh"
/// note: the pane folds mid-build rows as they land, driven only by `draw`.
#[test]
fn build_info_pane_auto_updates_on_poll_timer() {
    use egui_kittest::Harness;

    let build = "b-auto-viz";
    let mut app = build_app();
    // Select the 📡 Live Run tab + get the inbox sender (the poller's delivery
    // channel) — NOTHING is injected: the pane starts empty.
    let tx = app.build_info_poll_sender_for_test();

    let mut harness = Harness::builder()
        .with_size(egui::Vec2::new(1200.0, 800.0))
        .build_ui_state(|ui, app: &mut UrdrThreadsApp| app.draw_ui(ui), app);

    // Frame once — the pane is empty until the first interval pull.
    harness.run_ok();
    let s0 = harness.state().state_json();
    assert_eq!(s0["tab"].as_str(), Some("LiveRun"));
    assert_eq!(
        s0["build_info_live"]["tail_total"].as_u64(),
        Some(0),
        "empty until a pull"
    );
    assert_eq!(s0["build_info_live"]["poll_count"].as_u64(), Some(0));

    // Interval-pull #1: the poller delivers a batch; the pane's own `draw` drains
    // it over the next frames (no re-inject).
    tx.send(vec![
        step_row(build, "compile", 1, "running"),
        log_row(build, "compile", 2, "Compiling holger v0.1"),
    ])
    .expect("deliver interval pull #1");
    for _ in 0..4 {
        harness.run_ok();
    }
    let s1 = harness.state().state_json();
    let bi1 = &s1["build_info_live"];
    assert_eq!(
        bi1["tail_total"].as_u64(),
        Some(2),
        "pane AUTO-FOLDED the delivered batch on the timer, no re-inject"
    );
    assert!(
        bi1["poll_count"].as_u64().unwrap() >= 1,
        "at least one tick"
    );
    let tickets = bi1["live_tickets"].as_array().unwrap();
    assert!(
        tickets.iter().any(|t| t["step_id"] == "compile"),
        "compile ticket auto-tracked"
    );

    // Interval-pull #2: the feed advances live — the tail must GROW and the status
    // transition (running → done) must appear across the auto-refresh.
    tx.send(vec![
        step_row(build, "compile", 3, "done"),
        step_row(build, "test", 4, "running"),
    ])
    .expect("deliver interval pull #2");
    for _ in 0..4 {
        harness.run_ok();
    }
    let s2 = harness.state().state_json();
    let bi2 = &s2["build_info_live"];
    assert_eq!(
        bi2["tail_total"].as_u64(),
        Some(4),
        "tail ADVANCED on the next interval"
    );
    assert_eq!(
        bi2["step_count"].as_u64(),
        Some(2),
        "second sub-job appeared live"
    );
    let steps = bi2["steps"].as_array().unwrap();
    let compile = steps.iter().find(|s| s["step_id"] == "compile").unwrap();
    assert_eq!(
        compile["status"].as_str(),
        Some("done"),
        "status advanced with the pulls"
    );
    let poll_count = bi2["poll_count"].as_u64().unwrap();
    assert!(
        poll_count >= 2,
        "ticks advanced across the two interval pulls (got {poll_count})"
    );

    // Land the verdict as a functional_status matrix row (green ⟺ the pane
    // auto-updated across the two simulated interval pulls, no re-inject).
    #[cfg(feature = "testmatrix")]
    nornir_testmatrix::functional_status(
        "viz/build-info-live",
        "auto_updates_on_poll_timer",
        bi2["tail_total"].as_u64() == Some(4) && poll_count >= 2,
        &format!(
            "poll_count={poll_count} tail={} steps={}",
            bi2["tail_total"], bi2["step_count"]
        ),
    );
}

// ─── wgpu snapshot (LAW #4 — the pane actually renders) ───────────────────────

#[test]
fn viz_snapshot_build_info_live() {
    use egui_kittest::Harness;

    let build = "b-snap";
    let mut app = build_app();
    app.inject_build_info_for_test(vec![
        step_row(build, "fetch", 1, "done"),
        log_row(build, "fetch", 2, "fetched 12 crates"),
        step_row(build, "compile", 3, "running"),
        log_row(build, "compile", 4, "Compiling holger v0.1"),
    ]);

    let mut harness = Harness::builder()
        .with_size(egui::Vec2::new(1200.0, 800.0))
        .wgpu()
        .build_ui_state(|ui, app: &mut UrdrThreadsApp| app.draw_ui(ui), app);

    for _ in 0..6 {
        harness.run_ok();
    }

    // FUNCTIONAL PROOF on the rendered state.
    let state = harness.state().state_json();
    assert_eq!(state["tab"].as_str(), Some("LiveRun"));
    let bi = &state["build_info_live"];
    assert_eq!(bi["step_count"].as_u64(), Some(2), "2 sub-jobs rendered");
    assert_eq!(bi["tail_total"].as_u64(), Some(4), "4 tail lines rendered");

    // VISUAL PROOF.
    let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/snapshots");
    std::fs::create_dir_all(&dir).expect("create tests/snapshots");
    let image = harness
        .render()
        .expect("wgpu render of build-info live pane");
    let png_path = dir.join("viz_build_info_live.png");
    image
        .save(&png_path)
        .expect("write viz_build_info_live.png");
    let size = std::fs::metadata(&png_path).map(|m| m.len()).unwrap_or(0);
    eprintln!(
        "PROOF: viz_build_info_live → {} ({size} bytes)",
        png_path.display()
    );
    assert!(
        size > 3_000,
        "PNG suspiciously small ({size} bytes) — did it render?"
    );

    harness.snapshot("viz_build_info_live");
    if let Err(results) = harness.take_snapshot_results().into_result() {
        let inner: Vec<_> = results.into();
        for err in &inner {
            let msg = err.to_string();
            assert!(
                msg.contains("Missing snapshot"),
                "[viz_build_info_live] golden diff: {msg}"
            );
        }
    }
}

// ─── the REAL poll timer over a live Flight wire (feature build-info-flight) ──

/// End-to-end RED-when-broken: the background poller (`BuildInfoTail::start_poller`)
/// re-`pull`s a live sub-job ticket from a REAL `edda/nornir-flight` server on its
/// timer, so rows a build emits mid-run land in the pane's fold across intervals —
/// the tail ADVANCES with each `do_put`, driven ONLY by the timer (no inject, no
/// manual `pull`). Proves the gated async drive works over the wire, not just the
/// inbox drain. Deduped by the per-key high-water mark, so a re-pulled full stream
/// never double-folds.
#[cfg(feature = "build-info-flight")]
#[tokio::test(flavor = "multi_thread", worker_threads = 3)]
async fn poller_pulls_live_flight_rows_across_intervals() {
    use std::sync::Arc;
    use std::time::Duration;

    use nornir::build::flight_sink::{FlightBuildInfoSink, route_key};
    use nornir::viz::build_info_live::BuildInfoTail;
    use nornir_build_thing::build_info::BuildInfoSink;
    use nornir_flight::{FlightRoutes, FlightServer, MemFlightStore};

    // A Flight server with a shared in-mem store (open — no bearer).
    let addr = std::net::TcpListener::bind("127.0.0.1:0")
        .unwrap()
        .local_addr()
        .unwrap();
    let routes = FlightRoutes::shared(Arc::new(MemFlightStore::new()));
    tokio::spawn(async move {
        let _ = FlightServer::serve_with_auth(routes, addr, Arc::new(String::new())).await;
    });
    for _ in 0..100 {
        if tokio::net::TcpStream::connect(addr).await.is_ok() {
            break;
        }
        tokio::time::sleep(Duration::from_millis(20)).await;
    }
    let ep = format!("http://{addr}");

    let build = "b-wire";
    let sink = FlightBuildInfoSink::connect(&ep, None).unwrap();

    // Interval-pull #1's worth of rows: the compile sub-job starts + logs.
    sink.emit(&[
        step_row(build, "compile", 1, "running"),
        log_row(build, "compile", 2, "Compiling holger v0.1"),
    ])
    .unwrap();
    sink.flush().await.unwrap();

    // Start the pane's auto-refresh poller on a fast interval, subscribed to the
    // compile sub-job ticket. NOTHING is injected — the poller does the pulling.
    let mut tail = BuildInfoTail::new();
    tail.subscribe_ticket(build, "compile");
    tail.start_poller(&ep, None, Duration::from_millis(40));

    // Drain (what `draw` does each frame) until the first pull lands.
    let mut folded = 0u64;
    for _ in 0..100 {
        tail.pump();
        folded = tail.state_json()["tail_total"].as_u64().unwrap();
        if folded >= 2 {
            break;
        }
        tokio::time::sleep(Duration::from_millis(20)).await;
    }
    assert_eq!(folded, 2, "the poller pulled the first batch off the wire");

    // A second do_put (mid-build) — the timer re-pulls and the tail ADVANCES,
    // WITHOUT re-folding the already-seen rows (dedup by high-water mark).
    sink.emit(&[step_row(build, "compile", 3, "done")]).unwrap();
    sink.flush().await.unwrap();

    let mut advanced = folded;
    for _ in 0..100 {
        tail.pump();
        advanced = tail.state_json()["tail_total"].as_u64().unwrap();
        if advanced >= 3 {
            break;
        }
        tokio::time::sleep(Duration::from_millis(20)).await;
    }
    let v = tail.state_json();
    assert_eq!(
        advanced, 3,
        "the poll timer re-pulled the new row (no double-fold), got {v}"
    );
    let compile = v["steps"]
        .as_array()
        .unwrap()
        .iter()
        .find(|s| s["step_id"] == "compile")
        .unwrap();
    assert_eq!(
        compile["status"].as_str(),
        Some("done"),
        "status advanced over the wire"
    );
    assert!(
        v["poll_count"].as_u64().unwrap() >= 2,
        "at least two ticks folded"
    );
    // The subscribed ticket is the sub-job route key.
    assert_eq!(route_key(build, "compile"), format!("{build}/compile"));
}