crates/knut-pipelines/tests/robot_web_viewer.rs
//! **Browser robot-UI test for the knut-pipelines WEB viewer** (feature `web`).
//!
//! This closes ROBOT-GAP-1: the axum+SSE web viewer had only `tower::oneshot`
//! router tests (no rendered DOM). Here we serve the REAL page, drive a REAL
//! browser through `fantoccini` (WebDriver), and assert the DAG *animates* — the
//! browser analogue of `robot_snarl_gui.rs`: same red/green `Row` matrix, same
//! "drive → expected → gate on zero reds + a substantial step count" spirit.
//!
//! The page is facett's `facet-wrapped` bundle — the Rust `facett-pipeline` viewer
//! compiled to wasm — so the oracle is what the wasm publishes for exactly this
//! purpose: `window.facet_ready`, `facet_mounted()`, `facet_error()` and
//! `facet_state()` (the component's own `state_json`, one entry per node with its
//! status and rows). There is no DOM to read: the wasm draws every pixel on one
//! canvas, and a DOM oracle would mean a DOM drawing, which is the thing row 6
//! deleted.
//!
//! Self-driving: the built-in [`knut_pipelines::demo_graph`] replays a synthetic
//! no-server SDP run over SSE (NO live Spark needed), so the DAG walks
//! bronze → silver → gold to all-done on its own; the robot only observes.
//!
//! ## Prerequisites (why it's `#[ignore]`)
//! * A WebDriver listening at `127.0.0.1:4444`:
//! `chromedriver --port=4444` (or `geckodriver --port 4444`), on a browser with
//! WebGPU — the bundle renders on WebGPU only.
//! * The facet-wrapped bundle: `FACET_WRAPPED_DIST=<dir>` holding `index.html`,
//! `facet_wrapped.js`, `facet_wrapped_bg.wasm` (see `knut_pipelines::web`).
//!
//! Run: `FACET_WRAPPED_DIST=… cargo test -p knut-pipelines --features web \
//! --test robot_web_viewer -- --ignored --nocapture`
#![cfg(feature = "web")]
use std::time::{Duration, Instant};
use fantoccini::ClientBuilder;
use knut_pipelines::demo_graph;
use knut_pipelines::web::bundle_from_env;
/// One driven/observed step's outcome — a red/green row (the matrix cell).
struct Row {
name: String,
ok: bool,
msg: String,
}
struct Robot {
rows: Vec<Row>,
}
impl Robot {
fn new() -> Self {
Robot { rows: Vec::new() }
}
fn check(&mut self, name: &str, ok: bool, msg: impl Into<String>) {
self.rows.push(Row {
name: name.to_string(),
ok,
msg: msg.into(),
});
}
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires: a WebDriver at 127.0.0.1:4444 (chromedriver/geckodriver), a WebGPU-capable browser and a built bundle at FACET_WRAPPED_DIST; run with --ignored"]
async fn dag_animates_to_all_done() {
let mut r = Robot::new();
// The bundle is a precondition, not a robot row: without it the page is the
// (correct) 503 refusal and there is nothing to drive.
let bundle = bundle_from_env().unwrap_or_else(|e| panic!("{e}"));
// ── serve the REAL web viewer on an ephemeral port (synthetic replay) ──
// The `serve_on_bundle` seam takes an already-bound listener, so we recover
// the concrete OS-assigned port with NO bind race, then hand the live
// listener to the server. A short per-event delay keeps it a real animation
// while staying well inside the poll budgets below.
let graph = demo_graph();
let dataset_count = graph.datasets.len(); // 3: bronze_events, silver_events, gold_daily
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral 127.0.0.1:0");
let addr = listener.local_addr().expect("recover bound addr");
let server = tokio::spawn(async move {
// connect=None → the self-contained synthetic SDP replay drives the page.
let _ = knut_pipelines::web::serve_on_bundle(
listener,
graph,
None,
Duration::from_millis(50),
Ok(bundle),
)
.await;
});
let url = format!("http://{addr}/");
// ── connect a real browser ──
let client = ClientBuilder::native()
.connect("http://127.0.0.1:4444")
.await
.expect("connect WebDriver at 127.0.0.1:4444 (start chromedriver/geckodriver)");
client.goto(&url).await.expect("navigate to the viewer");
// A JS expression evaluated in the page, as JSON. Reading the wasm's own
// read-back hooks is observation, not drawing.
let eval = |js: &str| {
let client = client.clone();
let js = format!("return ({js});");
async move { client.execute(&js, vec![]).await.unwrap_or(serde_json::Value::Null) }
};
// ── 0. the wasm initialised (the loader published its hooks) ──
let ready = wait_until(Duration::from_secs(20), || async {
eval("window.facet_ready === true").await == serde_json::Value::Bool(true)
})
.await;
r.check("wasm initialised (window.facet_ready)", ready, "");
// ── 1. the `pipeline` component is on the canvas — knut named it, not a fixture ──
let mounted = wait_until(Duration::from_secs(20), || async {
eval("window.facet_mounted()").await == serde_json::Value::String("pipeline".into())
})
.await;
r.check(
"facet_mounted() == \"pipeline\"",
mounted,
format!("mounted = {}", eval("window.facet_mounted()").await),
);
r.check(
"no mount refusal (facet_error() empty)",
eval("window.facet_error()").await == serde_json::Value::String(String::new()),
format!("facet_error() = {}", eval("window.facet_error()").await),
);
r.check(
"refusal panel hidden",
eval("getComputedStyle(document.getElementById('refusal')).display").await
== serde_json::Value::String("none".into()),
"",
);
// The independent backend check: which context the canvas really holds.
let api = eval("window.facet_canvas_api()").await;
r.check(
"canvas draws through WebGPU",
api == serde_json::Value::String("webgpu".into()),
format!("facet_canvas_api() = {api}"),
);
// ── 2. every node reaches `done` (the animation), read from the component's state ──
let nodes = || async {
let v = eval("window.facet_state()").await;
let s: serde_json::Value = v
.as_str()
.and_then(|t| serde_json::from_str(t).ok())
.unwrap_or(v);
s["nodes"].as_array().cloned().unwrap_or_default()
};
let all_done = wait_until(Duration::from_secs(20), || async {
let n = nodes().await;
n.len() == dataset_count && n.iter().all(|x| x["status"] == "done")
})
.await;
let final_nodes = nodes().await;
r.check(
&format!("every node reached `done` ({dataset_count} nodes)"),
all_done,
format!("nodes = {}", serde_json::Value::Array(final_nodes.clone())),
);
// Per-node coverage rows (each of bronze/silver/gold reached done).
for ds in ["bronze_events", "silver_events", "gold_daily"] {
let node = final_nodes.iter().find(|x| x["name"] == ds);
let status = node.map(|x| x["status"].to_string()).unwrap_or_default();
r.check(
&format!("coverage: node {ds} is done"),
node.is_some_and(|x| x["status"] == "done"),
format!("status = {status}"),
);
}
// ── 3. rows rode the wire: the terminal node carries the count knut parsed ──
let gold_rows = final_nodes
.iter()
.find(|x| x["name"] == "gold_daily")
.map(|x| x["rows"].clone())
.unwrap_or(serde_json::Value::Null);
r.check(
"gold_daily carries its rows",
gold_rows.as_u64().is_some_and(|n| n > 0),
format!("rows = {gold_rows}"),
);
// ── print the matrix + gate (robot_snarl_gui style) ──
let passed = r.rows.iter().filter(|x| x.ok).count();
let total = r.rows.len();
eprintln!("\n══ knut-pipelines WEB VIEWER ROBOT DRIVE (fantoccini / real browser / facet-wrapped wasm) ══");
eprintln!("serving the built-in demo DAG through the synthetic no-server SSE replay");
for row in &r.rows {
let mark = if row.ok { "✓" } else { "✗" };
eprintln!(
" {mark} {}{}",
row.name,
if row.msg.is_empty() {
String::new()
} else {
format!(" — {}", row.msg)
}
);
}
eprintln!("── {passed}/{total} robot steps PASSED ──\n");
// Emit one matrix row for the whole browser drive (gated → release strips it).
knut_pipelines::functional_status(
"web-viewer",
"dag_animates_to_all_done",
r.rows.iter().all(|x| x.ok),
&format!("{passed}/{total} browser-robot steps green"),
);
let _ = client.close().await;
server.abort();
let reds: Vec<_> = r
.rows
.iter()
.filter(|x| !x.ok)
.map(|x| (&x.name, &x.msg))
.collect();
assert!(reds.is_empty(), "web-viewer robot has red cells: {reds:#?}");
assert!(
total >= 8,
"expected a substantial browser drive, got {total} steps"
);
}
/// Poll `cond` until it returns true or `budget` elapses (200 ms cadence).
async fn wait_until<F, Fut>(budget: Duration, mut cond: F) -> bool
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = bool>,
{
let deadline = Instant::now() + budget;
while Instant::now() < deadline {
if cond().await {
return true;
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
false
}