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