mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-06 19:35:13 +02:00
145 lines
4.7 KiB
Rust
145 lines
4.7 KiB
Rust
|
|
//! Phase 23 — `nyx surface` subcommand smoke tests.
|
||
|
|
//!
|
||
|
|
//! Builds a [`SurfaceMap`] against the Phase 21 Flask fixture, renders
|
||
|
|
//! it via the three text-mode formatters (text / json / dot) and asserts
|
||
|
|
//! the output matches the recorded golden file and contains the
|
||
|
|
//! expected structural markers.
|
||
|
|
|
||
|
|
use nyx_scanner::callgraph::CallGraph;
|
||
|
|
use nyx_scanner::commands::surface::{load_or_build, render_dot, render_text};
|
||
|
|
use nyx_scanner::summary::GlobalSummaries;
|
||
|
|
use nyx_scanner::surface::{
|
||
|
|
SurfaceMap,
|
||
|
|
build::{SurfaceBuildInputs, build_surface_map},
|
||
|
|
};
|
||
|
|
use nyx_scanner::utils::config::Config;
|
||
|
|
use std::path::{Path, PathBuf};
|
||
|
|
|
||
|
|
const FLASK_FIXTURE: &str = "tests/dynamic_fixtures/surface/python_flask";
|
||
|
|
const GOLDEN_PATH: &str = "tests/dynamic_fixtures/surface/cli_output.golden.txt";
|
||
|
|
|
||
|
|
fn empty_call_graph() -> CallGraph {
|
||
|
|
CallGraph {
|
||
|
|
graph: petgraph::graph::DiGraph::new(),
|
||
|
|
index: Default::default(),
|
||
|
|
unresolved_not_found: vec![],
|
||
|
|
unresolved_ambiguous: vec![],
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
|
||
|
|
let entries = match std::fs::read_dir(dir) {
|
||
|
|
Ok(e) => e,
|
||
|
|
Err(_) => return,
|
||
|
|
};
|
||
|
|
for entry in entries.flatten() {
|
||
|
|
let path = entry.path();
|
||
|
|
if path.is_dir() {
|
||
|
|
walk(&path, out);
|
||
|
|
} else {
|
||
|
|
out.push(path);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn flask_map() -> (SurfaceMap, PathBuf) {
|
||
|
|
let dir = Path::new(FLASK_FIXTURE).to_path_buf();
|
||
|
|
let mut files = Vec::new();
|
||
|
|
walk(&dir, &mut files);
|
||
|
|
let cfg = Config::default();
|
||
|
|
let gs = GlobalSummaries::new();
|
||
|
|
let cg = empty_call_graph();
|
||
|
|
let inputs = SurfaceBuildInputs {
|
||
|
|
files: &files,
|
||
|
|
scan_root: Some(&dir),
|
||
|
|
global_summaries: &gs,
|
||
|
|
call_graph: &cg,
|
||
|
|
config: &cfg,
|
||
|
|
};
|
||
|
|
let map = build_surface_map(&inputs);
|
||
|
|
(map, dir)
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn text_output_matches_golden_for_flask_fixture() {
|
||
|
|
let (map, dir) = flask_map();
|
||
|
|
// The golden file was recorded with no scan root prefix so it
|
||
|
|
// stays valid across machines. Pass `None` so the renderer
|
||
|
|
// produces the same fixed header.
|
||
|
|
let actual = render_text(&map, None);
|
||
|
|
|
||
|
|
// Refresh the golden when running with UPDATE_GOLDEN=1. Useful
|
||
|
|
// when intentionally changing the formatter; mirrors the
|
||
|
|
// convention used elsewhere in the test suite.
|
||
|
|
if std::env::var("UPDATE_GOLDEN").ok().as_deref() == Some("1") {
|
||
|
|
std::fs::write(GOLDEN_PATH, &actual).unwrap();
|
||
|
|
}
|
||
|
|
|
||
|
|
let expected = std::fs::read_to_string(GOLDEN_PATH)
|
||
|
|
.expect("read tests/dynamic_fixtures/surface/cli_output.golden.txt");
|
||
|
|
assert_eq!(
|
||
|
|
actual,
|
||
|
|
expected,
|
||
|
|
"render_text output drifted from golden; re-run with UPDATE_GOLDEN=1 if intentional.\nfixture: {}",
|
||
|
|
dir.display()
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn dot_output_contains_entry_and_digraph_header() {
|
||
|
|
let (map, _) = flask_map();
|
||
|
|
let dot = render_dot(&map);
|
||
|
|
assert!(dot.starts_with("digraph nyx_surface"), "{dot}");
|
||
|
|
assert!(dot.contains("GET /users"), "DOT missing entry route: {dot}");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn json_output_round_trips_byte_identical() {
|
||
|
|
let (mut map, _) = flask_map();
|
||
|
|
let bytes = map.to_json().expect("canonical JSON");
|
||
|
|
let mut rt = SurfaceMap::from_json(&bytes).expect("from_json");
|
||
|
|
let rt_bytes = rt.to_json().expect("re-serialise");
|
||
|
|
assert_eq!(
|
||
|
|
bytes, rt_bytes,
|
||
|
|
"canonical JSON must round-trip identically"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn load_or_build_falls_back_to_filesystem_when_no_db() {
|
||
|
|
let tmp = tempfile::tempdir().unwrap();
|
||
|
|
let py = tmp.path().join("app.py");
|
||
|
|
std::fs::write(
|
||
|
|
&py,
|
||
|
|
"from flask import Flask\napp = Flask(__name__)\n@app.get('/u')\ndef u(): pass\n",
|
||
|
|
)
|
||
|
|
.unwrap();
|
||
|
|
let db_dir = tempfile::tempdir().unwrap();
|
||
|
|
let cfg = Config::default();
|
||
|
|
let map = load_or_build(tmp.path(), db_dir.path(), &cfg).expect("load_or_build");
|
||
|
|
assert!(
|
||
|
|
map.entry_points().next().is_some(),
|
||
|
|
"expected at least one entry-point in fallback path"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Phase 21 follow-up: the non-indexed scan path now returns the
|
||
|
|
/// SurfaceMap built during pass 2 alongside the diagnostics, so
|
||
|
|
/// consumers can avoid re-running the analysis to render the surface.
|
||
|
|
#[test]
|
||
|
|
fn scan_no_index_with_surface_map_returns_entry_points() {
|
||
|
|
let tmp = tempfile::tempdir().unwrap();
|
||
|
|
std::fs::write(
|
||
|
|
tmp.path().join("app.py"),
|
||
|
|
"from flask import Flask\napp = Flask(__name__)\n@app.get('/x')\ndef x(): pass\n",
|
||
|
|
)
|
||
|
|
.unwrap();
|
||
|
|
let cfg = Config::default();
|
||
|
|
let (_diags, map) = nyx_scanner::scan_no_index_with_surface_map(tmp.path(), &cfg)
|
||
|
|
.expect("scan_no_index_with_surface_map should succeed");
|
||
|
|
assert!(
|
||
|
|
map.entry_points().next().is_some(),
|
||
|
|
"expected at least one entry-point in returned SurfaceMap"
|
||
|
|
);
|
||
|
|
}
|