dwarves-gui/tests/robot_thin_appliance.rs
//! **ROBOT-UI: the thin appliance — the tab finds the constellation, and the live
//! medium's tunnel is a real choice on it.**
//!
//! Two claims, both on the constellation's REAL declaration (no fixture invents a
//! product here), driven headless through the live widget tree:
//!
//! 1. **The tab finds the constellation from a plausible launch cwd.** `default_root`
//! was `cwd.parent()` — an arithmetic guess — and answered `/home` when the ui was
//! launched from `$HOME` on 2026-08-27, so the ⚒ roster held one repo and `sök`
//! could not find gunnar at all. The red half is asserted here too: the old guess's
//! answer (`/home`) is read for a roster and must NOT hold gunnar, so this test says
//! what it protects rather than merely passing.
//!
//! 2. **`[[build.iso.options]]` is a choice, not a decoration.** The row has been a
//! field on `IsoBuild` and been VALIDATED since wave 2, and `SettingsTree::of_iso`
//! has always drawn a checkbox for it — while nothing on the ISO assemble leg
//! applied it: the payload was staged from the untouched variant set, so an iso
//! toggle moved a checkbox and left the shipped binary byte-identical. The applier
//! is `dwarves::assemble_cell::resolve_cell_options` (the one resolver both cell
//! kinds route through), and the pane's own toggle line is fed to it VERBATIM here —
//! law B at the option axis: the click and the prompt's `--opt wireguard=off` reach
//! the same build space.
//!
//! The RED for claim 2 is in the test: the same real cell with its `options` emptied —
//! the world before this declaration — is asserted to draw no such node and to REFUSE
//! the flag by name. Without that, a green here could not tell an applied toggle from
//! an accepted-and-ignored one.
//!
//! No build is run: composing this medium is a kernel compile plus a musl link. What
//! is compared is the build space both doors resolve, which is what the artifact then
//! comes out of.
#![cfg(not(target_arch = "wasm32"))]
use facett_core::Facet;
use nornir_robotui::egui_kittest::Harness;
use nornir_robotui::{Observable, RobotSession, Target};
use serde_json::json;
use dwarves::build_thing::ConstellationBuilds;
use dwarves_gui::build_forge::DeclaredForge;
use dwarves_gui::build_scene::BuildScene;
struct SceneApp(BuildScene);
impl Observable for SceneApp {
fn state_json(&self) -> serde_json::Value {
Facet::state_json(&self.0)
}
}
/// The live appliance medium and the row it packs, as gunnar spells them.
const REPO: &str = "gunnar";
const ROW: &str = "gunnar-server";
const ISO: &str = "iso:gunnar-appliance-iso";
/// The recipe's own word for the toggle — the label the operator reads.
const WG: &str = "WireGuard-klient";
/// The constellation as the ui would find it, plus the home the walk stops at.
///
/// `DWARVES_CONSTELLATION` wins when set (a lane may point this elsewhere); otherwise
/// the launch site is `$HOME` on purpose — that is the cwd that broke the roster on
/// 2026-08-27, and the only launch site whose correct answer needs the child-directory
/// leg of `root_above`.
fn constellation() -> Option<(std::path::PathBuf, std::path::PathBuf)> {
let home = std::path::PathBuf::from(std::env::var_os("HOME")?);
// `default_root` reads `$DWARVES_REPO_ROOT` before the cwd, so the launch site is
// set rather than `chdir`-ed: an integration binary's tests share one process and a
// cwd change would reach the others.
if std::env::var_os("DWARVES_CONSTELLATION").is_none() {
std::env::set_var("DWARVES_REPO_ROOT", &home);
}
let root = ConstellationBuilds::default_root();
Some((root, home))
}
fn session(root: &std::path::Path) -> RobotSession<'_, SceneApp> {
let scene = BuildScene::new(Box::new(DeclaredForge::at(root.to_path_buf())));
let harness = Harness::builder()
.with_size(nornir_robotui::egui::Vec2::new(1600.0, 1400.0))
.build_ui_state(
|ui, app: &mut SceneApp| Facet::ui(&mut app.0, ui),
SceneApp(scene),
);
let mut robot = RobotSession::new(harness, "dwarves-gui");
robot.run(2);
robot
}
/// The settings the pane carries on one row's one column, as `/rows` renders them.
fn settings_on(
robot: &RobotSession<'_, SceneApp>,
row: &str,
column: &str,
) -> Vec<serde_json::Value> {
robot
.state_at("/rows")
.and_then(|r| r.as_array().cloned())
.unwrap_or_default()
.iter()
.find(|r| r["name"] == json!(row))
.and_then(|r| r["targets"].as_array().cloned())
.unwrap_or_default()
.iter()
.find(|t| t["name"] == json!(column))
.and_then(|t| t["settings"].as_array().cloned())
.unwrap_or_default()
}
#[test]
fn the_build_thing_tab_finds_gunnar_from_a_launch_cwd_of_home() {
let Some((root, home)) = constellation() else {
eprintln!("SKIP: no $HOME to launch from");
return;
};
let gunnar_decl = home
.join("git")
.join(REPO)
.join(".nornir")
.join("build-variants.toml");
if !gunnar_decl.is_file() {
eprintln!("SKIP: {} is not on this disk", gunnar_decl.display());
return;
}
// ── THE RED: the arithmetic guess, asked the same question ────────────────
// `cwd.parent()` of `$HOME` is `/home`, and a roster read there is the 2026-08-27
// failure verbatim: it finds SOMETHING (so an empty-roster assertion would not
// catch it) and it does not find gunnar.
let old_guess = home
.parent()
.map(std::path::PathBuf::from)
.expect("$HOME has a parent");
let by_guess = ConstellationBuilds::discover(&old_guess);
assert!(
!by_guess.repos.iter().any(|r| r.repo == REPO),
"the OLD guess ({}) found gunnar, so this test cannot tell the fix from the bug: {:?}",
old_guess.display(),
by_guess
.repos
.iter()
.map(|r| r.repo.clone())
.collect::<Vec<_>>(),
);
// ── THE GREEN: found by evidence ─────────────────────────────────────────
let builds = ConstellationBuilds::discover(&root);
let names: Vec<String> = builds.repos.iter().map(|r| r.repo.clone()).collect();
assert!(
names.iter().any(|n| n == REPO),
"the roster the ⚒ tab serves from root {} does not hold gunnar: {names:?}",
root.display(),
);
eprintln!(
"root {} · {} repo(s) · gunnar found",
root.display(),
names.len()
);
// ── and the TAB itself: sök gunnar, and its variants are on screen ────────
let mut robot = session(&root);
robot
.type_into("artefakt", REPO)
.expect("the artefakt field");
robot.click_label("sök").expect("sök");
robot.run(3);
let rows: Vec<String> = robot
.state_at("/rows")
.and_then(|r| r.as_array().cloned())
.unwrap_or_default()
.iter()
.filter_map(|r| r["name"].as_str().map(str::to_string))
.collect();
assert!(
rows.iter().any(|r| r == ROW),
"the ⚒ matrix offers no `{ROW}` row: {rows:?}"
);
assert!(
rows.iter().any(|r| r == "gunnar-appliance-iso"),
"the ⚒ matrix offers no appliance-iso variant: {rows:?}"
);
// The row is a real widget, and the iso column on it is reachable.
robot
.click_label(ROW)
.expect("the appliance row is a real widget");
robot.run(2);
robot
.click_label(ISO)
.unwrap_or_else(|e| panic!("the `{ISO}` column is not clickable: {e}"));
robot.run(3);
assert_eq!(
robot.state_at("/target"),
Some(json!(ISO)),
"the iso column is not the subject"
);
}
#[test]
fn the_live_mediums_tunnel_is_a_toggle_and_the_click_is_the_prompts_flag() {
let Some((root, home)) = constellation() else {
eprintln!("SKIP: no $HOME to launch from");
return;
};
if !home
.join("git")
.join(REPO)
.join(".nornir")
.join("build-variants.toml")
.is_file()
{
eprintln!("SKIP: gunnar is not on this disk");
return;
}
let builds = ConstellationBuilds::discover(&root);
let Some(repo) = builds.repo(REPO) else {
eprintln!("SKIP: root {} does not hold gunnar", root.display());
return;
};
let set = repo.set.clone();
let iso = set
.isos
.iter()
.find(|i| i.name == "gunnar-appliance-iso")
.cloned()
.expect("gunnar declares the appliance iso");
assert_eq!(
iso.from_variant.as_deref(),
Some(ROW),
"the iso packs the server row"
);
// ── THE RED, first: the same cell with its options emptied ────────────────
// The world before the declaration. The tree draws no such node, and the flag is
// refused BY NAME rather than accepted and applied to nothing.
let bare = nornir_build_thing::iso::IsoBuild {
options: Vec::new(),
..iso.clone()
};
let bare_tree = nornir_build_thing::tree::SettingsTree::of_iso(&bare, None);
assert!(
bare_tree.get("wireguard").is_none(),
"an option-less iso already offers a `wireguard` node — the red is not red"
);
let e = dwarves::assemble_cell::resolve_cell_options(
"iso 'gunnar-appliance-iso'",
&set,
&bare_tree,
&bare.options,
&bare.params,
bare.from_variant.as_deref(),
&["wireguard=off".to_string()],
)
.expect_err("an undeclared toggle was accepted");
let e = format!("{e:#}");
assert!(
e.contains("wireguard"),
"the refusal of an undeclared toggle does not name it: {e}"
);
// ── THE PANE: the toggle is a real, ticked checkbox on the ISO column ─────
let mut robot = session(&root);
robot
.type_into("artefakt", REPO)
.expect("the artefakt field");
robot.click_label("sök").expect("sök");
robot.run(3);
robot.click_label(ROW).expect("the appliance row");
robot.run(2);
robot.click_label(ISO).expect("the iso column");
robot.run(3);
let settings = settings_on(&robot, ROW, ISO);
let wg = settings
.iter()
.find(|s| s["key"] == json!("wireguard"))
.unwrap_or_else(|| panic!("the iso column carries no `wireguard` setting: {settings:?}"));
assert_eq!(
wg["option"]["label"],
json!(WG),
"the label is not the recipe's own word"
);
assert_eq!(
wg["option"]["feature"],
json!("wg-client"),
"the toggle decides the wrong feature"
);
assert!(
robot.label_visible(WG),
"the tunnel opt-out has no control on the iso column"
);
assert_eq!(
robot.field_checked(&Target::Label(WG.into())),
Some(true),
"an opt-out must ship unless somebody opts out"
);
assert_eq!(
robot.state_at("/feature_delta"),
Some(json!("features: +wg-client"))
);
// ── the CLICK ────────────────────────────────────────────────────────────
robot
.click_label(WG)
.expect("the wireguard checkbox is clickable");
robot.run(3);
assert_eq!(robot.field_checked(&Target::Label(WG.into())), Some(false));
assert_eq!(
robot.state_at("/feature_delta"),
Some(json!("features: \u{2212}wg-client")),
"the delta does not follow the toggle"
);
// ── LAW B: that click, spelled as the prompt spells it, reaches the same
// build space through the CLI door's own resolver ─────────────────────
// The TREE is the third argument since lane AF: it is what tells a feature
// from a val from a donor's pair, so the door hands it the iso's own tree.
let iso_tree = nornir_build_thing::tree::SettingsTree::of_set(&set, &iso.name)
.expect("the iso declares a settings tree");
let (thin, resolved) = dwarves::assemble_cell::resolve_cell_options(
"iso 'gunnar-appliance-iso'",
&set,
&iso_tree,
&iso.options,
&iso.params,
iso.from_variant.as_deref(),
&["wireguard=off".to_string()],
)
.expect("the declared toggle resolves");
let before = set
.variants
.iter()
.find(|v| v.name == ROW)
.expect("the row")
.features
.clone();
let after = thin
.variants
.iter()
.find(|v| v.name == ROW)
.expect("the row")
.features
.clone();
assert!(
before.iter().any(|f| f == "wg-client"),
"the declared row does not carry wg-client, so subtracting it proves nothing: {before:?}"
);
assert!(
!after.iter().any(|f| f == "wg-client"),
"`--opt wireguard=off` left wg-client in the build space the payload is staged from: {after:?}"
);
assert_eq!(
after.len(),
before.len() - 1,
"the toggle changed more than the one feature it decides: {before:?} → {after:?}"
);
assert_eq!(
resolved.feature_delta(),
"features: \u{2212}wg-client",
"the CLI door's delta"
);
// And the symbol families the medium must now be read back for — the ISO leg's
// `refuse_forbidden_symbols` subject, declared rather than assumed.
let forbidden: Vec<String> = resolved
.forbidden_symbols
.iter()
.flat_map(|(_, f)| f.clone())
.collect();
assert!(
forbidden.iter().any(|f| f == "wg_appliance_core") && forbidden.iter().any(|f| f == "boringtun"),
"the off toggle forbids no symbol family, so the read-back would measure nothing: {forbidden:?}"
);
}