tests/install_onboarding.rs
//! Inject-assert: **fresh install / empty server / onboarding / workspace add**.
//!
//! This file is the matrix coverage for the bugs a fresh-install session surfaced
//! that the matrix SHOULD have caught automatically — every assertion is read from
//! DATA (`state_json` / the workspace RPCs), never an eyeball (LAW 6).
//!
//! The four covered bugs:
//!
//! 1. **Registered-but-unpopulated NAMED workspace must show ONBOARDING, not a
//! red "is not served" error.** A workspace can be REGISTERED (it shows in the
//! viz picker; `Workspaces.List` returns it) but NOT yet served — no warehouse
//! built (a `pushed`/`external` add, a `lazy` register, or a monitored
//! workspace whose eager build hasn't finished). Selecting it made `Viz.Timeline`
//! answer `NotFound: workspace `<name>` is not served`, and the viz client
//! rendered a RED "Failed to load warehouse" error. The fix routes that
//! NotFound to the graceful "not populated — click ⬇ Populate" onboarding
//! panel. `viz_unpopulated_named_workspace_is_onboarding_not_error` proves
//! `state_json` reports `onboarding.unpopulated == true`, `onboarding.active`,
//! and a NULL `timeline.error` — and the RED inverse (`onboarding_red_when_*`)
//! proves the same oracle goes RED if the not-served NotFound ever leaks back
//! into `timeline.error`.
//!
//! 3. **Workspace add is DESCRIPTOR-FREE / descriptor-optional.** A workspace
//! registered by name with its members shipped in the descriptor content (no
//! git clone, no mandatory bootstrap) registers cleanly and lists its members.
//! `register_is_descriptor_free_and_lists_members` asserts the registered
//! record carries the shipped members.
//!
//! 4. **An empty server serves an EMPTY REGISTRY without error.** A fresh server
//! (boot-config dropped) starts, answers `Workspaces.List` → 0, and the
//! default-workspace route does not panic/hard-fail.
//! `empty_server_serves_empty_registry` asserts the empty list + that a
//! viz app built against it lands in the empty-registry onboarding state.
//!
//! Bug #2 (the bare empty-server systemd unit has NO `NORNIR_MONITOR`/`NORNIR_POLL`
//! lines + a bare ExecStart) is a UNIT test on the install path's `render_server_unit`
//! and lives in `src/bin/nornir.rs` (the unit is private to that binary).
//!
//! Run: cargo test --features "viz,server" --test install_onboarding -- --test-threads=1
#![cfg(all(feature = "viz", feature = "server"))]
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::{Child, Command};
use std::time::{Duration, Instant};
use nornir::viz::UrdrThreadsApp;
use serde_json::Value;
use uuid::Uuid;
fn free_port() -> u16 {
TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port()
}
/// A minimal committed Rust-crate fixture repo a pure-gix `file://` clone can fetch.
fn fixture_crate(dir: &Path, name: &str) {
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(
dir.join("Cargo.toml"),
format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n"),
)
.unwrap();
std::fs::write(
dir.join("src").join("lib.rs"),
format!("//! {name} fixture crate\npub fn {name}_answer() -> i32 {{ 42 }}\n"),
)
.unwrap();
nornir::gitio::init(dir).unwrap();
nornir::gitio::commit_all(dir, "init fixture").unwrap();
}
fn file_url(dir: &Path) -> String {
format!("file://{}", dir.display())
}
/// An ephemeral `nornir-server` with a FRESH EMPTY registry (no workspaces served).
struct Server {
child: Child,
root: PathBuf,
endpoint: String,
token: String,
}
impl Drop for Server {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
let _ = std::fs::remove_dir_all(&self.root);
}
}
/// Spawn a server with a fresh empty registry — bug #4's "empty server serves an
/// empty registry": NO boot config, NO monitor env, NO seeded workspaces.
fn spawn_empty_server() -> Server {
let token = "install-onboarding-token-0123456789";
let port = free_port();
let endpoint = format!("http://127.0.0.1:{port}");
let root = std::env::temp_dir().join(format!(
"nornir-onboarding-{}-{}",
std::process::id(),
Uuid::new_v4()
));
let _ = std::fs::remove_dir_all(&root);
let server_home = root.join("home");
std::fs::create_dir_all(server_home.join(".nornir").join("workspaces")).unwrap();
let child = Command::new(env!("CARGO_BIN_EXE_nornir-server"))
.env("NORNIR_SERVER_ADDR", format!("127.0.0.1:{port}"))
.env("NORNIR_SERVER_TOKEN", token)
.env("HOME", &server_home)
// Long poll so the SWEEP loop never races our asserts.
.env("NORNIR_POLL", "3600s")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawn nornir-server");
let srv = Server {
child,
root,
endpoint: endpoint.clone(),
token: token.to_string(),
};
let deadline = Instant::now() + Duration::from_secs(45);
loop {
if nornir::viz::remote::list_workspaces(&endpoint, token).is_ok() {
break;
}
assert!(Instant::now() < deadline, "empty server never came up");
std::thread::sleep(Duration::from_millis(200));
}
srv
}
/// Write a workspace descriptor (server-readable local path) with two
/// `file://`-cloneable members. Returns the descriptor `.toml` path.
fn write_descriptor(dir: &Path, ws_name: &str) -> PathBuf {
let src_alpha = dir.join("src_alpha");
let src_beta = dir.join("src_beta");
fixture_crate(&src_alpha, "alpha");
fixture_crate(&src_beta, "beta");
let desc = dir.join("nornir-workspace.toml");
std::fs::write(
&desc,
format!(
"[workspace]\nname = \"{ws_name}\"\n\n\
[repos.alpha]\ngit = \"{}\"\n\n\
[repos.beta]\ngit = \"{}\"\n",
file_url(&src_alpha),
file_url(&src_beta),
),
)
.unwrap();
desc
}
/// Build a viz app in THIN/remote mode pointed at the server, selecting `ws`.
fn thin_app(srv: &Server, ws: &str) -> UrdrThreadsApp {
UrdrThreadsApp::with_remote(
srv.endpoint.clone(),
srv.token.clone(),
ws.to_string(),
PathBuf::new(),
Vec::new(),
)
}
// ─────────────────────────────────────────────────────────────────────────────
// BUG #4 — an empty server serves an empty registry without error.
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn empty_server_serves_empty_registry() {
let srv = spawn_empty_server();
// `Workspaces.List` → 0 (the boot-config-dropped empty server).
let listed = nornir::viz::remote::list_workspaces(&srv.endpoint, &srv.token)
.expect("empty server must answer Workspaces.List, not error");
assert_eq!(
listed.len(),
0,
"a fresh empty server serves 0 workspaces, got: {listed:?}"
);
// A viz app built against the empty server lands in the EMPTY-REGISTRY
// onboarding state (no workspace selected) — NOT an error, NOT a panic.
let app = thin_app(&srv, "");
let s = app.state_json();
let onboarding = &s["onboarding"];
assert_eq!(
onboarding["active"],
Value::Bool(true),
"empty registry → onboarding active, got onboarding={onboarding}"
);
assert_eq!(
onboarding["no_workspaces"],
Value::Bool(true),
"empty registry → no_workspaces true"
);
// The empty-registry case is NOT the unpopulated-named case.
assert_eq!(
onboarding["unpopulated"],
Value::Bool(false),
"empty registry is the no-selection onboarding, not the unpopulated-named one"
);
// No timeline error reached the user (the default route never hard-failed).
assert!(
s["timeline"]["error"].is_null(),
"empty server must not surface a timeline error, got: {}",
s["timeline"]["error"]
);
assert_eq!(
s["workspace_picker"]["count"],
Value::from(0),
"the picker offers 0 workspaces on an empty server"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// BUG #3 — workspace add is descriptor-free / descriptor-optional.
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn register_is_descriptor_free_and_lists_members() {
let srv = spawn_empty_server();
let ws = "descfree";
let fixtures = srv.root.join("fixtures-descfree");
std::fs::create_dir_all(&fixtures).unwrap();
let desc = write_descriptor(&fixtures, ws);
// `pushed` add: members are discovered from the descriptor content; no
// mandatory bootstrap clone of a workspace REPO (the descriptor itself is the
// source). The members are SHIPPED, not cloned from a remote workspace repo.
let (name, mode, members) = nornir::viz::remote::register_workspace(
&srv.endpoint,
&srv.token,
ws,
&desc.to_string_lossy(),
"pushed",
"3600s",
)
.expect("descriptor-free pushed register must succeed");
assert_eq!(name, ws, "registered the workspace we asked for");
assert_eq!(mode, "pushed");
assert_eq!(
members, 2,
"the two shipped members are discovered from the descriptor"
);
// It lists as registered (the row was upserted).
let listed =
nornir::viz::remote::list_workspaces(&srv.endpoint, &srv.token).expect("list workspaces");
assert!(
listed.iter().any(|w| w == ws),
"the descriptor-free workspace is listed: {listed:?}"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// BUG #1 — a registered-but-unpopulated NAMED workspace is ONBOARDING, not RED.
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn viz_unpopulated_named_workspace_is_onboarding_not_error() {
let srv = spawn_empty_server();
let ws = "unpop";
let fixtures = srv.root.join("fixtures-unpop");
std::fs::create_dir_all(&fixtures).unwrap();
let desc = write_descriptor(&fixtures, ws);
// RED-before-fix PRECONDITION (proves the probe is real, not vacuous): a
// workspace-routed RPC for `ws` BEFORE register is the not-served NotFound.
let before = nornir::viz::remote::fetch_timeline(&srv.endpoint, &srv.token, ws);
let before_err = before.expect_err("an unregistered workspace must NOT be served");
let before_msg = format!("{before_err:#}");
assert!(
before_msg.contains("is not served") || before_msg.contains("NotFound"),
"pre-register timeline should be a not-served NotFound, got: {before_msg}"
);
// REGISTER as `pushed` — registered (shows in the picker) but NOT served (no
// warehouse handle: pushed/external have no monitored served warehouse). So
// `Viz.Timeline` for this NAMED, selected workspace answers
// `NotFound: workspace `unpop` is not served`.
let (name, _mode, members) = nornir::viz::remote::register_workspace(
&srv.endpoint,
&srv.token,
ws,
&desc.to_string_lossy(),
"pushed",
"3600s",
)
.expect("register must succeed");
assert_eq!(name, ws);
assert_eq!(members, 2, "the workspace is registered with its members");
// The SERVER serves every registered workspace (8fc1d7b: mode gates fetching,
// not reading), so this registered-but-unpopulated name answers an EMPTY
// timeline — no lanes, no snapshot — not an error.
let tl = nornir::viz::remote::fetch_timeline(&srv.endpoint, &srv.token, ws)
.expect("a registered workspace is served, populated or not");
assert!(
tl.lanes.is_empty() && tl.latest_snapshot.is_none(),
"a registered-but-unpopulated workspace has nothing in it yet: {tl:?}"
);
// ── THE FIX (read from DATA, LAW 6): a thin viz app that SELECTS this
// registered-but-unpopulated workspace renders the ONBOARDING / "not
// populated — click Populate" state, NOT a red "Failed to load warehouse"
// error. ──
let app = thin_app(&srv, ws);
let s = app.state_json();
// The workspace IS selected (so this is the NAMED case, not empty-registry).
assert_eq!(
s["workspace_picker"]["selected"],
Value::from(ws),
"the unpopulated workspace is selected in the picker"
);
let onboarding = &s["onboarding"];
assert_eq!(
onboarding["unpopulated"],
Value::Bool(true),
"registered-unpopulated NAMED workspace → onboarding.unpopulated, got: {onboarding}"
);
assert_eq!(
onboarding["active"],
Value::Bool(true),
"the onboarding slice is active for the unpopulated workspace"
);
assert_eq!(
onboarding["workspace"],
Value::from(ws),
"the onboarding slice names the unpopulated workspace"
);
// This is NOT the empty-registry case — a workspace IS registered.
assert_eq!(
onboarding["no_workspaces"],
Value::Bool(false),
"a workspace is registered, so the registry is NOT empty"
);
// ── THE CORE ASSERTION: the not-served NotFound must NOT surface as a red
// error. It is suppressed from `timeline.error` (onboarding, not error). ──
assert!(
s["timeline"]["error"].is_null(),
"the not-served NotFound must NOT render red — onboarding instead. Got error: {}",
s["timeline"]["error"]
);
assert!(
onboarding["timeline_error"].is_null(),
"onboarding suppresses the not-served NotFound (it's a cause, not a user error)"
);
}