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

nordisk/facett

facett-buildthing/tests/robot.rs

//! **RED-when-broken robot proof** for the build-thing facet — render it headless
//! through a REAL `egui_kittest` AccessKit harness (CPU, no wgpu) and drive the
//! whole flow as DATA:
//!
//! 1. render non-blank: the declared option widgets reach the pixels (variant /
//!    feature labels are queryable AccessKit nodes);
//! 2. **select-kind → toggle-feature**: selecting `embedded` greys out its
//!    `exclusive` siblings (`minimal` / `full`) — decided by `validate()`, surfaced
//!    in `state_json["greyed"]` — and the live banner reads **valid**;
//! 3. **press Build → assemble()**: the returned [`Assembled`] receipt surfaces in
//!    `state_json["last_assembled"]` (output kind + build-info lineage rows, the
//!    artifact row carrying its `content_id`) — RED if the seam is unwired.
//!
//! A `FakeAssemble` stands in for real cargo: it synthesizes an artifact file and
//! captures REAL provenance over it, so the assemble→lineage→content_id chain runs
//! with no `cargo`, container, or KVM.

use std::cell::RefCell;
use std::path::Path;
use std::rc::Rc;

use anyhow::Result;
use egui_kittest::kittest::{By, Queryable};
use egui_kittest::Harness;

use facett_buildthing::BuildThing;
use facett_core::Facet;
use nornir_build_thing::{
    ArtifactKind, Assemble, BuildInfoSink, BuildOutcome, BuildProvenance, BuildVariantSet,
    HostInfo, Outcome, Profile, ServiceShape, Toolchain,
};

/// A holger-shaped declaration: an `exclusive` group over three exe variants, plus
/// an orthogonal `gui` feature.
const HOLGER: &str = r#"
[build]
offers = ["embedded", "minimal", "full", "gui"]
exclusive = [["embedded", "minimal", "full"]]

[[build.variant]]
name = "embedded"
package = "holger-embedded"
bin = "holger-embedded"
features = ["embedded"]
no_default_features = true
profile = "release"

[[build.variant]]
name = "full"
package = "holger-full"
bin = "holger-full"
features = ["full"]
profile = "release"
"#;

/// A fake [`Assemble`] standing in for real cargo: synthesize a deterministic
/// artifact file per variant name and capture REAL provenance over it. No `cargo`.
struct FakeAssemble;

impl Assemble for FakeAssemble {
    fn build_cargo(
        &self,
        set: &BuildVariantSet,
        name: &str,
        repo_root: &Path,
        _sink: &dyn BuildInfoSink,
    ) -> Result<BuildOutcome> {
        let v = set.require(name)?.clone();
        let dir = repo_root.join("target/release");
        std::fs::create_dir_all(&dir)?;
        let bin = v.bin.clone().or_else(|| v.package.clone()).unwrap_or_else(|| v.name.clone());
        let art = dir.join(&bin);
        std::fs::write(&art, format!("ARTIFACT {name}"))?;
        let rec = BuildProvenance::capture(
            &v,
            "testrepo",
            repo_root,
            Toolchain {
                rustc: "rustc 1.85.0".into(),
                host: "x86_64-unknown-linux-gnu".into(),
                cargo: "cargo 1.85.0".into(),
                // edda's Toolchain grew the pinned build image (2026-09); a host-side
                // robot build names none.
                image: None,
                image_sha256: None,
            },
            HostInfo { cores: 8, memory_bytes: 0 },
            Outcome::Success,
            std::slice::from_ref(&art),
        )?;
        Ok(BuildOutcome {
            variant: name.to_string(),
            artifacts: vec![art],
            attestation: rec.attest()?,
            success: true,
        })
    }

    fn cargo_available(&self) -> bool {
        true
    }
}

fn thing() -> BuildThing {
    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path().to_path_buf();
    // Leak the tempdir handle: it must outlive the returned thing for the duration
    // of the test (a real assemble writes under it). The OS reaps /tmp anyway.
    std::mem::forget(tmp);
    let set = BuildVariantSet::from_toml_str(HOLGER).unwrap();
    BuildThing::from_set("holger", set)
        .with_repo_root(root)
        .with_builders(Box::new(FakeAssemble))
}

#[test]
fn robot_drives_select_toggle_grey_and_build_surfaces_lineage() {
    let thing = Rc::new(RefCell::new(thing()));
    let ui_thing = thing.clone();
    let mut harness = Harness::builder()
        .with_size(egui::vec2(760.0, 900.0))
        .build_ui(move |ui| {
            ui_thing.borrow_mut().present_egui(ui);
        });
    harness.run();
    harness.run();

    // (1) render non-blank: the declared option widgets reached the pixels.
    for label in ["embedded", "full", "gui"] {
        assert!(
            harness.query_all(By::new().label_contains(label)).count() >= 1,
            "the option widget for `{label}` must render as an AccessKit node"
        );
    }

    // (2) select-kind → toggle-feature. Select `embedded`, keep it an Exe kind, and
    // add the orthogonal `gui` feature. The exclusive siblings must grey out.
    {
        let mut t = thing.borrow_mut();
        t.select("embedded");
        t.set_kind(ArtifactKind::Exe);
        t.toggle_feature("gui");
        // F4: drive the fat/thin toggle, the profile combo and the target field too —
        // the three candidate axes the pane draws (shape / profile / target) but that
        // no robot had ever moved. They feed `state_json["candidate"]` (lib.rs 463-465).
        t.set_shape(ServiceShape::ThinStandalone);
        t.set_profile(Profile::Dev);
        t.set_target(Some("x86_64-unknown-linux-musl".into()));
    }
    harness.run();

    let sj = thing.borrow().state_json();
    assert_eq!(sj["candidate"]["name"], "embedded");
    assert_eq!(sj["candidate"]["kind"], "exe");
    // F4: the driven shape / profile / target reach the observable candidate state.
    assert_eq!(sj["candidate"]["shape"], "thin", "the fat/thin toggle set the candidate shape");
    assert_eq!(sj["candidate"]["profile"], "dev", "the profile combo set the candidate profile");
    assert_eq!(
        sj["candidate"]["target"], "x86_64-unknown-linux-musl",
        "the target field set the candidate cross-compile target"
    );
    let feats = sj["candidate"]["features"].as_array().unwrap();
    assert!(feats.iter().any(|f| f == "gui"), "the toggled `gui` feature is on the candidate");

    // The illegal combo greys out — decided by validate(), not reflection.
    let greyed = sj["greyed"].as_array().unwrap();
    assert!(greyed.iter().any(|g| g == "minimal"), "minimal is exclusive with embedded → greyed: {greyed:?}");
    assert!(greyed.iter().any(|g| g == "full"), "full is exclusive with embedded → greyed: {greyed:?}");
    assert!(!greyed.iter().any(|g| g == "gui"), "gui is orthogonal → NOT greyed");

    // The live banner reads valid.
    assert_eq!(sj["valid"], true, "embedded+gui is a valid build point");
    assert!(
        harness.query_all(By::new().label_contains("valid")).count() >= 1,
        "the valid banner renders"
    );

    // (3) press Build → assemble(). Drive the same path the Build button calls.
    let assembled = thing.borrow_mut().assemble().expect("assemble delegates to the leaf seam");
    assert_eq!(assembled.kind(), ArtifactKind::Exe, "an exe candidate assembles to an Exe output");
    harness.run();

    let sj = thing.borrow().state_json();
    let last = &sj["last_assembled"];
    assert!(!last.is_null(), "the Assembled receipt surfaces in state_json (RED if the seam is unwired)");
    assert_eq!(last["output_kind"], "exe", "receipt output kind is exe");
    assert_eq!(last["artifact_count"], 1, "one produced artifact");

    // The build-info LINEAGE is present: a provenance row + an artifact row whose
    // content_id is a real hash (the join key across composite builds).
    let lineage = last["lineage"].as_array().unwrap();
    assert!(lineage.iter().any(|r| r["kind"] == "provenance"), "a provenance lineage row is present");
    let artifact_row = lineage
        .iter()
        .find(|r| r["kind"] == "artifact")
        .expect("an artifact lineage row is present");
    let cid = artifact_row["content_id"].as_str().unwrap_or_default();
    assert_eq!(cid.len(), 64, "the artifact row carries a real sha256 content_id: {cid:?}");

    // The receipt reaches the pixels too (the Build result panel renders).
    assert!(
        harness.query_all(By::new().label_contains("Assembled")).count() >= 1,
        "the Assembled result panel renders after a build"
    );

    // The reused leaf card model saw the success (BuildSucceeded fed back).
    let card = sj["cards"]["cards"]
        .as_array()
        .unwrap()
        .iter()
        .find(|c| c["name"] == "embedded")
        .unwrap();
    assert_eq!(card["status"]["kind"], "built", "the reused card model reflects the built status");

    // ── functional_status matrix rows (LAW: recorded as a matrix row) ──
    // No-op unless built with `--features testmatrix`; a real row under `nornir test`.
    facett_core::testmatrix::emit(
        "facett-buildthing::BuildThing",
        "greys_illegal_combo",
        greyed.iter().any(|g| g == "full") && greyed.iter().any(|g| g == "minimal"),
        &format!("greyed={greyed:?}"),
    );
    facett_core::testmatrix::emit(
        "facett-buildthing::BuildThing",
        "build_surfaces_lineage",
        last["output_kind"] == "exe" && cid.len() == 64,
        &format!("output_kind={} content_id_len={}", last["output_kind"], cid.len()),
    );
}

/// The facet shell is a real `dyn Facet` — headless render draws geometry and its
/// `state_json` matches the wrapped thing (the thin-shell delegate).
#[test]
fn facet_shell_delegates_present_and_state() {
    use facett_buildthing::BuildThingFacet;
    let mut facet = BuildThingFacet::from_set(
        "holger",
        BuildVariantSet::from_toml_str(HOLGER).unwrap(),
    );
    let report = facett_core::harness::headless_render(&mut facet);
    assert!(report.drew(), "the facet shell renders real geometry");
    assert_eq!(facet.state_json()["component"], "facett-buildthing::BuildThing");
    assert_eq!(facet.title(), "Build");
}