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