//! Integration tests for `flowrsc`. //! //! Each test builds a real `Graph`, serializes it to a temp JSON file, and drives //! `run()` over that file — the same path the binary takes, minus the process spawn. //! Assertions are on the actual exit code and captured output. use std::path::{Path, PathBuf}; use flowrs_cli::{Cli, Command, EXIT_INVALID, EXIT_OK, EXIT_USAGE, run}; use flowrs_core::{Graph, WireMode}; use tempfile::TempDir; /// Write `graph` as JSON into `dir` and return the path. fn write_graph(dir: &TempDir, graph: &Graph) -> PathBuf { let path = dir.path().join("test.flow"); std::fs::write( &path, serde_json::to_string(graph).expect("serialize graph"), ) .expect("write graph"); path } /// Run a command, returning `(exit_code, stdout, stderr)`. fn exec(command: Command, quiet: bool) -> (i32, String, String) { let cli = Cli { command, quiet }; let (mut out, mut err) = (Vec::new(), Vec::new()); let code = run(&cli, &mut out, &mut err); ( code, String::from_utf8(out).expect("utf8 stdout"), String::from_utf8(err).expect("utf8 stderr"), ) } fn build(graph: &Path, output: Option) -> (i32, String, String) { exec( Command::Build { graph: graph.to_path_buf(), output, }, false, ) } fn check(graph: &Path) -> (i32, String, String) { exec( Command::Check { graph: graph.to_path_buf(), }, false, ) } /// `2 + 3` — two int literals feeding an Add. Legal ownership, real codegen. fn valid_graph() -> Graph { let mut g = Graph::new(); let a = g.add_node("IntLiteral", 0.0, 0.0); let b = g.add_node("IntLiteral", 0.0, 60.0); let add = g.add_node("Add", 150.0, 30.0); g.get_node_mut(a) .unwrap() .properties .insert("value".into(), "2".into()); g.get_node_mut(b) .unwrap() .properties .insert("value".into(), "3".into()); g.add_wire(a, "value", add, "a", WireMode::Move).unwrap(); g.add_wire(b, "value", add, "b", WireMode::Move).unwrap(); g } /// A valid graph compiles: exit 0, and the generated Rust reaches stdout. #[test] fn build_valid_graph_emits_rust_and_exits_zero() { let dir = TempDir::new().unwrap(); let path = write_graph(&dir, &valid_graph()); let (code, stdout, stderr) = build(&path, None); assert_eq!(code, EXIT_OK, "stderr: {stderr}"); assert!( stdout.contains("fn main"), "expected generated Rust on stdout, got:\n{stdout}" ); // Assert on the bound literals and the add expression, not bare "2"/"3" — // generated variable names (v0, v1, v2) would satisfy a naive substring check // even if codegen had emitted an empty stub. // Typed literals: codegen emits `2i64`, not a bare `2`, so that an integer literal // feeding a float-typed port is a compile error rather than a silent inference. assert!(stdout.contains("= 2i64;"), "missing literal 2:\n{stdout}"); assert!(stdout.contains("= 3i64;"), "missing literal 3:\n{stdout}"); assert!( stdout.contains(" + "), "Add node did not emit an addition:\n{stdout}" ); } /// A missing file is a usage error (exit 2), not a graph failure (exit 1). #[test] fn build_missing_file_exits_two() { let dir = TempDir::new().unwrap(); let missing = dir.path().join("does-not-exist.flow"); let (code, _, stderr) = build(&missing, None); assert_eq!(code, EXIT_USAGE); assert!(stderr.contains("cannot read"), "stderr: {stderr}"); } /// Malformed JSON is also exit 2 — we never got far enough to judge the graph. #[test] fn build_bad_json_exits_two() { let dir = TempDir::new().unwrap(); let path = dir.path().join("broken.flow"); std::fs::write(&path, "{ this is not json ]").unwrap(); let (code, _, stderr) = build(&path, None); assert_eq!(code, EXIT_USAGE); assert!(stderr.contains("invalid graph JSON"), "stderr: {stderr}"); } /// Two `Move` wires off one output = a double move. Exit 1, with a diagnostic /// naming the offending node and port. #[test] fn build_ownership_violation_exits_one_with_diagnostic() { let mut g = Graph::new(); let src = g.add_node("IntLiteral", 0.0, 0.0); let x = g.add_node("Negate", 150.0, 0.0); let y = g.add_node("Negate", 150.0, 60.0); g.add_wire(src, "value", x, "input", WireMode::Move) .unwrap(); g.add_wire(src, "value", y, "input", WireMode::Move) .unwrap(); let dir = TempDir::new().unwrap(); let path = write_graph(&dir, &g); let (code, stdout, stderr) = build(&path, None); assert_eq!(code, EXIT_INVALID, "stderr: {stderr}"); assert!( stderr.contains("error: value moved 2 times"), "stderr: {stderr}" ); assert!( stderr.contains(&format!("(node {}, port value)", src.0)), "diagnostic should locate the node/port, got: {stderr}" ); // An invalid graph must not produce output. assert!(stdout.is_empty(), "stdout should be empty, got:\n{stdout}"); } /// `-o` writes the generated Rust to disk and leaves stdout clean. #[test] fn build_with_output_flag_writes_file() { let dir = TempDir::new().unwrap(); let path = write_graph(&dir, &valid_graph()); let out_path = dir.path().join("generated.rs"); let (code, stdout, stderr) = build(&path, Some(out_path.clone())); assert_eq!(code, EXIT_OK, "stderr: {stderr}"); assert!(stdout.is_empty(), "stdout should be empty, got:\n{stdout}"); let written = std::fs::read_to_string(&out_path).expect("output file written"); assert!(written.contains("fn main"), "got:\n{written}"); } /// `check` validates without emitting anything, and reports a clean graph. #[test] fn check_valid_graph_exits_zero_and_writes_no_output() { let dir = TempDir::new().unwrap(); let path = write_graph(&dir, &valid_graph()); let (code, stdout, stderr) = check(&path); assert_eq!(code, EXIT_OK, "stderr: {stderr}"); assert!( stdout.is_empty(), "check must not emit Rust, got:\n{stdout}" ); assert!(stderr.contains("ok: 3 nodes, 2 wires"), "stderr: {stderr}"); } /// `--quiet` drops the status note but keeps the generated Rust. #[test] fn quiet_suppresses_status_but_not_generated_rust() { let dir = TempDir::new().unwrap(); let path = write_graph(&dir, &valid_graph()); let (code, stdout, stderr) = exec( Command::Build { graph: path, output: None, }, true, ); assert_eq!(code, EXIT_OK); assert!(stdout.contains("fn main"), "got:\n{stdout}"); assert!(stderr.is_empty(), "quiet stderr should be empty: {stderr}"); } /// `--quiet` must never hide a real error. #[test] fn quiet_still_reports_violations() { let mut g = Graph::new(); let src = g.add_node("IntLiteral", 0.0, 0.0); let x = g.add_node("Negate", 150.0, 0.0); let y = g.add_node("Negate", 150.0, 60.0); g.add_wire(src, "value", x, "input", WireMode::Move) .unwrap(); g.add_wire(src, "value", y, "input", WireMode::Move) .unwrap(); let dir = TempDir::new().unwrap(); let path = write_graph(&dir, &g); let (code, _, stderr) = exec(Command::Check { graph: path }, true); assert_eq!(code, EXIT_INVALID); assert!(stderr.contains("error:"), "stderr: {stderr}"); } /// Regression: `check` must catch the dataflow-native defects, not just ownership. /// /// The CLI originally validated only via OwnershipTracker, so a cyclic graph passed /// `check` with exit 0 while `build` failed at codegen — the validation command missing /// exactly what it exists to find. It now shares flowrs-core's `diagnostics` with the /// editor, so the two can never disagree about whether a graph is valid. #[test] fn check_catches_a_cycle_not_just_ownership() { let mut g = Graph::new(); let a = g.add_node("Add", 0.0, 0.0); let b = g.add_node("Add", 200.0, 0.0); g.add_wire(a, "result", b, "a", WireMode::Move).unwrap(); g.add_wire(b, "result", a, "a", WireMode::Move).unwrap(); let dir = TempDir::new().unwrap(); let path = write_graph(&dir, &g); let (code, _stdout, stderr) = check(&path); assert_eq!( code, EXIT_INVALID, "a cycle must fail check; stderr: {stderr}" ); // "on a cycle", not "part of a cycle": the detector used to report everything // downstream of a loop with that wording, and most of those claims were false. It now // reports only the loop's own members and names the path. assert!( stderr.contains("on a cycle"), "expected a cycle diagnostic, got: {stderr}" ); } /// An unconnected input is advisory: reported, but it must NOT fail the run. Half-built /// graphs are normal and failing CI on them would make the check useless. #[test] fn unconnected_input_warns_without_failing() { let mut g = Graph::new(); g.add_node("PrintLn", 0.0, 0.0); // `input` fed by nothing let dir = TempDir::new().unwrap(); let path = write_graph(&dir, &g); let (code, _stdout, stderr) = check(&path); assert_eq!(code, EXIT_OK, "a warning must not fail; stderr: {stderr}"); assert!( stderr.contains("warning:") && stderr.contains("not connected"), "expected an unconnected-input warning, got: {stderr}" ); } /// `2.5 + 4.0` as a graph, returning it and the add node's id. fn adder() -> (Graph, u64) { let mut g = Graph::new(); let a = g.add_node("FloatLiteral", 0.0, 0.0); let b = g.add_node("FloatLiteral", 0.0, 0.0); let add = g.add_node("math.add", 0.0, 0.0); g.get_node_mut(a) .unwrap() .properties .insert("value".into(), "2.5".into()); g.get_node_mut(b) .unwrap() .properties .insert("value".into(), "4.0".into()); g.add_wire(a, "value", add, "a", WireMode::Move).unwrap(); g.add_wire(b, "value", add, "b", WireMode::Move).unwrap(); (g, add.0) } fn run_test(graph: &Path, expect: &str, dir: &TempDir) -> (i32, String, String) { let e = dir.path().join("expect.json"); std::fs::write(&e, expect).unwrap(); exec( Command::Test { graph: graph.to_path_buf(), expect: e, tolerance: 1e-9, junit: None, }, false, ) } /// `test --junit`, returning the exit code and the report it wrote (if any). fn run_test_junit( graph: &Path, expect: &str, tolerance: f64, dir: &TempDir, ) -> (i32, Option) { let e = dir.path().join("nominal.json"); std::fs::write(&e, expect).unwrap(); let report = dir.path().join("report.xml"); let _ = std::fs::remove_file(&report); let (code, _, _) = exec( Command::Test { graph: graph.to_path_buf(), expect: e, tolerance, junit: Some(report.clone()), }, false, ); (code, std::fs::read_to_string(&report).ok()) } /// The CI side of `flowrsc test`: the graded expectations as JUnit, failures explained, and /// a report the JUnit reader takes back with the same verdicts. #[test] fn test_writes_the_graded_expectations_as_junit_for_ci() { let dir = TempDir::new().unwrap(); let (g, add) = adder(); let path = write_graph(&dir, &g); let (code, xml) = run_test_junit(&path, &format!("{{\"{add}.sum\": 6.5}}"), 0.25, &dir); assert_eq!(code, EXIT_OK); let xml = xml.expect("a passing run still writes its report"); // The graph is the suite, the expectation file the class. assert!( xml.contains(r#""# )), "{xml}" ); let (code, xml) = run_test_junit( &path, &format!("{{\"{add}.sum\": 7.0, \"{add}.nope\": \"x\"}}"), 0.25, &dir, ); assert_eq!(code, EXIT_INVALID); let xml = xml.expect("a failing run writes its report"); assert!( xml.contains(r#""#), "{xml}" ); // A port that produced nothing is "not measured", never a measured zero. assert!( xml.contains(r#""#), "{xml}" ); assert!(xml.contains(r#"expected "x", got None"#), "{xml}"); let (run, loss) = flowrs_record::junit::from_xml(&xml).expect("the report reads back"); assert!( loss.count_mismatches.is_empty(), "{:?}", loss.count_mismatches ); assert_eq!(run.outcome, flowrs_record::Outcome::Fail); for port in ["sum", "nope"] { let name = format!("nominal.{add}.{port}"); let m = run .measurements() .find(|m| m.name == name) .unwrap_or_else(|| panic!("no {name}")); assert_eq!(m.outcome, flowrs_record::Outcome::Fail, "{name}"); } } /// A run that never reached grading writes no report: a CI system then says the report is /// missing, beside the exit code and the reason on stderr, instead of showing a pass. #[test] fn test_writes_no_junit_when_nothing_was_graded() { let dir = TempDir::new().unwrap(); let (code, xml) = run_test_junit(&dir.path().join("missing.flow"), "{}", 1e-9, &dir); assert_eq!(code, EXIT_USAGE); assert!(xml.is_none()); } #[test] fn test_passes_when_t0_outputs_match() { let dir = TempDir::new().unwrap(); let (g, add) = adder(); let path = write_graph(&dir, &g); let (code, out, _) = run_test(&path, &format!("{{\"{add}.sum\": 6.5}}"), &dir); assert_eq!(code, EXIT_OK, "{out}"); assert!(out.contains(&format!("ok {add}.sum")), "{out}"); } #[test] fn test_fails_on_a_wrong_or_missing_output() { let dir = TempDir::new().unwrap(); let (g, add) = adder(); let path = write_graph(&dir, &g); let (code, out, _) = run_test(&path, &format!("{{\"{add}.sum\": 7.0}}"), &dir); assert_eq!(code, EXIT_INVALID, "{out}"); assert!(out.contains("FAIL"), "{out}"); let (code, out, _) = run_test(&path, &format!("{{\"{add}.nope\": 1}}"), &dir); assert_eq!(code, EXIT_INVALID, "{out}"); let (code, _, _) = run_test(&path, "[1, 2]", &dir); assert_eq!(code, EXIT_USAGE); } #[test] fn diff_ignores_layout_and_reports_semantic_changes() { let dir = TempDir::new().unwrap(); let (g, add) = adder(); let old = write_graph(&dir, &g); let mut moved = g.clone(); for n in moved.nodes.values_mut() { n.position = (100.0, 50.0); } let same = dir.path().join("moved.flow"); std::fs::write(&same, serde_json::to_string(&moved).unwrap()).unwrap(); let (code, out, _) = exec( Command::Diff { old: old.clone(), new: same, }, false, ); assert_eq!(code, EXIT_OK, "{out}"); assert!(out.is_empty(), "{out}"); let mut edited = g.clone(); let lit = edited .nodes .values() .find(|n| n.properties.get("value").map(String::as_str) == Some("4.0")) .unwrap() .id; edited .get_node_mut(lit) .unwrap() .properties .insert("value".into(), "5.0".into()); let p = edited.add_node("Print", 0.0, 0.0); edited .add_wire(flowrs_core::NodeId(add), "sum", p, "input", WireMode::Move) .unwrap(); let new = dir.path().join("edited.flow"); std::fs::write(&new, serde_json::to_string(&edited).unwrap()).unwrap(); let (code, out, _) = exec(Command::Diff { old, new }, false); assert_eq!(code, EXIT_INVALID, "{out}"); assert!(out.contains("value: \"4.0\" -> \"5.0\""), "{out}"); assert!(out.contains("+ node") && out.contains("Print"), "{out}"); assert!(out.contains(&format!("+ wire {add}.sum ->")), "{out}"); } fn app(graph: &Path, output: &Path, name: Option<&str>) -> (i32, String, String) { exec( Command::App { graph: graph.to_path_buf(), output: output.to_path_buf(), name: name.map(str::to_string), linux: false, steps: None, flowrs: Some(PathBuf::from("/src/flowRS")), git: None, rev: None, }, false, ) } /// `flowrsc app` writes the program, its app and the shell; a `.flowrs` document's own app /// goes with it, and one that fails its check is refused with the reason. #[test] fn app_exports_the_program_and_its_app_and_refuses_an_app_that_fails() { let dir = TempDir::new().unwrap(); let doc = dir.path().join("sum.flowrs"); let g = valid_graph(); std::fs::write(&doc, flowrs_app::save(&g, None)).unwrap(); let out = dir.path().join("sum"); let (code, _, err) = app(&doc, &out, Some("Sum")); assert_eq!(code, EXIT_OK, "{err}"); for f in [ "Cargo.toml", "src/main.rs", "ui.oui", "ui/app.json", "ui/tokens.css", "app/Cargo.toml", "app/src/main.rs", ] { assert!(out.join(f).is_file(), "{f} missing: {err}"); } assert!(err.contains("cargo run --release -p sum-app"), "{err}"); // The inferred app, titled from the document's file as the editor titles it. let source = std::fs::read_to_string(out.join("ui.oui")).unwrap(); let opts = flowrs_app::infer::Options { file: Some("sum.flowrs".into()), ..Default::default() }; assert_eq!( source.trim_end(), flowrs_app::infer::infer_source_with(&g, opts) ); assert!(source.contains("Heading(\"Sum\")"), "{source}"); let bad = flowrs_app::app("root = Stack([Stat(\"Nothing\", n99.out)])"); std::fs::write(&doc, flowrs_app::save(&g, Some(&bad))).unwrap(); let (code, _, err) = app(&doc, &dir.path().join("bad"), None); assert_eq!(code, EXIT_INVALID, "{err}"); assert!(err.contains("does not pass its check"), "{err}"); assert!(!dir.path().join("bad").exists()); let (code, _, _) = app(&dir.path().join("missing.flowrs"), &out, None); assert_eq!(code, EXIT_USAGE); } /// Every file under `dir`, by its path relative to `dir` (`/`-separated), with its bytes. fn files_under(dir: &Path) -> std::collections::BTreeMap> { let mut out = std::collections::BTreeMap::new(); let mut stack = vec![dir.to_path_buf()]; while let Some(d) = stack.pop() { for e in std::fs::read_dir(&d).unwrap() { let p = e.unwrap().path(); if p.is_dir() { stack.push(p); } else { let rel = p.strip_prefix(dir).unwrap(); let rel: Vec = rel .components() .map(|c| c.as_os_str().to_string_lossy().into_owned()) .collect(); out.insert(rel.join("/"), std::fs::read(&p).unwrap()); } } } out } /// A stored (no compression) zip's entries, read from its central directory and local /// headers, each entry's CRC-32 checked: an independent reader for the editor's zip. fn unzip_stored(zip: &[u8]) -> std::collections::BTreeMap> { let u16_at = |i: usize| u16::from_le_bytes([zip[i], zip[i + 1]]) as usize; let u32_at = |i: usize| u32::from_le_bytes([zip[i], zip[i + 1], zip[i + 2], zip[i + 3]]); let crc32 = |data: &[u8]| { let mut crc: u32 = 0xFFFF_FFFF; for &b in data { crc ^= u32::from(b); for _ in 0..8 { crc = if crc & 1 == 1 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 }; } } !crc }; let eocd = zip .windows(4) .rposition(|w| w == [0x50, 0x4b, 0x05, 0x06]) .expect("an end of central directory"); let count = u16_at(eocd + 10); let mut at = u32_at(eocd + 16) as usize; let mut out = std::collections::BTreeMap::new(); for _ in 0..count { assert_eq!(u32_at(at), 0x0201_4b50, "a central directory entry"); assert_eq!(u16_at(at + 10), 0, "stored, not compressed"); let crc = u32_at(at + 16); let size = u32_at(at + 20) as usize; let name_len = u16_at(at + 28); let extra = u16_at(at + 30); let comment = u16_at(at + 32); let local = u32_at(at + 42) as usize; let name = String::from_utf8(zip[at + 46..at + 46 + name_len].to_vec()).unwrap(); assert_eq!(u32_at(local), 0x0403_4b50, "{name}: a local header"); let data_at = local + 30 + u16_at(local + 26) + u16_at(local + 28); let data = zip[data_at..data_at + size].to_vec(); assert_eq!(crc32(&data), crc, "{name}: its CRC-32"); out.insert(name, data); at += 46 + name_len + extra + comment; } out } /// The editor's "Export project" and `flowrsc app` write the same project for the same /// document, byte for byte, flowRS's vendored crates included. For every app fixture: /// `flowrsc app ` with its defaults writes a directory; the editor's path (the /// exporter's `document_with`, over the same text and file name, and the runtime bundle as /// the editor fetches it, this build's bundle through its JSON) builds the files it zips; the /// two are compared file by file, and the zip the editor downloads holds exactly those files, /// under the package's folder. No project names a git repository, the forge least of all. #[test] fn the_editors_export_is_flowrsc_apps_project_byte_for_byte() { use flowrs_appshell::export::{self, Options}; use flowrs_appshell::runtime::Runtime; // What the IDE's build writes beside its page, read back as the IDE reads it. let fetched = Runtime::from_json(&Runtime::this_build().unwrap().to_json()).unwrap(); let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../e2e/fixtures/apps"); let dir = TempDir::new().unwrap(); let mut docs: Vec = std::fs::read_dir(&fixtures) .unwrap() .map(|e| e.unwrap().path()) .filter(|p| p.extension().is_some_and(|x| x == "flowrs")) .collect(); docs.sort(); let (mut compared, mut refused) = (0, 0); for doc in &docs { let file = doc.file_name().unwrap().to_string_lossy().into_owned(); let text = std::fs::read_to_string(doc).unwrap(); let out = dir.path().join(doc.file_stem().unwrap()); let (code, _, err) = exec( Command::App { graph: doc.clone(), output: out.clone(), name: None, linux: false, steps: None, flowrs: None, git: None, rev: None, }, true, ); let editor = export::document_with(&text, Some(&file), &Options::default(), Some(&fetched)); let Ok(editor) = editor else { // What the editor refuses, flowrsc refuses too, and writes nothing. assert_ne!( code, EXIT_OK, "{file}: flowrsc exported what the editor refused" ); assert!(!out.exists(), "{file}"); refused += 1; continue; }; assert_eq!(code, EXIT_OK, "{file}: {err}"); let written = files_under(&out); let built: std::collections::BTreeMap> = editor .scaffold .files .iter() .map(|f| (f.path.clone(), f.contents.clone().into_bytes())) .collect(); assert_eq!( written.keys().collect::>(), built.keys().collect::>(), "{file}: the same files" ); for (path, bytes) in &written { assert!(built[path] == *bytes, "{file}: {path} differs"); } let zipped = unzip_stored(&export::zip(&editor)); let expected: std::collections::BTreeMap> = built .iter() .map(|(p, b)| (format!("{}/{p}", editor.package), b.clone())) .collect(); assert_eq!(zipped, expected, "{file}: the zip holds the project"); // Its crates are carried in it, from this build's commit, not taken from a path // here or from any repository. let toml = String::from_utf8(written["app/Cargo.toml"].clone()).unwrap(); assert!( toml.contains("flowrs-appshell = { path = \"../vendor/flowrs-appshell\" }"), "{toml}" ); for (path, bytes) in &written { if path.ends_with("Cargo.toml") { let t = String::from_utf8_lossy(bytes); assert!(!t.contains("git.transaction.science"), "{file}: {path}"); assert!(!t.contains("git ="), "{file}: {path}"); assert!(!t.contains("path = \"/"), "{file}: {path}"); } } for want in [ "vendor/README.md", "vendor/flowrs-appshell/Cargo.toml", "vendor/flowrs-appshell/LICENSE-MIT", "vendor/flowrs-appshell/LICENSE-APACHE", "vendor/flowrs-appshell/NOTICE", "vendor/flowrs-openui/LICENSES/OpenUI-MIT.txt", "ui/OFL.txt", ] { assert!(written.contains_key(want), "{file}: no {want}"); } let vendored = String::from_utf8(written["vendor/README.md"].clone()).unwrap(); assert!( vendored.contains(&format!("flowRS commit `{}`", export::REV)), "{vendored}" ); // The same project the in-process bundle gives (flowrsc's and the MCP tool's path). let direct = export::document(&text, Some(&file), &Options::default()).unwrap(); assert_eq!(direct, editor, "{file}"); let readme = String::from_utf8(written["README.md"].clone()).unwrap(); assert!( readme.contains(&format!("cargo run --release -p {}-app", editor.package)), "{readme}" ); compared += 1; } assert!(compared >= 10, "{compared} documents compared"); assert_eq!(compared + refused, docs.len()); }