tests/robot_ui_app.rs
//! holger-server-ui **real robot-UI** cell — drives the *actual egui app* through
//! `egui_kittest` (AccessKit clicks) with the reusable `nornir-robotui`
//! framework, and asserts the app's observable `state_json()` after each click.
//! Each `(action → expected)` step becomes a `nornir-testmatrix` row, so a robot
//! run auto-clicks the holger UI and **fills the nornir test matrix** — nornir
//! sees holger's UI tests.
//!
//! Gated behind `gui` (the egui app only compiles there). Run:
//! `cargo test -p holger-server-ui --features gui --test robot_ui_app`.
//!
//! LAW 1 (inject+assert) + LAW 2 (no empty data): the app is seeded with REAL
//! repositories, so the Repos count and the 3D graph carry populated data — an
//! empty graph would go RED, not green.
#![cfg(feature = "gui")]
use std::sync::Arc;
use egui_kittest::Harness;
use holger_server_ui::app::HolgerUiApp;
use holger_server_ui::data::UiData;
use nornir_robotui::{status, Action, Assert, Observable, RobotSession, Scenario, Step};
use serde_json::{json, Value};
use server_lib::exposed::fast_routes::FastRoutes;
use server_lib::LocalHolger;
use traits::{
ArchiveInfo, ArtifactEntry, ArtifactFormat, ArtifactId, HolgerObject, RepositoryBackendTrait,
};
/// A present-but-empty repository backend — enough that the UI lists real repos
/// (so `repo_count > 0`, LAW 2) without depending on any ecosystem's on-disk
/// format. The surface under test is the egui app + its `state_json`.
struct StubRepo {
name: &'static str,
writable: bool,
}
impl StubRepo {
/// A read-only stub repo.
fn ro(name: &'static str) -> Self {
Self { name, writable: false }
}
/// A writable stub repo (so the server's derived profile is dynamic).
fn rw(name: &'static str) -> Self {
Self { name, writable: true }
}
}
impl RepositoryBackendTrait for StubRepo {
fn name(&self) -> &str {
self.name
}
fn format(&self) -> ArtifactFormat {
ArtifactFormat::Rust
}
fn is_writable(&self) -> bool {
self.writable
}
fn fetch(&self, _id: &ArtifactId) -> anyhow::Result<Option<Vec<u8>>> {
Ok(None)
}
fn put(&self, _id: &ArtifactId, _data: &[u8]) -> anyhow::Result<()> {
Ok(())
}
fn list(&self, _name_filter: Option<&str>, _limit: usize) -> anyhow::Result<Vec<ArtifactEntry>> {
Ok(Vec::new())
}
fn archive_files(&self, _prefix: Option<&str>) -> anyhow::Result<Vec<String>> {
Ok(Vec::new())
}
fn archive_info(&self) -> anyhow::Result<ArchiveInfo> {
Ok(ArchiveInfo {
file_count: 0,
total_uncompressed_bytes: 0,
archive_path: self.name.to_string(),
})
}
fn handle_http2_request(
&self,
_method: &str,
_suburl: &str,
_body: &[u8],
) -> anyhow::Result<(u16, Vec<(String, String)>, Vec<u8>)> {
Ok((404, Vec::new(), Vec::new()))
}
}
/// A data-returning repository backend: real artifact listings, an archive with
/// stats + files, and a `fetch` that resolves `serde 1.0.0`. Writable so the
/// derived profile is dynamic. `vuln` swaps in a corpus carrying the demo
/// openssl advisory so the security scan blocks.
struct DataRepo {
name: &'static str,
vuln: bool,
}
impl RepositoryBackendTrait for DataRepo {
fn name(&self) -> &str {
self.name
}
fn format(&self) -> ArtifactFormat {
ArtifactFormat::Rust
}
fn is_writable(&self) -> bool {
true
}
fn fetch(&self, id: &ArtifactId) -> anyhow::Result<Option<Vec<u8>>> {
match (id.name.as_str(), id.version.as_str()) {
// serde carries a hosted SBOM + properties (see `SbomHolger`); tokio
// resolves to bytes but has NEITHER, so it drives the empty inverses.
("serde", "1.0.0") => Ok(Some(b"SERDE_CRATE_BYTES".to_vec())),
("tokio", "1.2.0") => Ok(Some(b"TOKIO_CRATE_BYTES_1234".to_vec())),
_ => Ok(None),
}
}
fn put(&self, _id: &ArtifactId, _data: &[u8]) -> anyhow::Result<()> {
Ok(())
}
fn list(&self, name_filter: Option<&str>, _limit: usize) -> anyhow::Result<Vec<ArtifactEntry>> {
let all = vec![
ArtifactEntry {
id: ArtifactId { namespace: None, name: "serde".into(), version: "1.0.0".into() },
size_bytes: 17,
content_type: "application/octet-stream".into(),
},
ArtifactEntry {
id: ArtifactId { namespace: None, name: "tokio".into(), version: "1.2.0".into() },
size_bytes: 9,
content_type: "application/octet-stream".into(),
},
];
Ok(match name_filter {
Some(f) => all.into_iter().filter(|e| e.id.name.contains(f)).collect(),
None => all,
})
}
fn archive_files(&self, prefix: Option<&str>) -> anyhow::Result<Vec<String>> {
let files = if self.vuln {
vec![
"crates/serde-1.0.0.crate".to_string(),
"crates/openssl-0.10.55.crate".to_string(),
"index/config.json".to_string(),
]
} else {
vec![
"crates/serde-1.0.0.crate".to_string(),
"crates/tokio-1.2.0.crate".to_string(),
"index/config.json".to_string(),
]
};
Ok(match prefix {
Some(p) => files.into_iter().filter(|f| f.starts_with(p)).collect(),
None => files,
})
}
fn archive_info(&self) -> anyhow::Result<ArchiveInfo> {
Ok(ArchiveInfo {
file_count: 3,
total_uncompressed_bytes: 4242,
archive_path: self.name.to_string(),
})
}
fn handle_http2_request(
&self,
_method: &str,
_suburl: &str,
_body: &[u8],
) -> anyhow::Result<(u16, Vec<(String, String)>, Vec<u8>)> {
Ok((404, Vec::new(), Vec::new()))
}
}
/// An app over a single data-returning repo (Browse/Archive/Artifact/Security
/// have real data to render).
fn data_app(vuln: bool) -> HolgerUiApp {
let repos: Vec<(String, Arc<dyn RepositoryBackendTrait>)> =
vec![("rust-data".to_string(), Arc::new(DataRepo { name: "rust-data", vuln }))];
app_over(repos)
}
/// A real holger-server-ui app over a LocalHolger seeded with three repositories, with
/// the splash skipped so the robot starts on the tabs.
fn seeded_app() -> HolgerUiApp {
// rust-dev is writable so the server's derived profile is *dynamic* (the
// default the tool-driving tests exercise); cache + sparring are read-only
// (so the Upload / static-mode tests have read-only repos to select).
let repos: Vec<(String, Arc<dyn RepositoryBackendTrait>)> = vec![
("rust-dev".to_string(), Arc::new(StubRepo::rw("rust-dev"))),
("cache".to_string(), Arc::new(StubRepo::ro("cache"))),
("sparring".to_string(), Arc::new(StubRepo::ro("sparring"))),
];
app_over(repos)
}
/// Build a real holger-server-ui app over a `LocalHolger` backed by the given repos,
/// with the splash skipped so the robot starts on the tabs. The server-derived
/// profile (read-only iff no repo is writable) drives the initial static mode.
fn app_over(repos: Vec<(String, Arc<dyn RepositoryBackendTrait>)>) -> HolgerUiApp {
let holger: Arc<dyn HolgerObject> = Arc::new(LocalHolger::new(FastRoutes::new(repos)));
let data = UiData::new(holger).expect("runtime");
let mut app = HolgerUiApp::new(data);
app.skip_splash();
app
}
/// A `HolgerObject` transport that wraps a real [`LocalHolger`] and adds the ONE
/// thing `LocalHolger` leaves at its trait default: a **hosted SBOM** and **custom
/// properties** for a coordinate. `LocalHolger::fetch_sbom` / `get_properties` return
/// `None` / empty (the routes carry only bytes), so without this the SBOM / property
/// surface could never render POPULATED data (LAW 2). `serde 1.0.0` carries both; any
/// other coordinate carries neither (the empty inverse). Everything else delegates to
/// the inner transport unchanged.
struct SbomHolger {
inner: Arc<dyn HolgerObject>,
}
#[async_trait::async_trait]
impl HolgerObject for SbomHolger {
async fn fetch(&self, repository: &str, id: &ArtifactId) -> anyhow::Result<Option<Vec<u8>>> {
self.inner.fetch(repository, id).await
}
async fn put(&self, repository: &str, id: &ArtifactId, data: &[u8]) -> anyhow::Result<()> {
self.inner.put(repository, id, data).await
}
async fn list_repositories(&self) -> anyhow::Result<Vec<traits::RepositoryInfo>> {
self.inner.list_repositories().await
}
async fn list_artifacts(
&self,
repository: &str,
name_filter: Option<String>,
limit: u32,
page_token: Option<String>,
) -> anyhow::Result<(Vec<ArtifactEntry>, String)> {
self.inner.list_artifacts(repository, name_filter, limit, page_token).await
}
async fn list_archive_files(
&self,
repository: &str,
prefix: Option<String>,
) -> anyhow::Result<Vec<String>> {
self.inner.list_archive_files(repository, prefix).await
}
async fn archive_info(&self, repository: &str) -> anyhow::Result<ArchiveInfo> {
self.inner.archive_info(repository).await
}
async fn health(&self) -> anyhow::Result<traits::Health> {
self.inner.health().await
}
async fn server_profile(&self) -> anyhow::Result<traits::ServerProfile> {
self.inner.server_profile().await
}
async fn search(&self, query: &traits::SearchQuery) -> anyhow::Result<traits::SearchResults> {
self.inner.search(query).await
}
async fn fetch_sbom(
&self,
_repository: &str,
id: &ArtifactId,
) -> anyhow::Result<Option<traits::SbomDoc>> {
if id.name == "serde" && id.version == "1.0.0" {
let document =
br#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[{"name":"serde","version":"1.0.0"}]}"#
.to_vec();
let size_bytes = document.len() as i64;
Ok(Some(traits::SbomDoc {
document,
size_bytes,
content_address: "b3:5eede0c0ffee".into(),
}))
} else {
Ok(None)
}
}
async fn get_properties(
&self,
_repository: &str,
id: &ArtifactId,
) -> anyhow::Result<Vec<traits::PropertyKv>> {
if id.name == "serde" && id.version == "1.0.0" {
Ok(vec![
traits::PropertyKv {
key: "license".into(),
values: vec!["MIT".into(), "Apache-2.0".into()],
},
traits::PropertyKv { key: "promoted".into(), values: vec!["prod".into()] },
])
} else {
Ok(Vec::new())
}
}
}
/// An app whose transport serves a real hosted SBOM + properties for `serde 1.0.0`
/// (via [`SbomHolger`]) over a single data-returning repo — so the Artifact tab's
/// SBOM inspector, property list and ⬇ download render POPULATED data, and `tokio`
/// (bytes, no SBOM/props) drives the empty inverses.
fn sbom_app() -> HolgerUiApp {
let repos: Vec<(String, Arc<dyn RepositoryBackendTrait>)> =
vec![("rust-data".to_string(), Arc::new(DataRepo { name: "rust-data", vuln: false }))];
let local: Arc<dyn HolgerObject> = Arc::new(LocalHolger::new(FastRoutes::new(repos)));
let holger: Arc<dyn HolgerObject> = Arc::new(SbomHolger { inner: local });
let data = UiData::new(holger).expect("runtime");
let mut app = HolgerUiApp::new(data);
app.skip_splash();
app
}
/// Newtype so we can implement the foreign `Observable` trait (orphan rule); a
/// one-line delegate to the app's own `state_json()`.
struct UiApp(HolgerUiApp);
impl Observable for UiApp {
fn state_json(&self) -> Value {
self.0.state_json()
}
}
/// **The declarative shell (facett-scene), robot-driven.** The holger shell is now
/// a `facett_app::scene::Scene` — a menubar over a `Pane::tabs` of eleven foldable slot
/// leaves — rendered through facett-app's own TabView/Router/menu primitives, DRIVING
/// the existing tab renderers. This asserts the scene as DATA (`/scene`): the pane
/// tree, all-foldable leaves, the six menu groups; that clicking a **scene TabView
/// header** switches the active tab (the strip is the scene's, not a hand-rolled
/// `selectable_value`); and that folding a tab body is observable. RED-when-broken if
/// the scene wiring, the tab bridge, or the fold state ever drops.
#[test]
fn robot_drives_scene_shell_tabs_and_folds() {
let harness = Harness::builder()
.with_size(egui::Vec2::new(1100.0, 720.0))
.build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
let mut robot = RobotSession::new(harness, "holger-server-ui");
robot.step();
// The shell is declared as data: the root tabbed shell pane + one foldable leaf per
// tab, and the six grouped menus (View/Operate/Repositories/Action/Look/Help).
let s = robot.state();
let panes: Vec<String> = s["scene"]["pane_names"]
.as_array()
.expect("scene reports its pane tree")
.iter()
.map(|v| v.as_str().unwrap_or("").to_string())
.collect();
assert!(panes.contains(&"holger-shell".to_string()), "root master/detail split pane: {panes:?}");
assert!(panes.contains(&"holger-workspace".to_string()), "the tabbed workspace pane: {panes:?}");
assert!(panes.contains(&"holger-rail".to_string()), "the registry rail pane: {panes:?}");
assert!(panes.contains(&"graph_pane".to_string()), "a tab body pane: {panes:?}");
// One foldable body per registered tab — the roster the app reports under `/tabs` —
// PLUS the rail and its two panes (the master side folds too).
let tab_count = s["tabs"].as_array().expect("the shell reports its tab roster").len();
// …plus the two HALVES of every tab body that decomposed into its own draggable
// master/detail split (the tab INTERNALS adopted the scene too, not just the shell).
let split_bodies = s["scene"]["split_bodies"]
.as_array()
.expect("the shell reports which tab bodies decomposed")
.len();
assert_eq!(
s["scene"]["foldable"].as_array().map(|a| a.len()),
Some(tab_count + 3 + 2 * split_bodies),
"every one of the {tab_count} tab bodies (+ {split_bodies} × 2 split halves) + the \
rail and its 2 panes are foldable: {s}"
);
// The shell carries REAL structure: two draggable dividers and one drawer — and then
// one INTERNAL divider per decomposed tab body, in tab order.
let splits: Vec<String> = s["scene"]["draggable_splits"]
.as_array()
.expect("the divider roster")
.iter()
.map(|v| v.as_str().unwrap_or("").to_string())
.collect();
let mut expected = vec!["holger-shell".to_string(), "holger-rail".to_string()];
expected.extend(
s["scene"]["split_bodies"].as_array().unwrap().iter().map(|v| v.as_str().unwrap_or("").to_string()),
);
assert_eq!(splits, expected, "the shell's two dividers, then one per decomposed body: {s}");
assert!(split_bodies > 0, "at least one tab body decomposed: {s}");
// FIVE grouped menus. There were six: a top-level **Look** submenu listed every
// theme preset. Appearance is a local preference, so it moved into the ⚙ Settings
// pane and the root menubar kept only navigation + actions (Repositories · Action
// · Help).
assert_eq!(s["scene"]["menus"], json!(5), "five grouped menus: {s}");
assert_eq!(s["scene"]["render"]["scene"], json!("holger"), "the scene rendered: {s}");
// Clicking a scene TabView header switches the active tab (the strip is the
// scene's own TabView, addressed by its title).
robot.click_label("Browse").expect("scene tab header 'Browse'");
robot.step();
assert_eq!(robot.state()["tab"], json!("browse"), "the scene tab strip drives the tab");
assert_eq!(
robot.state()["scene"]["active_tab"], json!(2),
"the scene's active_tab tracks the Browse leaf (index 2)"
);
robot.click_label("Graph").expect("scene tab header 'Graph'");
robot.step();
assert_eq!(robot.state()["tab"], json!("graph"), "and back to Graph via the strip");
// Folding a tab body is observable in the scene state (the fold interaction).
assert_eq!(
robot.state()["scene"]["folded"]["graph_pane"], json!(false),
"the graph tab body starts unfolded"
);
robot.app_mut().0.toggle_scene_fold("graph_pane");
robot.step();
assert_eq!(
robot.state()["scene"]["folded"]["graph_pane"], json!(true),
"folding the tab body is observable in state_json"
);
}
/// The robot auto-clicks the holger UI: Graph tab (3D graph builds from real
/// repos), Tools menu (selects the Skíðblaðnir deploy function), and the static
/// "rigged for silent running" toggle (writes disabled) — every step asserted
/// against the live `state_json` and emitted as a matrix row.
#[test]
fn robot_drives_tabs_tools_and_static_mode() {
let harness = Harness::builder()
.with_size(egui::Vec2::new(1100.0, 720.0))
.build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
let mut robot = RobotSession::new(harness, "holger-server-ui");
let scenario = Scenario::new("holger-server-ui-robot")
.step(
Step::new("repos populated (LAW2: no empty data)").assert(Assert::State {
pointer: "/repo_count".into(),
expected: json!(3),
}),
)
.step(
Step::new("click Graph → tab == graph")
.action(Action::ClickLabel("Graph".into()))
.assert(Assert::State {
pointer: "/tab".into(),
expected: json!("graph"),
}),
)
.step(
Step::new("3D graph built from real repos (LAW2)").assert(Assert::State {
pointer: "/graph_built".into(),
expected: json!(true),
}),
)
.step(Step::new("graph drew widgets").assert(Assert::Drew))
.step(
Step::new("click Tools → tab == tools")
.action(Action::ClickLabel("Tools".into()))
.assert(Assert::State {
pointer: "/tab".into(),
expected: json!("tools"),
}),
)
.step(
Step::new("select Deploy (Skíðblaðnir) → tool == deploy")
.action(Action::ClickLabel("4 · deploy · Skíðblaðnir".into()))
.assert(Assert::State {
pointer: "/tool".into(),
expected: json!("deploy"),
}),
)
.step(
Step::new("engage static silent-running → static_mode == true")
.action(Action::ClickLabel("⚓ static".into()))
.assert(Assert::State {
pointer: "/static_mode".into(),
expected: json!(true),
}),
)
.step(
Step::new("writes disabled in static → write_enabled == false").assert(Assert::State {
pointer: "/write_enabled".into(),
expected: json!(false),
}),
);
// Run the scenario: each step is driven on the real app and becomes a matrix row.
let rows = robot.run_scenario(&scenario);
assert_eq!(rows.len(), 8, "one matrix row per robot step");
for r in &rows {
assert_eq!(
r.status,
status::PASS,
"robot step {:?} should pass; message: {}",
r.test_name,
r.message
);
}
}
/// The robot drives the NEW **Lineage** tab (the live proxy-cache-filling facet):
/// open it (3D fill renders + the demo backlog begins dripping through the live
/// feed), assert it drew, toggle to the time-helix companion and back, and assert
/// the render mode each step — every assertion against the live `state_json`, and
/// the growing lineage graph observed as DATA (`/lineage/total_events` climbs as
/// the cache fills).
#[test]
fn robot_drives_lineage_tab() {
let harness = Harness::builder()
.with_size(egui::Vec2::new(1100.0, 720.0))
.build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
let mut robot = RobotSession::new(harness, "holger-server-ui");
let scenario = Scenario::new("holger-server-ui-lineage-robot")
.step(
Step::new("open Lineage → tab == lineage")
.action(Action::ClickLabel("Lineage".into()))
.assert(Assert::State { pointer: "/tab".into(), expected: json!("lineage") }),
)
.step(
Step::new("default mode is the rotating 3D fill")
.assert(Assert::State { pointer: "/lineage_mode".into(), expected: json!("graph3d") }),
)
.step(Step::new("3D cache-fill graph drew").assert(Assert::Drew))
.step(
Step::new("toggle time-helix companion → mode == helix")
.action(Action::ClickLabel("🌀 time helix".into()))
.assert(Assert::State { pointer: "/lineage_mode".into(), expected: json!("helix") }),
)
.step(Step::new("time-helix drew").assert(Assert::Drew))
.step(
Step::new("back to 3D fill → mode == graph3d")
.action(Action::ClickLabel("◉ 3D fill".into()))
.assert(Assert::State { pointer: "/lineage_mode".into(), expected: json!("graph3d") }),
)
.step(Step::new("cache kept filling (drew again)").assert(Assert::Drew));
let rows = robot.run_scenario(&scenario);
assert_eq!(rows.len(), 7, "one matrix row per robot step");
for r in &rows {
assert_eq!(r.status, status::PASS, "robot step {:?} should pass; message: {}", r.test_name, r.message);
}
// The cache filled as the tab ran: the live feed drip grew the lineage graph.
let filled = robot.app().0.state_json();
let events = filled["lineage"]["total_events"].as_u64().unwrap_or(0);
let artifacts = filled["lineage"]["nodes"]
.as_array()
.map(|a| a.iter().filter(|n| n["kind"] == "artifact").count())
.unwrap_or(0);
assert!(events >= 1, "the live cache-fill produced arrivals: total_events={events}");
assert!(artifacts >= 1, "cached artifact nodes popped in: {artifacts}");
}
/// The robot drives the NEW operator Tools panes wired this round: Tool #1
/// holger-ds (scale slider), Tool #3 Móðguðr security (scan the selected repo →
/// a real Pass verdict over an empty corpus), and Tool #5 airgap populate (the
/// 5th menu item). Every step asserts the live `state_json` — the panes as DATA.
#[test]
fn robot_drives_tools_1_3_5_panes() {
let harness = Harness::builder()
.with_size(egui::Vec2::new(1100.0, 720.0))
.build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
let mut robot = RobotSession::new(harness, "holger-server-ui");
let scenario = Scenario::new("holger-server-ui-tools-robot")
.step(
Step::new("open Tools")
.action(Action::ClickLabel("Tools".into()))
.assert(Assert::State { pointer: "/tab".into(), expected: json!("tools") }),
)
.step(
Step::new("select Tool #1 holger-ds → tool == holger-ds")
.action(Action::ClickLabel("1 · holger-ds".into()))
.assert(Assert::State { pointer: "/tool".into(), expected: json!("holger-ds") }),
)
.step(
Step::new("holger-ds default scale == micro")
.assert(Assert::State { pointer: "/holger_ds/scale".into(), expected: json!("micro") }),
)
.step(
Step::new("select Tool #3 security → tool == security")
.action(Action::ClickLabel("3 · security · Móðguðr".into()))
.assert(Assert::State { pointer: "/tool".into(), expected: json!("security") }),
)
.step(
Step::new("scan selected repo → Pass verdict over the empty corpus")
.action(Action::ClickLabel("Scan selected repo".into()))
.assert(Assert::State { pointer: "/security/decision".into(), expected: json!("pass") }),
)
.step(
Step::new("select Tool #5 airgap → tool == airgap")
.action(Action::ClickLabel("5 · airgap populate".into()))
.assert(Assert::State { pointer: "/tool".into(), expected: json!("airgap") }),
)
.step(
Step::new("airgap default target == holger")
.assert(Assert::State { pointer: "/airgap/target".into(), expected: json!("holger") }),
)
.step(
Step::new("select Tool #2 migrate → tool == migrate")
.action(Action::ClickLabel("2 · migrate / suck".into()))
.assert(Assert::State { pointer: "/tool".into(), expected: json!("migrate") }),
)
.step(
Step::new("migrate default source == nexus")
.assert(Assert::State { pointer: "/migrate/source".into(), expected: json!("nexus") }),
);
let rows = robot.run_scenario(&scenario);
assert_eq!(rows.len(), 9, "one matrix row per robot step");
for r in &rows {
assert_eq!(r.status, status::PASS, "robot step {:?}: {}", r.test_name, r.message);
}
}
/// The buttons now WORK: the robot clicks each operator tool's run button and
/// asserts its REAL action ran (as DATA in `state_json`) — Tool #1 holger-ds
/// produced a throughput result over the package protocol, Tool #2 migrate was
/// invoked (and surfaced its clear "url required" validation for the empty demo
/// url), Tool #3 security produced a Pass verdict, Tool #5 airgap produced a
/// dry-run plan/result — plus the splash carries a version string.
#[test]
fn robot_runs_each_tool_real_action_and_sees_version() {
let harness = Harness::builder()
.with_size(egui::Vec2::new(1100.0, 720.0))
.build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
let mut robot = RobotSession::new(harness, "holger-server-ui");
// The version string is presented (under the splash wordmark + in the header).
let v = json!(holger_server_ui::app::HOLGER_UI_VERSION);
assert!(
holger_server_ui::app::HOLGER_UI_VERSION.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false),
"version looks like a version: {}",
holger_server_ui::app::HOLGER_UI_VERSION
);
let scenario = Scenario::new("holger-server-ui-run-tools")
.step(
Step::new("splash carries a version string")
.assert(Assert::State { pointer: "/version".into(), expected: v }),
)
.step(
Step::new("open Tools")
.action(Action::ClickLabel("Tools".into()))
.assert(Assert::State { pointer: "/tab".into(), expected: json!("tools") }),
)
// Tool #1 holger-ds — RUN the benchmark over the package protocol.
.step(
Step::new("select holger-ds")
.action(Action::ClickLabel("1 · holger-ds".into()))
.assert(Assert::State { pointer: "/tool".into(), expected: json!("holger-ds") }),
)
// The RUN button exists only under `bench` (⇒ `gui-embedded`): the driver
// stands up a REAL holger HTTP gateway in this process, which is exactly what
// the thin-client rule keeps out of the shipped `holger-server-ui`. The thin build
// still renders the pane, its config and the CLI command — the step below
// asserts the ACTION, so it rides on the same feature the button does.
;
#[cfg(feature = "bench")]
let scenario = scenario.step(
Step::new("Run benchmark → a REAL throughput result")
.action(Action::ClickLabel("Run benchmark… (over the package protocol)".into()))
.assert(Assert::State { pointer: "/holger_ds/result/ran".into(), expected: json!(true) }),
);
let scenario = scenario
// Tool #2 migrate — RUN invokes the connector; empty demo url → clear msg.
.step(
Step::new("select migrate")
.action(Action::ClickLabel("2 · migrate / suck".into()))
.assert(Assert::State { pointer: "/tool".into(), expected: json!("migrate") }),
)
.step(
Step::new("Run migrate → connector invoked, clear url validation")
.action(Action::ClickLabel("Run migrate… (via holger-agent)".into()))
.assert(Assert::State { pointer: "/migrate/result/ran".into(), expected: json!(false) }),
)
// Tool #3 security — scan the selected repo → Pass verdict.
.step(
Step::new("select security")
.action(Action::ClickLabel("3 · security · Móðguðr".into()))
.assert(Assert::State { pointer: "/tool".into(), expected: json!("security") }),
)
.step(
Step::new("Scan → Pass verdict over the empty corpus")
.action(Action::ClickLabel("Scan selected repo".into()))
.assert(Assert::State { pointer: "/security/decision".into(), expected: json!("pass") }),
)
// Tool #5 airgap — the runnable demo default is a dry-run plan.
.step(
Step::new("select airgap")
.action(Action::ClickLabel("5 · airgap populate".into()))
.assert(Assert::State { pointer: "/tool".into(), expected: json!("airgap") }),
)
.step(
Step::new("Run populate (dry-run) → a real plan/result")
.action(Action::ClickLabel("Run populate… (dry-run plan)".into()))
.assert(Assert::State { pointer: "/airgap/run/ran".into(), expected: json!(true) }),
);
let rows = robot.run_scenario(&scenario);
// Ten steps under `bench` (⇒ `gui-embedded`), nine without: the holger-ds RUN
// button only exists where the in-process gateway driver does.
let expected_steps = if cfg!(feature = "bench") { 10 } else { 9 };
assert_eq!(rows.len(), expected_steps, "one matrix row per robot step");
for r in &rows {
assert_eq!(r.status, status::PASS, "robot step {:?}: {}", r.test_name, r.message);
}
// The migrate run carried a clear url validation (not a silent no-op).
let s = robot.state();
assert!(
s["migrate"]["result"]["error"].as_str().unwrap_or("").contains("url"),
"migrate surfaced a clear url-required message: {s}"
);
// The holger-ds result is real throughput over the protocol — only where the
// driver is compiled in (`bench` ⇒ `gui-embedded`); the thin client shows the
// pane and the CLI command and stands up no gateway of its own.
#[cfg(feature = "bench")]
assert!(
s["holger_ds"]["result"]["ops"].as_u64().unwrap_or(0) > 0,
"holger-ds completed GETs over the package protocol: {s}"
);
// The airgap dry-run plan lists the populate driver invocation.
assert!(
s["airgap"]["run"]["plan"].as_str().unwrap_or("").contains("airgap populate"),
"airgap produced a real plan: {s}"
);
}
/// Render-time introspection: each of the 5 Tools panes self-reports — the moment
/// it is shown — that it drew a usable operator surface (`tools_pane.ok == true`),
/// mirroring the gRPC handlers' server-side `functional_status` markers so
/// `nornir test --features testmatrix` SEES the operator surfaces fire (a per-frame
/// `holger-server-ui/tools/<pane>` `pane_rendered` row) without a human at the GUI. The
/// **RED-on-empty** contract is driven too: engaging static read-only silences the
/// write-tools (#2–#5) to a disabled notice, so the selected pane flips to
/// `ok == false`. Every step asserts the live `state_json` — the render report as
/// DATA — and becomes a nornir-testmatrix row.
#[test]
fn robot_tools_panes_self_report_render_status() {
let harness = Harness::builder()
.with_size(egui::Vec2::new(1100.0, 720.0))
.build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
let mut robot = RobotSession::new(harness, "holger-server-ui");
let scenario = Scenario::new("holger-server-ui-tools-render-status")
.step(
Step::new("open Tools")
.action(Action::ClickLabel("Tools".into()))
.assert(Assert::State { pointer: "/tab".into(), expected: json!("tools") }),
)
// #1 holger-ds — always draws its config (never blank), ok on render.
.step(
Step::new("#1 holger-ds pane identifies itself")
.action(Action::ClickLabel("1 · holger-ds".into()))
.assert(Assert::State { pointer: "/tools_pane/pane".into(), expected: json!("holger-ds") }),
)
.step(
Step::new("#1 holger-ds self-reports ok=true on render")
.assert(Assert::State { pointer: "/tools_pane/ok".into(), expected: json!(true) }),
)
// #2 migrate.
.step(
Step::new("#2 migrate pane identifies itself")
.action(Action::ClickLabel("2 · migrate / suck".into()))
.assert(Assert::State { pointer: "/tools_pane/pane".into(), expected: json!("migrate") }),
)
.step(
Step::new("#2 migrate self-reports ok=true on render")
.assert(Assert::State { pointer: "/tools_pane/ok".into(), expected: json!(true) }),
)
// #3 security · Móðguðr.
.step(
Step::new("#3 security pane identifies itself")
.action(Action::ClickLabel("3 · security · Móðguðr".into()))
.assert(Assert::State { pointer: "/tools_pane/pane".into(), expected: json!("security") }),
)
.step(
Step::new("#3 security self-reports ok=true on render")
.assert(Assert::State { pointer: "/tools_pane/ok".into(), expected: json!(true) }),
)
// #4 deploy · Skíðblaðnir.
.step(
Step::new("#4 deploy pane identifies itself")
.action(Action::ClickLabel("4 · deploy · Skíðblaðnir".into()))
.assert(Assert::State { pointer: "/tools_pane/pane".into(), expected: json!("deploy") }),
)
.step(
Step::new("#4 deploy self-reports ok=true on render")
.assert(Assert::State { pointer: "/tools_pane/ok".into(), expected: json!(true) }),
)
// #5 airgap populate.
.step(
Step::new("#5 airgap pane identifies itself")
.action(Action::ClickLabel("5 · airgap populate".into()))
.assert(Assert::State { pointer: "/tools_pane/pane".into(), expected: json!("airgap") }),
)
.step(
Step::new("#5 airgap self-reports ok=true on render")
.assert(Assert::State { pointer: "/tools_pane/ok".into(), expected: json!(true) }),
)
// RED-on-empty: engage static read-only → the selected write-tool blanks to
// a disabled notice → the pane self-reports ok=false (a real red-when-broken
// branch: were the static gate dropped, this flips green and fails).
.step(
Step::new("engage static → read-only silent running")
.action(Action::ClickLabel("⚓ static".into()))
.assert(Assert::State { pointer: "/static_mode".into(), expected: json!(true) }),
)
.step(
Step::new("RED on empty: airgap pane blanked under static → ok=false")
.assert(Assert::State { pointer: "/tools_pane/ok".into(), expected: json!(false) }),
);
let rows = robot.run_scenario(&scenario);
assert_eq!(rows.len(), 13, "one matrix row per robot step");
for r in &rows {
assert_eq!(r.status, status::PASS, "robot step {:?}: {}", r.test_name, r.message);
}
// The pane detail carries a real one-liner (not a placeholder) — under static
// the write-tool self-reports the blanked reason, the RED-on-empty evidence.
let s = robot.state();
assert_eq!(
s["tools_pane"]["detail"],
json!("static-disabled"),
"the blanked pane names why it went RED: {s}"
);
}
/// The DEMO SELLPOINT: the robot drives the neon 3D graph. Navigating to the
/// Graph tab builds the holger graph (server → repositories → upstream/artifact
/// nodes) under `DecorPolicy::Full` (neon glow/shockwave/dim); then clicking a
/// repository node lights its subgraph and dims the rest. Every claim is asserted
/// against the live `state_json["graph"]` — the lit subgraph as DATA, not pixels
/// (LAW 2: real populated data, an empty subgraph would go RED).
#[test]
fn robot_clicks_repo_node_and_lights_its_subgraph() {
let harness = Harness::builder()
.with_size(egui::Vec2::new(1100.0, 720.0))
.build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
let mut robot = RobotSession::new(harness, "holger-server-ui");
// Navigate to the Graph tab and let the neon graph build from real repos.
robot.click_label("Graph").expect("Graph tab is clickable");
robot.step();
// The graph is built, neon-lit (Full effects), with the server + 3 repos at
// least (LAW 2: populated). Nothing selected yet ⇒ nothing dimmed.
let s = robot.state();
assert_eq!(s["graph_built"], json!(true), "graph built from real repos: {s}");
assert_eq!(
s["graph"]["effects_policy"],
json!("full"),
"the beauty is ON — DecorPolicy::Full: {s}"
);
assert_eq!(s["graph"]["palette"], json!("neon"), "neon palette active: {s}");
let nodes = s["graph"]["nodes"].as_u64().unwrap_or(0);
assert!(nodes >= 4, "server + 3 repositories (≥4 nodes): {s}");
// The HOLGER NEON PALETTE maps every node to a kind (server/repository/…).
let kinds = s["graph"]["kinds"].as_array().expect("per-node kind palette");
assert_eq!(kinds.len() as u64, nodes, "one kind per node: {s}");
assert_eq!(kinds[0], json!("server"), "node 0 is the holger server anchor: {s}");
assert!(
s["graph"]["selected_node"].is_null(),
"no selection yet → calm default: {s}"
);
assert_eq!(s["graph"]["highlighted_count"], json!(0), "nothing lit yet: {s}");
assert_eq!(s["graph"]["dimmed_alpha"], json!(1.0), "nothing dimmed yet: {s}");
// Click (select) the first repository node — index 1, since node 0 is the
// server. This is the same path the in-view 3D click drives (Graph3D::select);
// the facett kernel lights its subgraph and dims the rest.
robot.app_mut().0.select_graph_node(1);
robot.step();
let s = robot.state();
assert_eq!(s["graph"]["selected_node"], json!(1), "the repo node is selected: {s}");
// The selected node is one of the seeded repositories (node 0 is the server).
let sel_label = s["graph"]["selected_label"].as_str().unwrap_or("");
assert!(
["rust-dev", "cache", "sparring"].contains(&sel_label),
"the selected node is a seeded repository, got {sel_label:?}: {s}"
);
assert!(
s["graph"]["selected_kind"].is_string(),
"the selected node carries a neon-palette kind: {s}"
);
// The lit subgraph is REAL and non-empty (the repo's neighbours light up).
let lit = s["graph"]["highlighted_neighbours"]
.as_array()
.expect("the lit subgraph is an array");
assert!(!lit.is_empty(), "clicking a repo lights a NON-EMPTY subgraph: {s}");
assert!(
s["graph"]["highlighted_count"].as_u64().unwrap_or(0) >= 1,
"≥1 neighbour lit: {s}"
);
// The dim is real: unrelated nodes/edges drop below full alpha.
assert!(
s["graph"]["dimmed_alpha"].as_f64().unwrap_or(1.0) < 1.0,
"selecting a repo dims the unrelated rest: {s}"
);
}
/// T5 (lands WITH B5/P5): the static "silent running" mode is driven off the
/// server-reported profile (`AdminService.ServerProfile`), NOT a local toggle.
/// (a) A server with ONLY read-only repos derives a static/read-only profile, so
/// the app starts static WITHOUT anyone clicking the toggle. (b) A server with a
/// writable repo derives a dynamic profile, so the app starts non-static.
#[test]
fn robot_static_mode_reflects_server_profile() {
// (a) all read-only → server profile is static/read-only → static by default.
let ro: Vec<(String, Arc<dyn RepositoryBackendTrait>)> = vec![
("locked-a".to_string(), Arc::new(StubRepo::ro("locked-a"))),
("locked-b".to_string(), Arc::new(StubRepo::ro("locked-b"))),
];
let app = app_over(ro);
let s = app.state_json();
assert_eq!(s["static_mode"], json!(true), "all-read-only server starts static: {s}");
assert_eq!(s["profile"]["read_only"], json!(true), "server profile is read-only: {s}");
assert_eq!(s["profile"]["loaded"], json!(true), "profile was fetched: {s}");
assert!(
s["profile"]["label"].as_str().unwrap_or("").contains("SILENT RUNNING"),
"profile carries the silent-running label: {s}"
);
assert_eq!(s["profile"]["writable_repo_count"], json!(0), "no writable repos: {s}");
// The badge / look follow the static profile.
assert_eq!(s["look"], json!("skade_vinter"), "static look engaged from the profile: {s}");
// (b) ≥1 writable repo → dynamic profile → NOT static by default.
let rw: Vec<(String, Arc<dyn RepositoryBackendTrait>)> = vec![
("live".to_string(), Arc::new(StubRepo::rw("live"))),
("locked".to_string(), Arc::new(StubRepo::ro("locked"))),
];
let app = app_over(rw);
let s = app.state_json();
assert_eq!(s["static_mode"], json!(false), "a writable server starts dynamic: {s}");
assert_eq!(s["profile"]["read_only"], json!(false), "server profile is dynamic: {s}");
assert_eq!(s["profile"]["writable_repo_count"], json!(1), "one writable repo: {s}");
}
/// T3: Tools #2-#5 (and, after B6, #1 holger-ds) are disabled — non-clickable —
/// in static read-only mode. Direct-drive style (app_mut) so a swallowed click
/// can be asserted as "no change from the prior value". seeded_app starts
/// dynamic (it has a writable repo), so the operator can first select holger-ds,
/// then engage static and watch the other tools stop responding.
#[test]
fn robot_static_mode_disables_other_tools() {
let harness = Harness::builder()
.with_size(egui::Vec2::new(1100.0, 720.0))
.build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
let mut robot = RobotSession::new(harness, "holger-server-ui");
robot.click_label("Tools").expect("Tools tab");
robot.step();
robot.click_label("1 · holger-ds").expect("holger-ds selectable (dynamic)");
robot.step();
assert_eq!(robot.state()["tool"], json!("holger-ds"), "tool == holger-ds");
// Engage static silent-running.
robot.click_label("⚓ static").expect("static toggle");
robot.step();
assert_eq!(robot.state()["static_mode"], json!(true), "static engaged");
assert_eq!(robot.state()["look"], json!("skade_vinter"), "winter look engaged");
// #2-#5 selectables are now disabled: clicking them is swallowed, the tool
// stays holger-ds. (click_label may not even find an enabled target; either
// way the tool must not change.)
for label in ["2 · migrate / suck", "3 · security · Móðguðr", "4 · deploy · Skíðblaðnir", "5 · airgap populate"] {
let _ = robot.click_label(label);
robot.step();
assert_eq!(
robot.state()["tool"],
json!("holger-ds"),
"tool unchanged after swallowed click on disabled {label:?}"
);
}
// Toggle static back off → #2 migrate becomes selectable again.
robot.click_label("⚓ static").expect("static toggle off");
robot.step();
assert_eq!(robot.state()["static_mode"], json!(false), "static disengaged");
robot.click_label("2 · migrate / suck").expect("migrate selectable again (dynamic)");
robot.step();
assert_eq!(robot.state()["tool"], json!("migrate"), "migrate selectable once dynamic");
}
/// T6: clicking a repository row drives selection (the flow every other tab
/// depends on), and the selection propagates downstream (the security scan
/// targets the selected repo). Needs T1 (`/repos` in state_json).
#[test]
fn robot_repo_row_selection_propagates() {
let harness = Harness::builder()
.with_size(egui::Vec2::new(1100.0, 720.0))
.build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
let mut robot = RobotSession::new(harness, "holger-server-ui");
robot.click_label("Repos").expect("Repos tab");
robot.step();
assert_eq!(robot.state()["tab"], json!("repos"));
// FastRoutes sorts the repos to [cache, rust-dev, sparring]; first selected.
assert_eq!(robot.state()["repos"]["selected"], json!(0), "first repo selected by default");
// Drive the row selection (the row name collides with the facett Table cell,
// so a label-addressed click is ambiguous; select_repo runs the same path).
robot.app_mut().0.select_repo(2);
robot.step();
assert_eq!(robot.state()["repos"]["selected"], json!(2), "selecting sparring → index 2");
assert_eq!(robot.state()["repos"]["repos"][2]["name"], json!("sparring"), "index 2 is sparring");
// Propagates downstream: the security scan targets the selected repo.
robot.click_label("Tools").expect("Tools");
robot.step();
robot.click_label("3 · security · Móðguðr").expect("security");
robot.step();
robot.click_label("Scan selected repo").expect("scan");
robot.step();
assert_eq!(
robot.state()["security"]["repository"],
json!("sparring"),
"the scan targets the row the operator selected"
);
}
/// T13: the Status tab is reachable by nav, its refresh button loads health, and
/// the status grid carries real values. Needs T1 (`/status` in state_json).
#[test]
fn robot_status_tab_nav_and_refresh() {
let harness = Harness::builder()
.with_size(egui::Vec2::new(1100.0, 720.0))
.build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
let mut robot = RobotSession::new(harness, "holger-server-ui");
robot.click_label("Graph").expect("Graph tab");
robot.step();
robot.click_label("Status").expect("Status tab");
robot.step();
assert_eq!(robot.state()["tab"], json!("status"));
robot.click_label("⟳ refresh").expect("refresh button");
robot.step();
let s = robot.state();
assert_eq!(s["status"]["loaded"], json!(true), "health loaded: {s}");
assert!(s["status"]["version"].as_str().map(|v| !v.is_empty()).unwrap_or(false), "version present: {s}");
assert_eq!(s["status"]["error"], json!(null), "no error on a healthy server: {s}");
assert_eq!(s["status"]["status"], json!("ok"), "status ok: {s}");
}
/// T19: the Upload flow reflects the selected repo's writability. seeded_app has
/// a writable repo (rust-dev) and read-only repos (cache/sparring). Needs T1
/// (`/upload` section).
#[test]
fn robot_upload_writable_reflects_selected_repo() {
let harness = Harness::builder()
.with_size(egui::Vec2::new(1100.0, 720.0))
.build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
let mut robot = RobotSession::new(harness, "holger-server-ui");
// FastRoutes sorts to [cache(ro), rust-dev(rw), sparring(ro)]; the default
// selection is cache (read-only) → upload disabled.
robot.click_label("Upload").expect("Upload tab");
robot.step();
assert_eq!(robot.state()["tab"], json!("upload"));
assert_eq!(robot.state()["upload"]["writable"], json!(false), "read-only repo → upload disabled");
// Select the writable repo (rust-dev, index 1) → upload enabled, warning gone.
robot.app_mut().0.select_repo(1);
robot.step();
let s = robot.state();
assert_eq!(s["repos"]["repos"][1]["name"], json!("rust-dev"), "index 1 is rust-dev");
assert_eq!(s["upload"]["writable"], json!(true), "writable repo → upload enabled: {s}");
}
/// T4: in static read-only mode the Upload tab is unreachable — selecting it
/// while static redirects to Status, and the disabled Upload nav stays inert.
#[test]
fn robot_static_mode_redirects_upload_to_status() {
let harness = Harness::builder()
.with_size(egui::Vec2::new(1100.0, 720.0))
.build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
let mut robot = RobotSession::new(harness, "holger-server-ui");
// Dynamic by default: Upload is reachable.
robot.click_label("Upload").expect("Upload tab");
robot.step();
assert_eq!(robot.state()["tab"], json!("upload"));
// Engage static → the next frame redirects Upload to Status.
robot.click_label("⚓ static").expect("static toggle");
robot.step();
assert_eq!(robot.state()["static_mode"], json!(true));
assert_eq!(robot.state()["tab"], json!("status"), "static mode forces Upload → Status");
// The disabled (strikethrough) Upload nav is inert: clicking leaves Status.
let _ = robot.click_label("Upload");
robot.step();
assert_eq!(robot.state()["tab"], json!("status"), "disabled Upload nav stays on Status");
}
/// T18: the look-preset switcher is driven by real clicks (not just the direct
/// setter), and the static override wins while engaged then restores the
/// operator's manual preset.
#[test]
fn robot_look_preset_switcher_and_static_override() {
let harness = Harness::builder()
.with_size(egui::Vec2::new(1100.0, 720.0))
.build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
let mut robot = RobotSession::new(harness, "holger-server-ui");
// Default look (collapsed roster — dark/light/device).
assert_eq!(robot.state()["look"], json!("dark"), "default look");
// The preset switcher lives in the ⚙ **Settings** pane now — it is no longer on
// the root chrome bar or in a root "Look" menu — so the robot navigates there
// first, exactly as a human would.
robot.click_label("Settings").expect("Settings tab");
robot.step();
assert_eq!(robot.state()["tab"], json!("settings"), "the Settings pane is open");
robot.click_label("device").expect("device preset label");
robot.step();
assert_eq!(robot.state()["look"], json!("device"), "clicking device switches the look");
// Static override wins.
robot.click_label("⚓ static").expect("static toggle");
robot.step();
assert_eq!(robot.state()["look"], json!("skade_vinter"), "static overrides the preset");
// Disengage → the manual preset (device) is restored.
robot.click_label("⚓ static").expect("static toggle off");
robot.step();
assert_eq!(robot.state()["look"], json!("device"), "manual preset restored after static");
}
/// T15: the Tool #1/#2/#5 config selectables drive real state changes (the egui
/// widget binding, not just the data-layer defaults).
#[test]
fn robot_tool_config_selectables_change_state() {
let harness = Harness::builder()
.with_size(egui::Vec2::new(1100.0, 720.0))
.build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
let mut robot = RobotSession::new(harness, "holger-server-ui");
robot.click_label("Tools").expect("Tools");
robot.step();
// Tool #1 holger-ds scale.
robot.click_label("1 · holger-ds").expect("holger-ds");
robot.step();
robot.click_label("large").expect("large scale");
robot.step();
assert_eq!(robot.state()["holger_ds"]["scale"], json!("large"), "scale → large");
// Tool #2 migrate source.
robot.click_label("2 · migrate / suck").expect("migrate");
robot.step();
robot.click_label("artifactory").expect("artifactory source");
robot.step();
assert_eq!(robot.state()["migrate"]["source"], json!("artifactory"), "source → artifactory");
// Tool #5 airgap target + auth.
robot.click_label("5 · airgap populate").expect("airgap");
robot.step();
robot.click_label("nexus").expect("nexus target");
robot.step();
assert_eq!(robot.state()["airgap"]["target"], json!("nexus"), "target → nexus");
robot.click_label("oidc").expect("oidc auth");
robot.step();
assert_eq!(robot.state()["airgap"]["auth"], json!("oidc"), "auth → oidc");
}
/// T16 (lands WITH B2): the airgap validate() result is a pure state pointer.
/// The runnable demo default is a dry-run plan, so `can_run` is true by default;
/// a push with no creds drives it back to the red "cannot run" branch.
#[test]
fn robot_airgap_validate_can_run_state() {
let harness = Harness::builder()
.with_size(egui::Vec2::new(1100.0, 720.0))
.build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
let mut robot = RobotSession::new(harness, "holger-server-ui");
robot.click_label("Tools").expect("Tools");
robot.step();
robot.click_label("5 · airgap populate").expect("airgap");
robot.step();
// Default is dry-run → validates → can_run true, no error.
let s = robot.state();
assert_eq!(s["airgap"]["dry_run"], json!(true), "demo default is dry-run: {s}");
assert_eq!(s["airgap"]["can_run"], json!(true), "dry-run plan can run: {s}");
assert_eq!(s["airgap"]["validate_error"], json!(null), "no validation error by default: {s}");
// Drive it into the red branch: a real push (dry_run off) with no creds.
{
let app = robot.app_mut();
app.0.airgap_set_dry_run(false);
}
robot.step();
let s = robot.state();
assert_eq!(s["airgap"]["can_run"], json!(false), "push with no creds cannot run: {s}");
assert!(
s["airgap"]["validate_error"].as_str().map(|e| !e.is_empty()).unwrap_or(false),
"the red branch carries a validation error: {s}"
);
}
/// T17 (demo must-have #1): the startup logo splash is shown on frame 1 (tabs
/// hidden) and dismisses on a click.
#[test]
fn robot_startup_splash_dismisses_on_click() {
// Build WITHOUT skip_splash so the splash is live.
let repos: Vec<(String, Arc<dyn RepositoryBackendTrait>)> = vec![
("rust-dev".to_string(), Arc::new(StubRepo::rw("rust-dev"))),
];
let holger: Arc<dyn HolgerObject> = Arc::new(LocalHolger::new(FastRoutes::new(repos)));
let data = UiData::new(holger).expect("runtime");
let app = HolgerUiApp::new(data); // no skip_splash
let harness = Harness::builder()
.with_size(egui::Vec2::new(1100.0, 720.0))
.build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(app));
let mut robot = RobotSession::new(harness, "holger-server-ui");
robot.step();
// Frame 1: splash up, tabs hidden.
assert_eq!(robot.state()["splash_done"], json!(false), "splash shows on startup");
assert!(!robot.label_visible("Status"), "tabs hidden behind the splash");
// Inject a pointer click → splash dismisses.
let pos = egui::Pos2::new(120.0, 120.0);
let m = egui::Modifiers::default();
robot.inject_events([
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: m },
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: m },
]);
robot.step();
robot.step();
assert_eq!(robot.state()["splash_done"], json!(true), "splash dismissed on click");
assert!(robot.label_visible("Status"), "tabs visible after dismiss");
}
/// T7: the Artifact fetch flow — a found hit and a clean not-found, driven via
/// the UI and asserted as DATA (`/artifact`). Needs T1.
#[test]
fn robot_artifact_fetch_found_and_not_found() {
let harness = Harness::builder()
.with_size(egui::Vec2::new(1100.0, 720.0))
.build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(data_app(false)));
let mut robot = RobotSession::new(harness, "holger-server-ui");
robot.click_label("Artifact").expect("Artifact tab");
robot.step();
assert_eq!(robot.state()["tab"], json!("artifact"));
// A hit: serde 1.0.0 resolves to bytes.
robot.app_mut().0.set_artifact_query("rust-data", "serde", "1.0.0");
robot.click_label("fetch").expect("fetch button");
robot.step();
let s = robot.state();
assert_eq!(s["artifact"]["name"], json!("serde"), "fetched name: {s}");
assert_eq!(s["artifact"]["found"], json!(true), "serde found: {s}");
assert!(s["artifact"]["size_bytes"].as_u64().unwrap_or(0) > 0, "non-zero size: {s}");
assert_eq!(s["artifact"]["error"], json!(null), "no transport error: {s}");
// A clean miss: NOT_FOUND is found==false with no error (distinct from a
// transport error).
robot.app_mut().0.set_artifact_query("rust-data", "ghost", "9.9.9");
robot.click_label("fetch").expect("fetch button");
robot.step();
let s = robot.state();
assert_eq!(s["artifact"]["found"], json!(false), "ghost not found: {s}");
assert_eq!(s["artifact"]["error"], json!(null), "clean NOT_FOUND, not an error: {s}");
}
/// The **SBOM inspector + property list + ⬇ download** surface — the shipped
/// operator UI's Artifact-tab read-tier extras (these were mini-only and absent from
/// the full `gui` build; now present in every tier). Driven through the REAL widgets
/// (`fetch` → `📄 Fetch SBOM` → `⬇ Save artifact`) and asserted as VALUES off
/// `state_json["artifact"]`. Sweeps populated vs empty (LAW 2/3): `serde 1.0.0`
/// carries a real hosted SBOM + two properties; `tokio 1.2.0` carries bytes but
/// NEITHER, so the same oracle goes to its empty/error value; the download is proven
/// both to a writable temp path (success) and to a bad path (`save_error`).
#[test]
fn robot_artifact_sbom_properties_and_download() {
let harness = Harness::builder()
.with_size(egui::Vec2::new(1100.0, 720.0))
.build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(sbom_app()));
let mut robot = RobotSession::new(harness, "holger-server-ui");
robot.click_label("Artifact").expect("Artifact tab");
robot.step();
assert_eq!(robot.state()["tab"], json!("artifact"));
// ── serde 1.0.0: a real fetch loads its custom PROPERTIES alongside the bytes ──
robot.app_mut().0.set_artifact_query("rust-data", "serde", "1.0.0");
robot.click_label("fetch").expect("fetch button");
robot.step();
let s = robot.state();
assert_eq!(s["artifact"]["found"], json!(true), "serde fetched: {s}");
let props = s["artifact"]["properties"].as_array().expect("properties array");
assert_eq!(props.len(), 2, "serde carries two custom properties (LAW2 populated): {s}");
let license = props
.iter()
.find(|p| p["key"] == json!("license"))
.expect("the license property is present");
let vals = license["values"].as_array().expect("license values array");
assert!(vals.iter().any(|v| v == &json!("MIT")), "license property carries MIT: {s}");
assert!(vals.iter().any(|v| v == &json!("Apache-2.0")), "…and Apache-2.0: {s}");
// The SBOM is not loaded until the operator asks (it can be large).
assert_eq!(s["artifact"]["sbom"], json!(null), "no SBOM until '📄 Fetch SBOM' is clicked: {s}");
assert_eq!(s["artifact"]["sbom_error"], json!(null), "…and no error yet: {s}");
// ── click the REAL '📄 Fetch SBOM' button → the hosted SBOM populates as DATA ──
robot.click_label("📄 Fetch SBOM").expect("Fetch SBOM button");
robot.step();
let s = robot.state();
assert!(
s["artifact"]["sbom"]["size_bytes"].as_i64().unwrap_or(0) > 0,
"the hosted SBOM has real bytes (LAW2): {s}"
);
assert!(
!s["artifact"]["sbom"]["content_address"].as_str().unwrap_or("").is_empty(),
"the SBOM reports its blake3 content address: {s}"
);
assert!(
s["artifact"]["sbom"]["preview"].as_str().unwrap_or("").contains("CycloneDX"),
"the inspector preview carries the real document text: {s}"
);
assert_eq!(s["artifact"]["sbom_error"], json!(null), "a populated SBOM has no error: {s}");
// ── ⬇ download the fetched bytes to a writable temp path → saved_to is the value ──
let good = std::env::temp_dir()
.join(format!("holger-robot-serde-{}.crate", std::process::id()));
let good_s = good.to_str().unwrap().to_string();
robot.app_mut().0.set_save_path(&good_s);
robot.click_label("⬇ Save artifact").expect("Save artifact button");
robot.step();
let s = robot.state();
assert_eq!(s["artifact"]["saved_to"], json!(good_s), "the download outcome is a path VALUE: {s}");
assert_eq!(s["artifact"]["save_error"], json!(null), "a successful save has no error: {s}");
assert!(good.exists(), "the bytes were actually written to disk");
let _ = std::fs::remove_file(&good);
// ── download FAILURE inverse (LAW2): a save into a non-existent dir is an error VALUE ──
robot.app_mut().0.set_save_path("/holger-robot-nonexistent-dir-zzz/nope.crate");
robot.click_label("⬇ Save artifact").expect("Save artifact button (fail)");
robot.step();
let s = robot.state();
assert_eq!(s["artifact"]["saved_to"], json!(null), "a failed save clears saved_to: {s}");
assert!(
!s["artifact"]["save_error"].as_str().unwrap_or("").is_empty(),
"the failed download is a readable error value, not a silent no-op: {s}"
);
// ── EMPTY inverse: tokio has bytes but no SBOM + no properties (same oracles → empty) ──
robot.app_mut().0.set_artifact_query("rust-data", "tokio", "1.2.0");
robot.click_label("fetch").expect("fetch tokio");
robot.step();
assert_eq!(robot.state()["artifact"]["found"], json!(true), "tokio resolves to bytes");
assert_eq!(
robot.state()["artifact"]["properties"].as_array().map(|a| a.len()),
Some(0),
"tokio carries no properties (the empty inverse asserted as a value)"
);
robot.click_label("📄 Fetch SBOM").expect("Fetch SBOM (empty)");
robot.step();
let s = robot.state();
assert_eq!(s["artifact"]["sbom"], json!(null), "tokio has no hosted SBOM: {s}");
assert_eq!(
s["artifact"]["sbom_error"], json!("no SBOM attached"),
"the 'no SBOM' empty state is an ASSERTED value, never green-by-vacuum (LAW2): {s}"
);
}
/// The **real-write outcome** of the Upload tab (`put_artifact`) as a READABLE VALUE
/// (LAW 6), not a transient notice label. Driven through the REAL `upload` button
/// over the shipped shell: a writable repo + a real temp source file → the write
/// succeeds and `state_json["upload"]["outcome"]` reports `ok:true` + the exact byte
/// count; a missing source file → the SAME oracle goes to `ok:false` with the failing
/// `stage` + a populated `error` (LAW 2 — the failure path is an asserted value, and
/// the increment is swept: attempted flips false→true, ok null→true→false).
#[test]
fn robot_upload_writes_outcome_value() {
let harness = Harness::builder()
.with_size(egui::Vec2::new(1100.0, 720.0))
.build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
let mut robot = RobotSession::new(harness, "holger-server-ui");
// Select the writable repo (FastRoutes sorts to [cache, rust-dev, sparring]).
robot.app_mut().0.select_repo(1);
robot.click_label("Upload").expect("Upload tab");
robot.step();
let s = robot.state();
assert_eq!(s["tab"], json!("upload"));
assert_eq!(s["upload"]["writable"], json!(true), "rust-dev is writable: {s}");
// Clean slate: no upload attempted yet (the outcome is a value, even empty).
assert_eq!(s["upload"]["outcome"]["attempted"], json!(false), "no upload run yet: {s}");
assert_eq!(s["upload"]["outcome"]["ok"], json!(null), "ok is null before any attempt: {s}");
// A real temp source file → the write SUCCEEDS with an exact byte count.
let src = std::env::temp_dir()
.join(format!("holger-robot-upload-{}.bin", std::process::id()));
std::fs::write(&src, b"ROBOT UPLOAD PAYLOAD BYTES").expect("write temp upload source");
let src_len = std::fs::metadata(&src).unwrap().len();
robot.app_mut().0.set_upload_coord("mycrate", "0.1.0");
robot.app_mut().0.set_upload_path(src.to_str().unwrap());
robot.click_label("⬆ Upload artifact").expect("upload button");
robot.step();
let s = robot.state();
assert_eq!(s["upload"]["outcome"]["attempted"], json!(true), "the upload ran: {s}");
assert_eq!(s["upload"]["outcome"]["ok"], json!(true), "the write SUCCEEDED as a value: {s}");
assert_eq!(
s["upload"]["outcome"]["bytes"].as_u64(), Some(src_len),
"the stored byte count is the exact source length: {s}"
);
assert_eq!(s["upload"]["outcome"]["repo"], json!("rust-dev"), "the write targeted rust-dev: {s}");
assert_eq!(s["upload"]["outcome"]["error"], json!(null), "a successful write has no error: {s}");
assert!(
s["upload"]["notice"].as_str().unwrap_or("").contains("uploaded"),
"the human notice mirrors the outcome: {s}"
);
let _ = std::fs::remove_file(&src);
// FAILURE inverse (LAW2): a missing source file → ok:false, stage:"read", error set.
robot.app_mut().0.set_upload_path("/holger-robot-nonexistent-dir-zzz/missing.bin");
robot.click_label("⬆ Upload artifact").expect("upload button (fail)");
robot.step();
let s = robot.state();
assert_eq!(s["upload"]["outcome"]["ok"], json!(false), "the read failure is a value: {s}");
assert_eq!(s["upload"]["outcome"]["stage"], json!("read"), "the failing stage is named: {s}");
assert!(
!s["upload"]["outcome"]["error"].as_str().unwrap_or("").is_empty(),
"the failure carries a readable error, never a silent green: {s}"
);
}
/// T8: the Browse tab refresh button lists the repo's artifacts as DATA. Needs T1.
#[test]
fn robot_browse_tab_refresh_lists_artifacts() {
let harness = Harness::builder()
.with_size(egui::Vec2::new(1100.0, 720.0))
.build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(data_app(false)));
let mut robot = RobotSession::new(harness, "holger-server-ui");
robot.click_label("Browse").expect("Browse tab");
robot.step();
assert_eq!(robot.state()["tab"], json!("browse"));
robot.click_label("⟳ refresh").expect("refresh button");
robot.step();
let s = robot.state();
assert_eq!(s["browse"]["repository"], json!("rust-data"), "browse targets the selected repo: {s}");
let entries = s["browse"]["entries"].as_array().expect("entries array");
assert_eq!(entries.len(), 2, "serde + tokio listed: {s}");
assert!(entries.iter().any(|e| e["name"] == json!("serde")), "serde row: {s}");
assert!(entries.iter().any(|e| e["name"] == json!("tokio")), "tokio row: {s}");
}
/// T9: the Archive tab refresh button exposes the stats grid + file tree, and the
/// graph grows with artifact nodes. Needs T1.
#[test]
fn robot_archive_tab_refresh_stats_and_files() {
let harness = Harness::builder()
.with_size(egui::Vec2::new(1100.0, 720.0))
.build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(data_app(false)));
let mut robot = RobotSession::new(harness, "holger-server-ui");
robot.click_label("Archive").expect("Archive tab");
robot.step();
assert_eq!(robot.state()["tab"], json!("archive"));
robot.click_label("⟳ refresh").expect("refresh button");
robot.step();
let s = robot.state();
assert_eq!(s["archive"]["file_count"], json!(3), "archive file_count: {s}");
assert_eq!(s["archive"]["total_uncompressed_bytes"], json!(4242), "archive bytes: {s}");
let files = s["archive"]["files"].as_array().expect("files array");
assert_eq!(files.len(), 3, "three archive entries: {s}");
// The graph grows artifact nodes from the loaded archive.
robot.click_label("Graph").expect("Graph tab");
robot.step();
let s = robot.state();
let kinds = s["graph"]["kinds"].as_array().expect("kinds array");
assert!(kinds.iter().any(|k| k == &json!("artifact")), "graph grew artifact nodes: {s}");
}
/// T10: the security scan BLOCK verdict + findings are driven via the UI over a
/// vuln corpus (the openssl advisory), asserted as DATA.
#[test]
fn robot_security_scan_block_verdict() {
let harness = Harness::builder()
.with_size(egui::Vec2::new(1100.0, 720.0))
.build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(data_app(true)));
let mut robot = RobotSession::new(harness, "holger-server-ui");
robot.click_label("Tools").expect("Tools");
robot.step();
robot.click_label("3 · security · Móðguðr").expect("security");
robot.step();
robot.click_label("Scan selected repo").expect("scan");
robot.step();
let s = robot.state();
assert_eq!(s["security"]["decision"], json!("block"), "vuln corpus blocks: {s}");
assert!(s["security"]["block"].as_u64().unwrap_or(0) >= 1, "≥1 blocked artifact: {s}");
assert_eq!(s["security"]["error"], json!(null), "block is a verdict, not a transport error: {s}");
// The openssl finding is present with its advisory id.
let findings = s["security"]["findings"].as_array().expect("findings array");
let openssl = findings.iter().find(|f| f["name"] == json!("openssl")).expect("openssl finding");
let ids = openssl["ids"].as_array().expect("ids array");
assert!(
ids.iter().any(|i| i.as_str().map(|s| s.contains("RUSTSEC-2023-0044")).unwrap_or(false)),
"openssl finding carries the advisory id: {s}"
);
// The scan's PROVENANCE + TALLIES as values (LAW 6): which corpus fed the verdict,
// how many artifacts were scanned, and the per-decision counts — not just the
// top-line decision. `loaded` marks a real scan ran (RAGNARÖK: an unscanned pane
// has loaded==false, so a zero-tally green can't sneak through).
assert_eq!(s["security"]["loaded"], json!(true), "a real scan populated the pane: {s}");
assert_eq!(
s["security"]["source"], json!("demo"),
"the advisory corpus provenance is the built-in demo feed (not a silent warehouse fallback): {s}"
);
assert!(
s["security"]["scanned"].as_u64().unwrap_or(0) >= 2,
"both serde + openssl were scanned (LAW2 populated): {s}"
);
assert!(
s["security"]["pass"].as_u64().unwrap_or(0) >= 1,
"the clean serde artifact is tallied as a pass: {s}"
);
// The dependency-GRAPH view backs the same scan: every scanned artifact is a
// verdict-coloured node, and the per-colour tallies + the honest has_edges flag are
// asserted as VALUES (the graph mode was entirely value-blind before).
let dep = &s["dep_graph"];
assert_eq!(dep["loaded"], json!(true), "the dep-graph view populated from the scan: {s}");
let dep_nodes = dep["nodes"].as_array().expect("dep_graph nodes array");
assert!(dep_nodes.len() >= 2, "every scanned artifact is a graph node (LAW2): {s}");
assert!(dep["block"].as_u64().unwrap_or(0) >= 1, "≥1 block-coloured node: {s}");
assert_eq!(
dep["block"].as_u64().unwrap_or(0) + dep["warn"].as_u64().unwrap_or(0)
+ dep["pass"].as_u64().unwrap_or(0),
dep_nodes.len() as u64,
"every node is colour-tallied exactly once (the tallies reconcile to the node set): {s}"
);
// A served archive carries no resolved dependency edges — the HONEST empty flag is
// asserted as a value, not eyeballed (LAW2 populate-diagnostic: no warehouse snapshot).
assert_eq!(
dep["has_edges"], json!(false),
"no warehouse snapshot for this subject → the 'no resolved graph' flag is honest: {s}"
);
assert_eq!(
dep["edges"].as_array().map(|a| a.len()), Some(0),
"…and the edge set is correspondingly empty: {s}"
);
}
/// Tool #4 Deploy (Skíðblaðnir) — the button now WORKS (was a no-op "** being
/// implemented" stub). The robot opens Tools, selects Deploy, picks the nexus
/// target, and clicks "Deploy to test…"; the app reads the selected repo's REAL
/// sealed znippy bundle (`archive_info`) and produces a real airgap-engine
/// push-only deploy plan. Every claim is asserted against the live `state_json`
/// — the deploy result as DATA, not pixels.
#[test]
fn robot_deploys_selected_repo_sealed_bundle() {
let harness = Harness::builder()
.with_size(egui::Vec2::new(1100.0, 720.0))
.build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(data_app(false)));
let mut robot = RobotSession::new(harness, "holger-server-ui");
let scenario = Scenario::new("holger-server-ui-deploy")
.step(
Step::new("open Tools")
.action(Action::ClickLabel("Tools".into()))
.assert(Assert::State { pointer: "/tab".into(), expected: json!("tools") }),
)
.step(
Step::new("select Deploy (Skíðblaðnir) → tool == deploy")
.action(Action::ClickLabel("4 · deploy · Skíðblaðnir".into()))
.assert(Assert::State { pointer: "/tool".into(), expected: json!("deploy") }),
)
.step(
Step::new("pick the nexus target")
.action(Action::ClickLabel("nexus".into()))
.assert(Assert::State { pointer: "/deploy/target".into(), expected: json!("nexus") }),
)
.step(
Step::new("Deploy to test… → a REAL airgap deploy plan over the sealed bundle")
.action(Action::ClickLabel("Deploy to test…".into()))
.assert(Assert::State { pointer: "/deploy/result/ran".into(), expected: json!(true) }),
);
let rows = robot.run_scenario(&scenario);
assert_eq!(rows.len(), 4, "one matrix row per robot step");
for r in &rows {
assert_eq!(r.status, status::PASS, "robot step {:?}: {}", r.test_name, r.message);
}
// The deploy carried the REAL sealed bundle (rust-data's archive_info: 3 files)
// and a real airgap push-only plan — not a no-op.
let s = robot.state();
assert_eq!(s["deploy"]["result"]["file_count"], json!(3), "sealed bundle file count: {s}");
assert_eq!(s["deploy"]["result"]["target"], json!("nexus"), "deployed to the picked target: {s}");
assert!(
s["deploy"]["result"]["plan"].as_str().unwrap_or("").contains("airgap populate"),
"deploy produced a real airgap-engine plan: {s}"
);
assert!(
s["deploy"]["result"]["plan"].as_str().unwrap_or("").contains("push-only"),
"deploy carries the already-sealed bundle (push-only): {s}"
);
assert_eq!(s["deploy"]["result"]["error"], json!(null), "a clean deploy plan, no error: {s}");
}
/// **About + Tools·Demo — the shared common-framework surfaces, robot-clicked.** The
/// robot opens the **About** identity popup (the `ℹ About` header button), asserts the
/// live `state_json` reports it open with holger's identity, dismisses it with Close,
/// then opens **Tools → 6 · demo appliance** and clicks "Start demo appliance…" — the
/// jera-backed demo. Every step asserts the live `state_json`: the demo dispatch is
/// recorded through the shared job manager (a non-empty jera job id), not a bespoke
/// spawn. Real AccessKit clicks; matrix rows a robot run fills.
#[test]
fn robot_opens_about_and_dispatches_the_jera_demo() {
// Tall enough that the shared `facett_about::AboutCard`'s hero logo (a full-height
// LFS PNG when the blob is materialised) does not push the window's "Close" button
// past the viewport — otherwise the positional Close click lands off-screen. The
// height is a test-env concern only; every assertion below is unchanged.
let harness = Harness::builder()
.with_size(egui::Vec2::new(1100.0, 2400.0))
.build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
let mut robot = RobotSession::new(harness, "holger-server-ui");
let scenario = Scenario::new("holger-server-ui-about-and-demo")
// About: the shared identity surface opens + reports holger's identity.
.step(
Step::new("open About → popup open + identity reported")
.action(Action::ClickLabel("ℹ About".into()))
.assert(Assert::State { pointer: "/about/open".into(), expected: json!(true) }),
)
.step(
Step::new("About reports the wordmark identity")
.assert(Assert::State { pointer: "/about/wordmark".into(), expected: json!("holger") }),
)
.step(
Step::new("dismiss About with Close")
.action(Action::ClickLabel("Close".into()))
.assert(Assert::State { pointer: "/about/open".into(), expected: json!(false) }),
)
// Tools · Demo appliance: dispatched THROUGH jera (observable job id).
.step(
Step::new("open Tools")
.action(Action::ClickLabel("Tools".into()))
.assert(Assert::State { pointer: "/tab".into(), expected: json!("tools") }),
)
.step(
Step::new("select the demo appliance tool")
.action(Action::ClickLabel("6 · demo appliance".into()))
.assert(Assert::State { pointer: "/tool".into(), expected: json!("demo") }),
)
.step(
Step::new("Start demo appliance → dispatched as a jera job")
.action(Action::ClickLabel("Start demo appliance… (as a jera job)".into()))
.assert(Assert::State { pointer: "/demo_appliance/dispatched".into(), expected: json!(true) }),
);
let rows = robot.run_scenario(&scenario);
assert_eq!(rows.len(), 6, "one matrix row per robot step");
for r in &rows {
assert_eq!(r.status, status::PASS, "robot step {:?}: {}", r.test_name, r.message);
}
// The demo went through the shared job manager: a real jera job id + the pinned
// demo registry image — not a bespoke spawn.
let s = robot.state();
let job_id = s["demo_appliance"]["job_id"].as_str().unwrap_or("");
assert!(!job_id.is_empty(), "the demo dispatch recorded a jera job id: {s}");
assert!(
s["demo_appliance"]["image"].as_str().unwrap_or("").contains("registry"),
"the dispatched demo container is the pinned registry image: {s}"
);
// About carried holger's version + the shared framework it is built on (incl. jera).
let fw = s["about"]["framework"].as_array().expect("framework list");
assert!(
fw.iter().any(|c| c.as_str().unwrap_or("").contains("jera")),
"About names the shared jera job manager: {s}"
);
}
/// **The ADOPTED shared pane/scene host (facett_app::scene).** holger's shell is a
/// declarative `Scene` — a draggable master/detail `Split` (the registry rail beside the
/// tabbed workspace) rendered through facett's OWN TabView + pane engine by
/// `scene::render`, NOT a hand-rolled tab strip. The robot proves the adoption is live and
/// observable: the app reports the `holger` scene, a desktop-class device on a wide
/// viewport with controls unfolded, the root pane is a `split` and the workspace a `tabs`
/// layout, and clicking a TabView header (through the scene) moves the active tab. Every
/// step asserts the live `state_json` and becomes a nornir-testmatrix row. RED-when-broken:
/// if the scene stopped rendering (bespoke twin resurrected / render dropped) the
/// `/scene/*` facts go missing; if the master/detail split were flattened back to a bare
/// Tabs root the layout step fails.
#[test]
fn robot_drives_the_adopted_facett_scene() {
let harness = Harness::builder()
.with_size(egui::Vec2::new(1100.0, 720.0))
.build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
let mut robot = RobotSession::new(harness, "holger-server-ui");
let scenario = Scenario::new("holger-server-ui-facett-scene")
// The adopted scene is live and named.
.step(
Step::new("the shared facett-scene rendered (scene == holger)").assert(Assert::State {
pointer: "/scene/scene".into(),
expected: json!("holger"),
}),
)
// The root pane is the shared DRAGGABLE master/detail `Split` (rail | workspace).
.step(
Step::new("the shell root pane is a facett draggable Split").assert(Assert::State {
pointer: "/scene/panes/holger-shell/layout".into(),
expected: json!("split"),
}),
)
.step(
Step::new("…and its divider is draggable, not a static fraction").assert(Assert::State {
pointer: "/scene/panes/holger-shell/resizable".into(),
expected: json!(true),
}),
)
// The tab strip lives on the workspace pane (the detail side), still facett's TabView.
.step(
Step::new("the workspace pane is a facett Tabs layout").assert(Assert::State {
pointer: "/scene/panes/holger-workspace/layout".into(),
expected: json!("tabs"),
}),
)
// The registry rail wears a chrome title bar with minimise + maximise controls.
.step(
Step::new("the registry rail wears a minimise control").assert(Assert::State {
pointer: "/scene/panes/holger-rail/chrome/spec/minimize".into(),
expected: json!(true),
}),
)
.step(
Step::new("…and a maximise control").assert(Assert::State {
pointer: "/scene/panes/holger-rail/chrome/spec/maximize".into(),
expected: json!(true),
}),
)
// A wide viewport ⇒ desktop-class, controls shown inline (not folded into the drawer).
.step(
Step::new("wide viewport ⇒ scene device == desktop").assert(Assert::State {
pointer: "/scene/device".into(),
expected: json!("desktop"),
}),
)
.step(
Step::new("controls shown inline on desktop (not folded)").assert(Assert::State {
pointer: "/controls_folded".into(),
expected: json!(false),
}),
)
.step(Step::new("the scene drew its tab canvas").assert(Assert::Drew))
// Navigation THROUGH the scene's TabView: clicking the header moves the active tab.
.step(
Step::new("click Browse (scene TabView) → tab == browse")
.action(Action::ClickLabel("Browse".into()))
.assert(Assert::State { pointer: "/tab".into(), expected: json!("browse") }),
)
.step(
Step::new("click Graph (scene TabView) → tab == graph")
.action(Action::ClickLabel("Graph".into()))
.assert(Assert::State { pointer: "/tab".into(), expected: json!("graph") }),
);
let rows = robot.run_scenario(&scenario);
assert_eq!(rows.len(), 11, "one matrix row per robot step");
for r in &rows {
assert_eq!(r.status, status::PASS, "robot step {:?}: {}", r.test_name, r.message);
}
}
/// **Responsive canvas-first fold (facett-scene `Device::from_width`).** On a phone-class
/// (narrow) viewport the shell reports the `phone` device class and folds its control chrome
/// (the static toggle + look switcher) behind the ⚙ drawer so the tab canvas gets the screen —
/// the same mobile fold facett-demo uses. The robot drives the REAL app at a narrow size and
/// asserts the observable device class + fold flag, while the scene still renders its tab
/// canvas. RED-when-broken: if the responsive fold regressed, `device`/`controls_folded` lie.
#[test]
fn robot_scene_folds_controls_on_narrow_viewport() {
// A narrow (phone-class) window — below facett-scene's 600pt phone breakpoint.
let harness = Harness::builder()
.with_size(egui::Vec2::new(460.0, 820.0))
.build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
let mut robot = RobotSession::new(harness, "holger-server-ui");
let scenario = Scenario::new("holger-server-ui-responsive-fold")
.step(
Step::new("narrow viewport ⇒ scene device == phone").assert(Assert::State {
pointer: "/scene/device".into(),
expected: json!("phone"),
}),
)
.step(
Step::new("controls fold behind the ⚙ drawer (canvas-first)").assert(Assert::State {
pointer: "/controls_folded".into(),
expected: json!(true),
}),
)
.step(
Step::new("the scene still rendered its tab canvas").assert(Assert::State {
pointer: "/scene/scene".into(),
expected: json!("holger"),
}),
)
.step(Step::new("the canvas drew on a narrow viewport").assert(Assert::Drew));
let rows = robot.run_scenario(&scenario);
assert_eq!(rows.len(), 4, "one matrix row per robot step");
for r in &rows {
assert_eq!(r.status, status::PASS, "robot step {:?}: {}", r.test_name, r.message);
}
}
/// The **Helix** top-level tab — the ONLY operator tab that was un-robot-tested (the
/// lineage *companion* helix was covered, but the standalone Helix tab that winds a
/// scanned repo's dependency DAG along a synthesized time axis was not). The robot runs a
/// real Móðguðr scan (block corpus) to populate the dep-graph, opens Helix, and asserts the
/// wound coil AS DATA via the newly-exposed `state_json["helix"]` (the `HelixView`'s own
/// `state_json`). RED-when-broken: an empty/mis-wired `build_helix`, or a silent fall-back
/// to the self-contained `HelixView::demo()` scene, fails the node-count / title / verdict
/// assertions instead of going quietly green.
#[test]
fn robot_helix_tab_winds_the_scanned_repo_dag() {
let harness = Harness::builder()
.with_size(egui::Vec2::new(1100.0, 720.0))
.build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(data_app(true)));
let mut robot = RobotSession::new(harness, "holger-server-ui");
let scenario = Scenario::new("holger-helix-tab")
.step(
Step::new("open Tools").action(Action::ClickLabel("Tools".into())).assert(
Assert::State { pointer: "/tab".into(), expected: json!("tools") },
),
)
.step(
Step::new("select the Móðguðr security pane")
.action(Action::ClickLabel("3 · security · Móðguðr".into()))
.assert(Assert::State { pointer: "/tool".into(), expected: json!("security") }),
)
.step(
Step::new("scan the selected repo → block verdict populates the dep-graph")
.action(Action::ClickLabel("Scan selected repo".into()))
.assert(Assert::State {
pointer: "/security/decision".into(),
expected: json!("block"),
}),
)
.step(
Step::new("Helix tab → wind the coil")
.action(Action::ClickLabel("Helix".into()))
.assert(Assert::State { pointer: "/tab".into(), expected: json!("helix") }),
)
.step(Step::new("the Helix view built its coil").assert(Assert::State {
pointer: "/helix_built".into(),
expected: json!(true),
}))
.step(Step::new("the time-helix drew").assert(Assert::Drew));
let rows = robot.run_scenario(&scenario);
assert_eq!(rows.len(), 6, "one matrix row per robot step");
for r in &rows {
assert_eq!(r.status, status::PASS, "robot step {:?}: {}", r.test_name, r.message);
}
// Deep-assert the wound coil AS DATA (the robot's oracle) — every claim red-when-broken.
let s = robot.state();
let helix = &s["helix"];
let dep_nodes = s["dep_graph"]["nodes"].as_array().map(|a| a.len()).unwrap_or(0) as u64;
assert!(dep_nodes >= 1, "the scan populated the dep-graph (LAW2: no empty data): {s}");
assert!(
helix["title"].as_str().unwrap_or("").starts_with("holger · "),
"a REAL scan winds a repo-titled coil, not the demo fallback: {helix}"
);
assert_eq!(
helix["total_nodes"].as_u64().unwrap_or(0),
dep_nodes + 1,
"every scanned artifact + the repo root is wound onto the coil: {helix}"
);
assert_eq!(helix["coil_count"], json!(1), "one repository → one coil: {helix}");
assert_eq!(
helix["winds_forward"],
json!(true),
"the synthesized time axis advances monotonically along the coil: {helix}"
);
// The block verdict rides in as a Blocked helix node (verdict → run status).
let coil0 = &helix["coils"][0];
let nodes = coil0["nodes"].as_array().expect("coil-0 nodes array in helix state");
assert!(
nodes.iter().any(|n| n["status"] == json!("blocked")),
"a block-verdict artifact winds in as a Blocked helix node: {helix}"
);
}