diff --git a/tests/fixtures/fake_test_runner.rs b/tests/fixtures/fake_test_runner.rs new file mode 100644 index 0000000000..cc2f25a3ba --- /dev/null +++ b/tests/fixtures/fake_test_runner.rs @@ -0,0 +1,32 @@ +//! Native fake for CLI routing tests: records the executable and argument vector +//! without requiring Node, a shell, a package registry, or installed JS runners. +use std::env; +use std::fs::OpenOptions; +use std::io::Write; + +fn main() { + let executable = env::current_exe().expect("current executable"); + let name = executable.file_stem().expect("executable name"); + let mut trace = OpenOptions::new() + .create(true) + .append(true) + .open(env::var_os("RTK_TEST_TRACE").expect("trace path")) + .expect("open trace"); + writeln!(trace, "{}", name.to_string_lossy()).expect("record executable"); + for arg in env::args().skip(1) { + writeln!(trace, "{arg}").expect("record argument"); + } + writeln!(trace, "END").expect("record invocation end"); + std::io::stdout() + .write_all(env::var("RTK_TEST_STDOUT").unwrap_or_default().as_bytes()) + .expect("write stdout"); + std::io::stderr() + .write_all(env::var("RTK_TEST_STDERR").unwrap_or_default().as_bytes()) + .expect("write stderr"); + std::process::exit( + env::var("RTK_TEST_EXIT") + .expect("exit code") + .parse() + .expect("numeric exit code"), + ); +} diff --git a/tests/framework_test_routing_test.rs b/tests/framework_test_routing_test.rs new file mode 100644 index 0000000000..7007eaecd1 --- /dev/null +++ b/tests/framework_test_routing_test.rs @@ -0,0 +1,245 @@ +//! Exercise the public CLI, package-manager selection, spawn and output wiring. +//! A native Rust fake runs on Unix and Windows; no JS tools or downloads are used. +use std::fs; +use std::path::Path; +use std::process::{Command, Output}; +use std::sync::LazyLock; + +mod common; + +const PASS_JSON: &str = + r#"{"numTotalTests":2,"numPassedTests":2,"numFailedTests":0,"testResults":[]}"#; +const FAIL_JSON: &str = r#"{"numTotalTests":1,"numPassedTests":0,"numFailedTests":1,"testResults":[{"name":"src/fail.test.ts","assertionResults":[{"fullName":"fails deliberately","status":"failed","failureMessages":["expected true, got false"]}]}]}"#; + +// Compile once using the same Rust toolchain required to run the test suite. +// Keep bytes rather than a TempDir in a static so the compiler's scratch files +// are cleaned up, and every fixture owns its own executables. +static FAKE_RUNNER: LazyLock> = LazyLock::new(|| { + let dir = tempfile::tempdir().expect("compiler scratch directory"); + let output = dir + .path() + .join(format!("fake{}", std::env::consts::EXE_SUFFIX)); + let result = Command::new(std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into())) + .arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/fake_test_runner.rs")) + .args(["--edition=2024", "--deny", "warnings", "-o"]) + .arg(&output) + .output() + .expect("compile native fake runner"); + assert!( + result.status.success(), + "compile fake runner: {}", + String::from_utf8_lossy(&result.stderr) + ); + fs::read(output).expect("read native fake runner") +}); + +struct Fixture { + dir: tempfile::TempDir, +} + +impl Fixture { + fn new(direct_tools: bool, lockfile: Option<&str>) -> Self { + let dir = tempfile::tempdir().expect("fixture directory"); + let bin = dir.path().join("bin"); + fs::create_dir(&bin).expect("fixture bin"); + // Install all runner fakes so choosing the wrong one is observable too. + let mut names = vec!["pnpm", "yarn", "npx", "bunx"]; + if direct_tools { + names.extend(["vitest", "jest"]); + } + for name in names { + let path = bin.join(format!("{name}{}", std::env::consts::EXE_SUFFIX)); + fs::write(&path, &*FAKE_RUNNER).expect("write fake executable"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o755)) + .expect("make fake executable"); + } + } + if let Some(lockfile) = lockfile { + fs::write(dir.path().join(lockfile), "").expect("write lockfile"); + } + Self { dir } + } + + fn run(&self, args: &[&str], stdout: &str, stderr: &str, exit: i32) -> Output { + common::rtk_command() + .args(args) + .current_dir(self.dir.path()) + // No inherited PATH: an installed runner must never defeat the fake + // or cause these tests to execute/download real JavaScript packages. + .env("PATH", self.dir.path().join("bin")) + .env("PATHEXT", ".EXE") + .env("NO_COLOR", "1") + .env("RTK_TEST_TRACE", self.dir.path().join("trace")) + .env("RTK_TEST_STDOUT", stdout) + .env("RTK_TEST_STDERR", stderr) + .env("RTK_TEST_EXIT", exit.to_string()) + .output() + .expect("run rtk") + } + + fn assert_invocation(&self, expected: &[&str]) { + let trace = fs::read_to_string(self.dir.path().join("trace")).expect("read spawn trace"); + let expected: Vec<_> = expected.iter().copied().chain(["END"]).collect(); + assert_eq!(trace.lines().collect::>(), expected); + } +} + +fn framework_args(framework: &str) -> Vec<&str> { + match framework { + "vitest" => vec!["run", "--reporter=json"], + "jest" => vec!["--no-watch", "--json"], + _ => panic!("unknown test framework"), + } +} + +#[test] +fn cli_dispatches_each_framework_to_its_own_executable_and_arguments() { + for framework in ["vitest", "jest"] { + // A lockfile must not displace a runner already available on PATH. + let fixture = Fixture::new(true, Some("pnpm-lock.yaml")); + let user_args = ["src/has space.test.ts", "-t", "works with spaces"]; + let args: Vec<_> = [framework].into_iter().chain(user_args).collect(); + let out = fixture.run(&args, PASS_JSON, "", 0); + let mut expected = vec![framework]; + expected.extend(framework_args(framework)); + expected.extend(user_args); + fixture.assert_invocation(&expected); + assert_eq!(out.status.code(), Some(0)); + assert_eq!(out.stdout, b"PASS (2) FAIL (0)\n"); + assert!(out.stderr.is_empty()); + } +} + +#[test] +fn missing_framework_uses_the_projects_package_manager_without_fetching() { + for (lockfile, prefix) in [ + (Some("pnpm-lock.yaml"), vec!["pnpm", "exec", "--"]), + (Some("yarn.lock"), vec!["yarn", "exec", "--"]), + (Some("bun.lock"), vec!["npx", "--no-install", "--"]), + (None, vec!["npx", "--no-install", "--"]), + ] { + for framework in ["vitest", "jest"] { + let fixture = Fixture::new(false, lockfile); + let out = fixture.run(&[framework, "src/a.test.ts"], PASS_JSON, "", 0); + let mut expected = prefix.clone(); + expected.push(framework); + expected.extend(framework_args(framework)); + expected.push("src/a.test.ts"); + fixture.assert_invocation(&expected); + assert_eq!(out.status.code(), Some(0)); + assert_eq!(out.stdout, b"PASS (2) FAIL (0)\n"); + assert!(out.stderr.is_empty()); + } + } +} + +#[test] +fn explicit_vitest_reporter_reaches_the_spawn_and_bypasses_parsing() { + let verbose = + " ✓ src/a.test.ts > keeps this test name\n\n Tests 2 passed (2)\n Duration 10ms\n"; + let json_line = format!("{PASS_JSON}\n"); + for (reporter, raw, expected_stdout) in [ + (vec!["--reporter=verbose"], verbose, verbose), + (vec!["--reporter", "verbose"], verbose, verbose), + // An explicitly requested JSON report must also remain JSON. + (vec!["--reporter=json"], PASS_JSON, json_line.as_str()), + ] { + let fixture = Fixture::new(true, None); + let mut args = vec!["vitest", "src/a.test.ts"]; + args.extend(&reporter); + let out = fixture.run(&args, raw, "", 0); + let mut expected = vec!["vitest", "run", "src/a.test.ts"]; + expected.extend(&reporter); + fixture.assert_invocation(&expected); + assert_eq!(out.status.code(), Some(0)); + // Terminated reports stay byte-exact; only a missing final newline is added. + assert_eq!(out.stdout, expected_stdout.as_bytes()); + assert!(out.stderr.is_empty()); + } +} + +#[test] +fn framework_failures_keep_the_child_exit_status() { + for framework in ["vitest", "jest"] { + let fixture = Fixture::new(true, None); + let out = fixture.run(&[framework], FAIL_JSON, "", 7); + let mut expected = vec![framework]; + expected.extend(framework_args(framework)); + fixture.assert_invocation(&expected); + assert_eq!(out.status.code(), Some(7)); + let shown = String::from_utf8_lossy(&out.stdout); + assert!(shown.contains("FAIL (1)"), "{shown}"); + assert!(shown.contains("fails deliberately"), "{shown}"); + assert!(shown.contains("expected true, got false"), "{shown}"); + assert!(out.stderr.is_empty()); + } +} + +#[test] +fn explicit_reporter_keeps_stderr_diagnostics_and_failure_status() { + let fixture = Fixture::new(true, None); + let stdout = " Tests 2 passed (2)\n"; + let stderr = "reporter teardown failed\n"; + let out = fixture.run(&["vitest", "--reporter=verbose"], stdout, stderr, 3); + fixture.assert_invocation(&["vitest", "run", "--reporter=verbose"]); + assert_eq!(out.status.code(), Some(3)); + assert_eq!(out.stdout, format!("{stdout}{stderr}").as_bytes()); + assert!(out.stderr.is_empty()); +} + +#[test] +fn native_spawn_preserves_argument_boundaries_and_metacharacters() { + for framework in ["vitest", "jest"] { + let fixture = Fixture::new(true, None); + let user_args = [ + "src/a.test.ts", + "--", + "-literal", + "a;$(echo unsafe)&b", + "quote\"and\\slash", + ]; + let args: Vec<_> = [framework].into_iter().chain(user_args).collect(); + let out = fixture.run(&args, PASS_JSON, "", 0); + let mut expected = vec![framework]; + expected.extend(framework_args(framework)); + expected.extend(user_args); + fixture.assert_invocation(&expected); + assert_eq!(out.status.code(), Some(0)); + assert_eq!(out.stdout, b"PASS (2) FAIL (0)\n"); + assert!(out.stderr.is_empty()); + } +} + +#[test] +fn reporter_passthrough_also_reaches_the_package_manager() { + let fixture = Fixture::new(false, Some("pnpm-lock.yaml")); + let out = fixture.run(&["vitest", "--reporter", "json"], PASS_JSON, "", 0); + fixture.assert_invocation(&["pnpm", "exec", "--", "vitest", "run", "--reporter", "json"]); + assert_eq!(out.status.code(), Some(0)); + assert_eq!( + String::from_utf8_lossy(&out.stdout), + format!("{PASS_JSON}\n") + ); + assert!(out.stderr.is_empty()); +} + +#[test] +fn explicit_reporter_preserves_empty_and_terminated_output() { + for (raw, expected) in [ + ("", ""), + ("report", "report\n"), + ("report\n", "report\n"), + ("report\n\n", "report\n\n"), + ("report\r\n", "report\r\n"), + ] { + let fixture = Fixture::new(true, None); + let out = fixture.run(&["vitest", "--reporter=verbose"], raw, "", 0); + fixture.assert_invocation(&["vitest", "run", "--reporter=verbose"]); + assert_eq!(out.status.code(), Some(0)); + assert_eq!(out.stdout, expected.as_bytes(), "report input: {raw:?}"); + assert!(out.stderr.is_empty()); + } +}