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

nordisk/nornir

tests/viz_action_log.rs

//! N5 inject-and-assert proof for the viz **action trail → warehouse** sink —
//! THE_BIG_PLAN LAW #1: feed real input, assert real output, never merely
//! "didn't panic".
//!
//! N5 promotes the viz action log from a `/tmp`-only stopgap to a durable,
//! queryable warehouse table (`viz_actions`), per the
//! persist-to-warehouse-stream-not-`/tmp` law. The `/tmp` file stays the fast
//! local live-channel; the warehouse is the durable sink, and the viz reads its
//! own action history **back** from it.
//!
//! These tests prove the contract two ways:
//!
//!   1. `action_log_streams_into_warehouse_and_reads_back` — attach a **real
//!      on-disk warehouse**, push a handful of typed actions (LIFE/TAB/CLICK/
//!      RPC/ERROR) with real context (workspace + tab), flush the durable sink,
//!      then assert the exact rows landed in the `viz_actions` table AND that
//!      the log's own read-back view returns them (scoped to this session, in
//!      `seq` order, with the real cell values — not just a row count).
//!
//!   2. `tmp_file_remains_the_live_channel_alongside_the_warehouse` — the
//!      `/tmp` file (LAW: write both) still receives the same actions, so the
//!      fast greppable local channel is intact next to the durable sink.
#![cfg(feature = "viz")]

use std::path::PathBuf;

use nornir::viz::action_log::{ActionLog, Kind};
use nornir::warehouse::iceberg::IcebergWarehouse;
use nornir::warehouse::viz_actions::{ActionSelector, query_viz_actions_blocking};

/// Both tests point `NORNIR_VIZ_ACTIONLOG` at their own file, and `ActionLog::new`
/// reads it: run side by side, one test's log opened the other's path and its
/// own file never appeared (seen 1 run in 4). Each test holds this for its run.
static ENV: std::sync::Mutex<()> = std::sync::Mutex::new(());

/// Push the same scripted trail into a fresh log, return the log so the caller
/// can assert against its session + the warehouse it wrote to.
fn drive(log: &ActionLog) {
    // Real input: a realistic edge-triggered trail with real context.
    log.set_context("knut", "Timeline");
    log.push(Kind::Tab, "Timeline → Release");
    log.set_context("knut", "Release");
    log.push(Kind::Click, "↻ reload");
    log.push(Kind::Rpc, "Viz.Timeline knut");
    log.push(Kind::Query, "Search.Query q=\"gatling\"");
    log.push(Kind::Error, "reload failed: connection refused");
}

#[test]
fn action_log_streams_into_warehouse_and_reads_back() {
    let _env = ENV.lock().unwrap_or_else(|e| e.into_inner());
    let dir = tempfile::tempdir().unwrap();
    let wh_root: PathBuf = dir.path().to_path_buf();
    // Don't clobber an operator's real /tmp action log during the test.
    // TODO: Audit that the environment access only happens in single-threaded code.
    unsafe {
        std::env::set_var(
            "NORNIR_VIZ_ACTIONLOG",
            dir.path().join("actions.log").display().to_string(),
        )
    };

    let mut log = ActionLog::new();
    log.attach_warehouse(wh_root.clone());
    let session = log.session_id().to_string();

    drive(&log);
    // Deterministic: close the channel and wait for the drain thread to flush
    // every queued row into the warehouse — no sleeps.
    log.flush();

    // Assert the durable rows landed, scoped to THIS session, in seq order.
    // The durable sink keeps its OWN warehouse in `<root>/viz_actionlog` so it
    // never holds redb's writer lock on the data `catalog.redb` (which would force
    // every data tab into the degraded copy-aside snapshot read path).
    let wh = IcebergWarehouse::open_read_only(&wh_root.join("viz_actionlog")).unwrap();
    let rows =
        query_viz_actions_blocking(&wh, &ActionSelector::Session(session.clone()), None).unwrap();

    // The durable sink captures from the moment it's attached: the first LIFE
    // "action-log started" line is pushed in `new()` BEFORE the warehouse sink
    // exists (it lives only in the /tmp file + ring), so the warehouse trail
    // opens with the re-emitted "durable sink →" LIFE marker (seq 2), then the 5
    // scripted actions (seq 3..=7) = 6 rows.
    assert_eq!(
        rows.len(),
        6,
        "every action pushed after attach is durable in viz_actions"
    );

    // seq is the durable ordering key, strictly increasing, starting at the
    // attach marker (2) — it mirrors the in-memory ring's seq exactly.
    assert_eq!(
        rows.iter().map(|r| r.seq).collect::<Vec<_>>(),
        vec![2, 3, 4, 5, 6, 7],
    );
    assert_eq!(rows[0].kind, "LIFE");
    assert!(
        rows[0]
            .detail
            .starts_with("durable sink → warehouse viz_actions")
    );
    // Every row carries this session's id.
    assert!(rows.iter().all(|r| r.session_id == session));

    // The scripted actions round-trip with the exact kind/context/detail —
    // injected values asserted, not "didn't panic".
    let scripted = &rows[1..]; // skip the durable-sink LIFE marker
    assert_eq!(scripted[0].kind, "TAB");
    assert_eq!(scripted[0].detail, "Timeline → Release");
    assert_eq!(scripted[0].workspace, "knut");

    assert_eq!(scripted[1].kind, "CLICK");
    assert_eq!(scripted[1].detail, "↻ reload");
    // Context advanced to the Release tab before the click was pushed.
    assert_eq!(scripted[1].tab, "Release");

    assert_eq!(scripted[2].kind, "RPC");
    assert_eq!(scripted[2].detail, "Viz.Timeline knut");

    assert_eq!(scripted[3].kind, "QUERY");
    assert_eq!(scripted[3].detail, "Search.Query q=\"gatling\"");

    assert_eq!(scripted[4].kind, "ERROR");
    assert_eq!(scripted[4].detail, "reload failed: connection refused");

    // ts is a real UTC microsecond stamp (monotonic across the trail).
    assert!(rows.windows(2).all(|w| w[1].ts_micros >= w[0].ts_micros));

    // The viz's OWN read-back view (the durable history surfaced in state_json)
    // returns this session's actions as a parseable JSON array carrying the
    // injected values.
    let json = log.warehouse_recent_json(12);
    let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
    let arr = parsed.as_array().unwrap();
    assert_eq!(arr.len(), 6);
    assert_eq!(arr.last().unwrap()["kind"], "ERROR");
    assert_eq!(
        arr.last().unwrap()["detail"],
        "reload failed: connection refused"
    );
    assert!(arr.iter().all(|o| o["session_id"] == session.as_str()));
}

#[test]
fn tmp_file_remains_the_live_channel_alongside_the_warehouse() {
    let _env = ENV.lock().unwrap_or_else(|e| e.into_inner());
    let dir = tempfile::tempdir().unwrap();
    let file_path = dir.path().join("live.log");
    // TODO: Audit that the environment access only happens in single-threaded code.
    unsafe { std::env::set_var("NORNIR_VIZ_ACTIONLOG", file_path.display().to_string()) };

    let mut log = ActionLog::new();
    log.attach_warehouse(dir.path().to_path_buf());
    drive(&log);
    log.flush();

    // LAW: write both — the fast local /tmp file still carries the same trail.
    let file = std::fs::read_to_string(&file_path).unwrap();
    assert!(
        file.contains("[TAB] Timeline → Release"),
        "tab switch in the live file"
    );
    assert!(file.contains("[CLICK] ↻ reload"), "click in the live file");
    assert!(file.contains("[ERROR] reload failed: connection refused"));
    // The greppable per-line format is intact (stamp + seq + tag).
    assert!(file.lines().any(|l| l.contains("[RPC] Viz.Timeline knut")));
}