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

nordisk/dwarves

dwarves-gui/tests/robot_explorer.rs

//! **🧭 Explorer, end to end: UI → forge → nornir.** The tab is given ONLY the forge's
//! address. The forge is the REAL dwarves Explorer service (its jera lanes, its Runa
//! road for the super build) beside the real code-room / workspaces proxies with the
//! 🌳 tree lane, in front of a mock nornir (a registry, the build board, the artifacts
//! Builder 2 landed). The robot clicks through the shipped widgets:
//!
//! 1. open Explorer — the project tree is there;
//! 2. select the workspace — its repos and the live unit graph;
//! 3. click ▶ Build workspace — queued → running → done, from the forge's job; the
//!    run is then in the history;
//! 4. select a Build Thing — its artifacts;
//! 5. select a pipeline — its stages.
//!
//! `live_explorer_render` (ignored) stands the same forge in front of a LIVE dwarf
//! (`$DWARVES_LIVE_FORGE`, read-only: nothing is clicked) and writes a PNG.

use std::path::Path;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

use dwarves::explorer::lane::{spawn_forge, FakeRunner};
use dwarves_gui::explorer::ExplorerTab;
use egui_kittest::kittest::Queryable;
use facett_core::Facet;
use nornir_warehouse_embed::testkit::{spawn_router, MockCodeRoom, WorkspacesLog};
use serde_json::{json, Value};

const PROJECT: &str = r#"
[project]
name = "shop"
about = "a test forge"

[mimir]
name = "shop"

[[workspaces]]
name = "shop"

[[build_things]]
name = "app"
repo = "app"

[[pipelines]]
name = "app-ci"
repo = "app"
file = "app.runa.ron"
"#;

fn board() -> Value {
    let core = "path+ws:core/core#core@0.1.0";
    json!({
        "state": "complete",
        "members": [
            { "member": "app", "phase": "compiling", "sha": "a1", "units": 2, "done": 1, "own_units": 1 },
            { "member": "core", "phase": "landed", "sha": "c1", "units": 1, "done": 1, "own_units": 1 },
            { "member": "tools", "phase": "queued", "sha": "t1", "units": 1, "done": 0, "own_units": 0 },
        ],
        "counts": { "done": 2, "compiling": 1 },
        "distinct_units": 3,
        "units": [
            { "key": "c", "package": core, "target": "lib:core", "mode": "check", "test": false, "features": [], "members": ["app", "core", "tools"], "shared_by": 3, "state": "done", "deps": [] },
            { "key": "a", "package": "path+ws:app#app@0.1.0", "target": "lib:app", "mode": "check", "test": false, "features": [], "members": ["app"], "shared_by": 1, "state": "compiling", "deps": [0] },
        ],
    })
}

fn artifacts() -> Value {
    json!({ "state": "complete", "count": 2, "artifacts": [
        { "id": "x1", "owner": "o", "file": "app-server", "repo": "app", "variant": "app-server", "target": "x86_64-unknown-linux-gnu:release", "features": [], "provenance": "build-7", "crates": ["app@0.1.0", "core@0.1.0"], "commits": ["app@a1b2c3d4"] },
        { "id": "x2", "owner": "o", "file": "tool", "repo": "tools", "variant": "t", "target": "x86_64-unknown-linux-gnu:release", "features": [], "provenance": "build-8", "crates": [], "commits": [] },
    ] })
}

/// A forge's warehouse holding the registered project, and the checkouts it reads.
fn lay_out(dir: &Path) {
    let projects = dir.join("warehouse/projects");
    std::fs::create_dir_all(&projects).unwrap();
    std::fs::write(projects.join("shop.dwarves.toml"), PROJECT).unwrap();
    let app = dir.join("checkouts/app");
    std::fs::create_dir_all(app.join(".nornir")).unwrap();
    std::fs::write(
        app.join(".nornir/build-variants.toml"),
        "[build]\n[[build.variant]]\nname = \"app-server\"\ndefault = true\nopen = [\"extra\"]\n",
    )
    .unwrap();
    std::fs::write(
        app.join("app.runa.ron"),
        r#"(name: "app-ci", stages: [(name: "lint", steps: [CargoTest]), (name: "build", needs: ["lint"], steps: [Build(variant: "app-server")])])"#,
    )
    .unwrap();
}

/// Step the harness until `cond` holds on the tab's state (or panic after `secs`).
fn step_until(
    h: &mut egui_kittest::Harness<'_>,
    tab: &Arc<Mutex<ExplorerTab>>,
    secs: u64,
    what: &str,
    cond: impl Fn(&Value) -> bool,
) -> Value {
    let t0 = Instant::now();
    loop {
        h.step();
        let s = tab.lock().unwrap().state_json();
        if cond(&s) {
            return s;
        }
        assert!(
            t0.elapsed() < Duration::from_secs(secs),
            "{what}: never happened — {s}"
        );
        std::thread::sleep(Duration::from_millis(15));
    }
}

#[test]
fn the_explorer_walks_the_project_and_builds_a_workspace_through_the_forge() {
    let dir = tempfile::tempdir().unwrap();
    lay_out(dir.path());

    // the mock nornir: the registry (Workspaces) and the code room on one socket
    let registry = WorkspacesLog::default();
    registry.set_member_states(&[("app", "ok"), ("core", "ok"), ("tools", "ok")]);
    let room = MockCodeRoom::answering(&[
        ("Coderoom.Units", board()),
        ("Coderoom.Artifacts", artifacts()),
    ]);
    let asked = room.asked.clone();
    let (reg, rm) = (registry.clone(), room);
    let nornir = spawn_router(move |mut s| {
        s.add_service(nornir_warehouse_embed::pb::workspaces_server::WorkspacesServer::new(reg))
            .add_service(nornir_warehouse_embed::pb::code_room_server::CodeRoomServer::new(rm))
    });
    nornir_warehouse_embed::register_workspace_descriptor(
        &nornir.endpoint,
        "",
        "shop",
        "[workspace]\nname = \"shop\"\n",
        "monitored",
    )
    .unwrap();

    let forge = spawn_forge(
        nornir.endpoint.clone(),
        String::new(),
        dir.path().join("warehouse"),
        Some(dir.path().join("checkouts")),
        Arc::new(FakeRunner::ok(Duration::from_millis(1500))),
    );

    let tab = Arc::new(Mutex::new(ExplorerTab::new(
        forge.endpoint.clone(),
        String::new(),
    )));
    let t2 = tab.clone();
    let mut h = egui_kittest::Harness::builder()
        .with_size(egui::vec2(1400.0, 1000.0))
        .build_ui(move |ui| t2.lock().unwrap().ui(ui));

    // 1. the project tree
    let s = step_until(&mut h, &tab, 20, "the tree", |s| {
        s["tree"].as_array().is_some_and(|t| !t.is_empty())
    });
    assert_eq!(s["project"], "shop");
    assert_eq!(
        s["tree"],
        json!([
            "project:shop",
            "mimir:shop",
            "group:workspaces",
            "workspace:shop",
            "group:build_things",
            "build_thing:app",
            "group:pipelines",
            "pipeline:app-ci"
        ])
    );
    assert!(
        h.query_by_label("📁 Build Things (1)").is_some(),
        "the groups are drawn"
    );
    assert!(
        s["answer"]["via"]
            .as_str()
            .unwrap()
            .contains(&forge.endpoint),
        "asked the forge, nothing else: {s}"
    );

    // 2. the workspace: its repos and its graph
    h.get_by_label("📚 shop").click();
    let s = step_until(&mut h, &tab, 20, "the workspace's repos and graph", |s| {
        s["detail_kind"] == "workspace"
            && s["repos"].as_array().is_some_and(|r| r.len() == 3)
            && s["graph"]["nodes"].as_u64().unwrap_or(0) > 0
    });
    assert_eq!(s["repos"], json!(["app", "core", "tools"]));
    assert_eq!(s["graph"]["workspace"], "shop");
    // the embedded live unit graph opens CENTRED in the part of its canvas the viewer
    // sees (±2 px) and wholly inside it — not "10 cm to the right"
    for _ in 0..4 {
        h.step();
    }
    let s = tab.lock().unwrap().state_json();
    let (b, v) = (&s["graph"]["screen"]["box"], &s["graph"]["screen"]["visible"]);
    let f = |a: &Value, i: usize| a[i].as_f64().unwrap_or_else(|| panic!("no screen box: {s}"));
    let (bc, vc) = (((f(b, 0) + f(b, 2)) / 2.0, (f(b, 1) + f(b, 3)) / 2.0), ((f(v, 0) + f(v, 2)) / 2.0, (f(v, 1) + f(v, 3)) / 2.0));
    assert!((bc.0 - vc.0).abs() <= 2.0 && (bc.1 - vc.1).abs() <= 2.0, "the graph is centred: box {b} in {v}");
    assert!(f(b, 0) >= f(v, 0) - 0.5 && f(b, 2) <= f(v, 2) + 0.5 && f(b, 1) >= f(v, 1) - 0.5 && f(b, 3) <= f(v, 3) + 0.5, "fully visible: box {b} in {v}");
    // the graph gets the bulk of the 1 000 px window, under a drag bar (it sat at a
    // fixed 420 px under the lists: "only the lower part of the window")
    assert!(f(v, 3) - f(v, 1) >= 0.6 * 1000.0 * 0.85, "the graph covers most of the tab's height: {v}");
    let top = f(v, 1) as f32;
    let bar = (0..120)
        .map(|d| egui::pos2(900.0, top - d as f32))
        .find(|p| {
            h.hover_at(*p);
            h.step();
            h.output().platform_output.cursor_icon == egui::CursorIcon::ResizeVertical
        })
        .expect("a drag bar above the graph");
    h.drag_at(bar);
    h.step();
    h.hover_at(bar + egui::vec2(0.0, 120.0));
    h.step();
    h.drop_at(bar + egui::vec2(0.0, 120.0));
    for _ in 0..3 {
        h.step();
    }
    let s2 = tab.lock().unwrap().state_json();
    let v2 = &s2["graph"]["screen"]["visible"];
    assert!(f(v2, 1) > f(v, 1) + 80.0, "dragging the bar moved the graph's top: {v} → {v2}");

    // 3. ▶ Build workspace: queued → running → done, from the forge's job
    h.get_by_label("▶ Build workspace").click();
    let s = step_until(&mut h, &tab, 60, "the build job to end", |s| {
        s["job"]["state"] == "done" || s["job"]["state"] == "failed"
    });
    assert_eq!(
        s["job"]["seen"],
        json!(["queued", "running", "done"]),
        "{s}"
    );
    let run_id = s["job"]["run_id"].as_str().unwrap().to_string();
    assert!(run_id.starts_with("explorer-build_workspace-"), "{s}");
    let s = step_until(&mut h, &tab, 20, "the run in the history", |s| {
        s["builds"]
            .as_array()
            .is_some_and(|b| b.iter().any(|r| r == &json!(run_id)))
    });
    assert_eq!(s["builds"][0], json!(run_id));
    assert!(
        s["build_units"].is_object(),
        "the build carries its units (the forge's unit history): {s}"
    );
    let runs = dwarves::store::read_runs(&dir.path().join("warehouse")).unwrap();
    assert_eq!(
        (runs[0].pipeline.as_str(), runs[0].trigger.as_str()),
        ("workspace-build-shop", "explorer"),
        "the Runa road historised it"
    );

    // 4. a Build Thing: its artifacts (only its repo's)
    h.get_by_label("🔨 app").click();
    let s = step_until(&mut h, &tab, 20, "the Build Thing's artifacts", |s| {
        s["detail_kind"] == "build_thing"
            && s["artifacts"].as_array().is_some_and(|a| !a.is_empty())
    });
    assert_eq!(
        (s["artifacts"].clone(), s["variants"].clone()),
        (json!(["app-server"]), json!(["app-server"])),
        "{s}"
    );
    assert!(s["graph"].is_null(), "the graph is the workspace's");

    // 4b. pick the variant, tick its open cargo feature, ▶ Build: the forge's job
    //     builds WITH it (the Build Thing job carries the choice to the build)
    h.get_by_label("app-server (default)").click();
    step_until(&mut h, &tab, 5, "the variant picked", |_| true);
    h.get_by_label("extra").click();
    let s = step_until(&mut h, &tab, 5, "the feature ticked", |s| s["cargo"]["features"] == json!(["extra"]));
    assert_eq!(s["cargo"]["features"], json!(["extra"]), "{s}");
    h.get_by_label("▶ Build app-server").click();
    let s = step_until(&mut h, &tab, 30, "the Build Thing job done", |s| {
        s["job"]["state"] == "done"
            && s["job"]["lines"].as_array().is_some_and(|l| l.iter().any(|x| x.as_str().is_some_and(|x| x.contains("--features extra"))))
    });
    assert!(
        s["job"]["lines"].to_string().contains("[app-server] --features extra"),
        "the build ran WITH the picked feature: {s}"
    );

    // 5. a pipeline: its stages
    h.get_by_label("🔀 app-ci").click();
    let s = step_until(&mut h, &tab, 20, "the pipeline's stages", |s| {
        s["detail_kind"] == "pipeline" && s["stages"].as_array().is_some_and(|a| !a.is_empty())
    });
    assert_eq!(s["stages"], json!(["lint", "build"]));
    assert!(h.query_by_label("▶ Run").is_some());

    let asked = asked.lock().unwrap().clone();
    assert!(
        asked
            .iter()
            .all(|(_, s, _)| s == "Coderoom.Units" || s == "Coderoom.Artifacts"),
        "nornir is asked the board and the artifacts only: {asked:?}"
    );
}

/// The live render: `DWARVES_LIVE_FORGE=http://10.125.73.187:7900
/// DWARVES_EXPLORER_PNG=/tmp/explorer.png DWARVES_LIVE_PROJECT=<…/nordisk.dwarves.toml>
/// DWARVES_LIVE_CHECKOUTS=<clones> cargo test -p dwarves-gui --test robot_explorer
/// live_explorer_render -- --ignored --nocapture`. Read-only: it selects, it never builds.
#[test]
#[ignore]
fn live_explorer_render() {
    let Ok(live) = std::env::var("DWARVES_LIVE_FORGE") else {
        eprintln!("DWARVES_LIVE_FORGE not set — nothing to render");
        return;
    };
    let token = std::env::var("DWARVES_LIVE_TOKEN").unwrap_or_default();
    let project =
        std::env::var("DWARVES_LIVE_PROJECT").expect("DWARVES_LIVE_PROJECT: the project file");
    let dir = tempfile::tempdir().unwrap();
    let projects = dir.path().join("warehouse/projects");
    std::fs::create_dir_all(&projects).unwrap();
    std::fs::copy(
        &project,
        projects.join(Path::new(&project).file_name().unwrap()),
    )
    .unwrap();
    let checkouts = std::env::var("DWARVES_LIVE_CHECKOUTS")
        .ok()
        .map(std::path::PathBuf::from);
    let forge = spawn_forge(
        live,
        token,
        dir.path().join("warehouse"),
        checkouts,
        Arc::new(FakeRunner::ok(Duration::ZERO)),
    );
    let tab = Arc::new(Mutex::new(ExplorerTab::new(
        forge.endpoint.clone(),
        String::new(),
    )));
    tab.lock().unwrap().update_json(r#"{"wait":true}"#);
    let select =
        std::env::var("DWARVES_LIVE_SELECT").unwrap_or_else(|_| "workspace:nordisk".into());
    tab.lock()
        .unwrap()
        .update_json(&json!({ "select": select }).to_string());
    tab.lock().unwrap().update_json(r#"{"wait":true}"#);
    let t2 = tab.clone();
    let mut h = egui_kittest::Harness::builder()
        .with_size(egui::vec2(1800.0, 1300.0))
        .build_ui(move |ui| t2.lock().unwrap().ui(ui));
    let t0 = Instant::now();
    while t0.elapsed() < Duration::from_secs(20) {
        h.step();
        let s = tab.lock().unwrap().state_json();
        if !select.starts_with("workspace:") || s["graph"]["nodes"].as_u64().unwrap_or(0) > 0 {
            break;
        }
        std::thread::sleep(Duration::from_millis(50));
    }
    for _ in 0..10 {
        h.step();
    }
    let s = tab.lock().unwrap().state_json();
    println!("{}", serde_json::to_string_pretty(&s).unwrap());
    if let Ok(png) = std::env::var("DWARVES_EXPLORER_PNG") {
        match h.render() {
            Ok(img) => {
                img.save(&png).expect("write png");
                println!("wrote {png}");
            }
            Err(e) => println!("no render: {e}"),
        }
    }
}