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

nordisk/knut

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
}