tests/viz_buttons.rs
//! Exhaustive headless **crash-hunt** for `nornir viz` (nornir-ui).
//!
//! Where [`viz_matrix`](./viz_matrix.rs) asserts on the *data* layer
//! (`state_json()`), this test drives the **real egui UI** through an
//! `egui_kittest::Harness` (no window, no GPU — it walks the AccessKit tree) and
//! **clicks every clickable widget on every tab of every workspace**, recording
//! any panic as a structured failure instead of aborting the whole run.
//!
//! The goal is to surface the kind of bug that only fires when an operator
//! actually clicks a control in the live GUI: an out-of-bounds index, a
//! non-char-boundary string slice, a poisoned mutex, an `unwrap()` on missing
//! data — none of which the data-shape matrix exercises.
//!
//! Strategy, per discovered `workspace_*` (empty warehouses included — the
//! empty-state buttons must be safe too):
//! 1. Build `UrdrThreadsApp::with_repos(..)` in LOCAL mode.
//! 2. Wrap it in a state-based `Harness` driving `|ui, app| app.draw_ui(ui)`.
//! 3. Visit every tab (click the toolbar tab buttons), and within each tab
//! click every `Role::Button` node. Re-enumerate after each click since a
//! click can reveal new buttons (combos, drill-downs); stop a tab once no
//! new button label appears (bounded passes).
//! 4. Each click + `harness.run_ok()` is wrapped in `catch_unwind`; a panic is
//! collected as `{workspace, tab, button, msg}` and the hunt continues.
//! 5. After all workspaces, assert no failures with a detailed listing.
//!
//! Build / run:
//! cargo test --features viz --test viz_buttons -- --nocapture
#![cfg(feature = "viz")]
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::path::{Path, PathBuf};
use egui::accesskit;
use egui_kittest::Harness;
use egui_kittest::kittest::{By, NodeT, Queryable};
use nornir::viz::UrdrThreadsApp;
/// AccessKit role for a clickable button (egui's `Button` / `selectable_value`
/// both surface as this role).
const ROLE_BUTTON: accesskit::Role = accesskit::Role::Button;
/// The tabs the UI exposes (the `state_json()["tab"]` debug names).
const EXPECTED_TABS: [&str; 15] = [
"Nornir",
"Timeline",
"DepGraph",
"CallGraph",
"Architecture",
"Funnel",
"TimeTravel",
"LiveRun",
"Release",
"Knowledge",
"Warehouse",
"Mcp",
"Search",
"Gates",
"Security",
];
/// Toolbar button labels (with the emoji prefix egui renders) that switch tabs,
/// paired with the `state_json()["tab"]` name they select. Clicking the button
/// by label is how we navigate; we then confirm via `state_json()`.
const TAB_BUTTONS: [(&str, &str); 15] = [
("🧬 nornir", "Nornir"),
("🧵 Timeline", "Timeline"),
("🔗 Dep Graph", "DepGraph"),
("🕸 Call Graph", "CallGraph"),
("🏛 Architecture", "Architecture"),
("🗂 Funnel", "Funnel"),
("⏳ Time Travel", "TimeTravel"),
("📡 Live Run", "LiveRun"),
("🚀 Release", "Release"),
("🗺 Knowledge", "Knowledge"),
("🗄 Warehouse", "Warehouse"),
("📞 MCP", "Mcp"),
("🔍 Search", "Search"),
("🚦 Gates", "Gates"),
("🛡 Security", "Security"),
];
// ─── workspace discovery ────────────────────────────────────────────────────
/// Discover every `workspace_*` directory and return
/// `(workspace_dir, warehouse_root, workspace_name)`.
///
/// Unlike `viz_matrix`'s helper (which only looks at `CARGO_MANIFEST_DIR`'s
/// direct parent), this climbs the ancestor chain so it finds the real
/// `workspace_*` siblings of the *repo* even when the test is compiled from a
/// git **worktree** (where the manifest lives under `.../.claude/worktrees/…`
/// and the workspaces are several levels up next to the canonical checkout).
///
/// Empty / missing warehouses are INCLUDED — the empty-state buttons must be
/// crash-safe too. For those, `warehouse_root` points at `.nornir` (so
/// `IcebergWarehouse::open` can still create/find a root) instead of a
/// nonexistent deep path.
fn discover_workspaces() -> Vec<(PathBuf, PathBuf, String)> {
let here = Path::new(env!("CARGO_MANIFEST_DIR"));
// Climb ancestors; the first ancestor that has at least one `workspace_*`
// child dir is the workspace root.
let mut workspace_parent: Option<PathBuf> = None;
for ancestor in here.ancestors() {
if dir_has_workspace_child(ancestor) {
workspace_parent = Some(ancestor.to_path_buf());
break;
}
}
let parent = match workspace_parent {
Some(p) => p,
None => return Vec::new(),
};
let mut found = Vec::new();
let entries = match std::fs::read_dir(&parent) {
Ok(e) => e,
Err(_) => return found,
};
for entry in entries.flatten() {
let dir = entry.path();
if !dir.is_dir() {
continue;
}
let stem = dir.file_name().and_then(|s| s.to_str()).unwrap_or("");
if !stem.starts_with("workspace_") {
continue;
}
// Workspace name from `[workspace] name =` in the descriptor toml; fall
// back to the dir-name stem.
let toml_path = dir.join("nornir-workspace.toml");
let ws_name = if toml_path.is_file() {
let contents = std::fs::read_to_string(&toml_path).unwrap_or_default();
contents
.lines()
.find_map(|line| {
let line = line.trim();
if line.starts_with("name") && line.contains('=') {
let val = line.splitn(2, '=').nth(1)?.trim().trim_matches('"');
Some(val.to_string())
} else {
None
}
})
.unwrap_or_else(|| stem.trim_start_matches("workspace_").to_string())
} else {
stem.trim_start_matches("workspace_").to_string()
};
// Warehouse root: `.nornir/warehouse` when present, else `.nornir`.
let nornir_dir = dir.join(".nornir");
let warehouse_root = {
let candidate = nornir_dir.join("warehouse");
if candidate.is_dir() {
candidate
} else {
nornir_dir.clone()
}
};
found.push((dir, warehouse_root, ws_name));
}
found.sort_by(|a, b| a.0.cmp(&b.0));
found
}
/// True iff `dir` has at least one immediate child named `workspace_*`.
fn dir_has_workspace_child(dir: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
for entry in entries.flatten() {
if !entry.path().is_dir() {
continue;
}
if entry
.file_name()
.to_str()
.map(|s| s.starts_with("workspace_"))
.unwrap_or(false)
{
return true;
}
}
false
}
// ─── per-workspace crash-hunt ───────────────────────────────────────────────
/// A recorded panic from clicking a button.
#[derive(Debug)]
struct Failure {
workspace: String,
tab: String,
button: String,
msg: String,
}
/// Per-workspace summary stats (for the eprintln! summary).
#[derive(Default)]
struct Summary {
tabs_visited: usize,
buttons_clicked: usize,
panics: usize,
}
/// Extract a human-readable message from a panic payload.
fn panic_msg(payload: &(dyn std::any::Any + Send)) -> String {
if let Some(s) = payload.downcast_ref::<&str>() {
(*s).to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"<non-string panic payload>".to_string()
}
}
/// Collect the labels of every currently-visible button node in the harness.
fn current_button_labels(harness: &Harness<'_, UrdrThreadsApp>) -> Vec<String> {
harness
.get_all(By::new().role(ROLE_BUTTON))
.filter_map(|n| n.accesskit_node().label())
.collect()
}
/// Build the harness for one workspace.
fn build_harness(warehouse_root: &Path, ws_name: &str) -> Harness<'static, UrdrThreadsApp> {
let app = UrdrThreadsApp::with_repos(
warehouse_root.to_path_buf(),
ws_name.to_string(),
PathBuf::new(),
Vec::new(),
);
Harness::builder()
.with_size(egui::Vec2::new(1400.0, 900.0))
.build_ui_state(|ui, app: &mut UrdrThreadsApp| app.draw_ui(ui), app)
}
/// Catch-unwind a single click + run. Returns `Some(panic_msg)` on panic.
fn click_and_run(harness: &mut Harness<'_, UrdrThreadsApp>, label: &str) -> Option<String> {
let result = catch_unwind(AssertUnwindSafe(|| {
// Re-find the button by label, click the first match, then pump a frame.
// The button may have vanished (a previous click changed state) — that's
// fine, we simply don't click anything this round.
if let Some(node) = harness
.get_all(By::new().role(ROLE_BUTTON))
.find(|n| n.accesskit_node().label().as_deref() == Some(label))
{
node.click();
}
// `run_ok` (not `run`) so a permanently-animating UI — the splash overlay
// and the 1 s auto-refresh both request_repaint every frame — doesn't
// *panic* the harness via the max-steps guard. It still pumps the queued
// click events and settles whatever it can; the AccessKit tree is rebuilt
// each frame so post-click re-enumeration sees any revealed buttons.
harness.run_ok();
}));
match result {
Ok(()) => None,
Err(payload) => Some(panic_msg(payload.as_ref())),
}
}
/// Switch to a tab by clicking its toolbar button, with panic capture.
/// Returns `Some(panic_msg)` if switching panicked.
fn goto_tab(harness: &mut Harness<'_, UrdrThreadsApp>, button_label: &str) -> Option<String> {
click_and_run(harness, button_label)
}
/// Drive one workspace: visit every tab, click every button on each.
fn hunt_workspace(
dir: &Path,
warehouse_root: &Path,
ws_name: &str,
failures: &mut Vec<Failure>,
) -> Summary {
eprintln!(
"\n=== viz_buttons: workspace={ws_name} dir={} warehouse={}",
dir.display(),
warehouse_root.display()
);
let mut summary = Summary::default();
// Build once; we reuse the harness across all tabs so click-revealed state
// persists the way it does for a real operator.
let build_result = catch_unwind(AssertUnwindSafe(|| build_harness(warehouse_root, ws_name)));
let mut harness = match build_result {
Ok(h) => h,
Err(payload) => {
let msg = panic_msg(payload.as_ref());
eprintln!(" [{ws_name}] PANIC building harness: {msg}");
failures.push(Failure {
workspace: ws_name.to_string(),
tab: "<build>".to_string(),
button: "<harness>".to_string(),
msg,
});
summary.panics += 1;
return summary;
}
};
// First frame so the AccessKit tree exists.
if let Err(payload) = catch_unwind(AssertUnwindSafe(|| harness.run_ok())) {
let msg = panic_msg(payload.as_ref());
eprintln!(" [{ws_name}] PANIC on initial frame: {msg}");
failures.push(Failure {
workspace: ws_name.to_string(),
tab: "<initial>".to_string(),
button: "<frame>".to_string(),
msg,
});
summary.panics += 1;
return summary;
}
for (tab_button, tab_name) in TAB_BUTTONS {
// Switch to the tab.
if let Some(msg) = goto_tab(&mut harness, tab_button) {
eprintln!(" [{ws_name}/{tab_name}] PANIC switching to tab: {msg}");
failures.push(Failure {
workspace: ws_name.to_string(),
tab: tab_name.to_string(),
button: format!("<switch:{tab_button}>"),
msg,
});
summary.panics += 1;
// Best effort: keep going to the next tab.
continue;
}
summary.tabs_visited += 1;
// Confirm the tab actually activated (best-effort; some buttons may be
// off-screen on the two-row toolbar but kittest clicks via AccessKit so
// visibility isn't an issue). We don't hard-fail on mismatch — the point
// is the crash-hunt, not navigation assertions.
let active = harness.state().state_json()["tab"]
.as_str()
.unwrap_or("")
.to_string();
let _ = active; // (kept for debugging; not asserted)
// Click every button on this tab. Re-enumerate after each click since a
// click can reveal new buttons. Bound the work: track clicked labels and
// stop once a full pass reveals nothing new (max a few passes).
let mut clicked: std::collections::HashSet<String> = std::collections::HashSet::new();
// The tab-switch buttons themselves are always present; pre-mark every
// OTHER tab's button as "clicked" so we never navigate away mid-tab.
for (other_btn, _) in TAB_BUTTONS {
clicked.insert(other_btn.to_string());
}
const MAX_PASSES: usize = 8;
for _pass in 0..MAX_PASSES {
let labels = current_button_labels(&harness);
// Pick the next unclicked button label.
let next = labels.into_iter().find(|l| !clicked.contains(l));
let Some(label) = next else {
break; // no new buttons — tab exhausted
};
clicked.insert(label.clone());
if let Some(msg) = click_and_run(&mut harness, &label) {
eprintln!(" [{ws_name}/{tab_name}] PANIC clicking `{label}`: {msg}");
failures.push(Failure {
workspace: ws_name.to_string(),
tab: tab_name.to_string(),
button: label.clone(),
msg,
});
summary.panics += 1;
// The harness may now be in a poisoned/odd state; rebuild it and
// re-navigate to this tab so the remaining buttons still get hit.
match catch_unwind(AssertUnwindSafe(|| build_harness(warehouse_root, ws_name))) {
Ok(h) => {
harness = h;
let _ = catch_unwind(AssertUnwindSafe(|| harness.run_ok()));
let _ = goto_tab(&mut harness, tab_button);
}
Err(_) => break, // can't rebuild — bail this tab
}
}
summary.buttons_clicked += 1;
}
}
eprintln!(
" [{ws_name}] SUMMARY: tabs_visited={} buttons_clicked={} panics={}",
summary.tabs_visited, summary.buttons_clicked, summary.panics
);
summary
}
// ─── the test ───────────────────────────────────────────────────────────────
// IGNORED BY DEFAULT — this is an EXHAUSTIVE crash-hunt, not a fast unit guard.
// It discovers every `workspace_*` dir reachable from the checkout and clicks
// every clickable widget on all 18 tabs of each. On a multi-repo disk that is a
// dozen+ workspaces (several with multi-gigabyte warehouses), and a single frame
// pass re-enumerates the whole AccessKit tree up to 8× per tab — the run blows
// well past 20 minutes with no natural bound, so under `cargo test --workspace`
// it read as non-terminating. Even one empty synthetic workspace did not finish
// inside a few minutes, so a "bounded workspace set" does not make it a
// well-behaved default-suite test. It is therefore an opt-in / CI-only crash-hunt:
// run it explicitly with `cargo test --features viz --test viz_buttons -- --ignored`
// Coverage is preserved (nothing deleted) — it simply no longer blocks the suite.
#[test]
#[ignore = "exhaustive crash-hunt: walks every workspace_* × every button; run with --ignored (CI-only)"]
fn viz_buttons_crash_hunt_all_workspaces() {
// The Funnel tab's 🌱 Run demo / 🗑 Nuke disk buttons do real I/O (create /
// delete fake repos near the workspace's git root + write the warehouse).
// The crash-hunt must still CLICK them (to prove they don't panic) but must
// not mutate the real workspaces — disable the demo's effect via this flag.
// TODO: Audit that the environment access only happens in single-threaded code.
unsafe { std::env::set_var("NORNIR_FUNNEL_DEMO_DISABLED", "1") };
let workspaces = discover_workspaces();
if workspaces.is_empty() {
eprintln!("[skip] no workspace_* directories found from CARGO_MANIFEST_DIR ancestors");
return;
}
eprintln!(
"viz_buttons: hunting {} workspace(s) × {} tab(s)",
workspaces.len(),
EXPECTED_TABS.len()
);
let mut failures: Vec<Failure> = Vec::new();
let mut total_tabs = 0usize;
let mut total_buttons = 0usize;
for (dir, warehouse_root, ws_name) in &workspaces {
let s = hunt_workspace(dir, warehouse_root, ws_name, &mut failures);
total_tabs += s.tabs_visited;
total_buttons += s.buttons_clicked;
}
eprintln!(
"\nviz_buttons: TOTAL workspaces={} tabs_visited={} buttons_clicked={} panics={}",
workspaces.len(),
total_tabs,
total_buttons,
failures.len()
);
assert!(
failures.is_empty(),
"viz_buttons found {} panic(s) clicking UI buttons:\n{}",
failures.len(),
failures
.iter()
.map(|f| format!(
" • workspace={} tab={} button={:?}\n panic: {}",
f.workspace, f.tab, f.button, f.msg
))
.collect::<Vec<_>>()
.join("\n")
);
}