715 lines
26 KiB
Rust
715 lines
26 KiB
Rust
//! 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<PathBuf>) -> (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<String>) {
|
|
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#"<testsuite name="test" tests="1" failures="0""#),
|
|
"{xml}"
|
|
);
|
|
assert!(
|
|
xml.contains(&format!(
|
|
r#"<testcase name="{add}.sum" classname="nominal">"#
|
|
)),
|
|
"{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#"<failure message="measured 6.5; limit [6.75, 7.25]" type="measurement">"#),
|
|
"{xml}"
|
|
);
|
|
// A port that produced nothing is "not measured", never a measured zero.
|
|
assert!(
|
|
xml.contains(r#"<failure message="not measured" type="measurement">"#),
|
|
"{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<String, Vec<u8>> {
|
|
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<String> = 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<String, Vec<u8>> {
|
|
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 <fixture>` 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<PathBuf> = 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<String, Vec<u8>> = editor
|
|
.scaffold
|
|
.files
|
|
.iter()
|
|
.map(|f| (f.path.clone(), f.contents.clone().into_bytes()))
|
|
.collect();
|
|
assert_eq!(
|
|
written.keys().collect::<Vec<_>>(),
|
|
built.keys().collect::<Vec<_>>(),
|
|
"{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<String, Vec<u8>> = 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());
|
|
}
|