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

nordisk/nornir

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")
    );
}