// ========================================================================== // Script Execution Tests // ========================================================================== // // These tests extract a real Anvil project to a temp directory and then // ACTUALLY EXECUTE the generated scripts. They are not fast and they // require real build-machine dependencies: // // test.sh / test/run_tests.sh --> cmake, g++ (or clang++), git // build.sh --> arduino-cli (with arduino:avr core) // // If a dependency is missing, the test is SKIPPED (shown as "ignored" // in cargo test output). Detection happens at compile time via build.rs // which sets cfg flags: has_cmake, has_cpp_compiler, has_git, // has_arduino_cli. // // On Windows the .bat variants are tested instead. // ========================================================================== use std::fs; use std::path::Path; use std::process::Command; use tempfile::TempDir; use serial_test::serial; use anvil::templates::{TemplateContext, TemplateManager}; use anvil::version::ANVIL_VERSION; // -------------------------------------------------------------------------- // Helpers // -------------------------------------------------------------------------- /// Build a TemplateContext with sensible defaults for testing. fn test_context(name: &str) -> TemplateContext { TemplateContext { project_name: name.to_string(), anvil_version: ANVIL_VERSION.to_string(), board_name: "Arduino Uno (ATmega328P)".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, } } /// Extract a fresh project into a temp directory and return the TempDir. fn extract_project(name: &str) -> TempDir { let tmp = TempDir::new().expect("Failed to create temp directory"); let ctx = test_context(name); let count = TemplateManager::extract("basic", tmp.path(), &ctx) .expect("Failed to extract template"); assert!(count > 0, "Template extraction produced zero files"); tmp } /// Make all .sh files under `root` executable (Unix only). #[cfg(unix)] fn chmod_scripts(root: &Path) { chmod_recursive(root); } #[cfg(unix)] fn chmod_recursive(dir: &Path) { use std::os::unix::fs::PermissionsExt; let entries = match fs::read_dir(dir) { Ok(e) => e, Err(_) => return, }; for entry in entries.filter_map(|e| e.ok()) { let path = entry.path(); if path.is_dir() { chmod_recursive(&path); } else if path.extension().map(|e| e == "sh").unwrap_or(false) { let mut perms = match fs::metadata(&path) { Ok(m) => m.permissions(), Err(_) => continue, }; perms.set_mode(0o755); let _ = fs::set_permissions(&path, perms); } } } /// Run a shell script (bash on Unix, cmd /C on Windows). /// Returns (success, stdout, stderr). fn run_script(dir: &Path, script: &str) -> (bool, String, String) { let output = if cfg!(windows) { Command::new("cmd") .args(["/C", script]) .current_dir(dir) .output() } else { Command::new("bash") .arg(script) .current_dir(dir) .output() }; let output = output.unwrap_or_else(|e| panic!("Failed to execute {}: {}", script, e)); let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string(); (output.status.success(), stdout, stderr) } /// Run a script with extra arguments. fn run_script_with_args(dir: &Path, script: &str, args: &[&str]) -> (bool, String, String) { let output = if cfg!(windows) { let mut all_args = vec!["/C", script]; all_args.extend_from_slice(args); Command::new("cmd") .args(&all_args) .current_dir(dir) .output() } else { Command::new("bash") .arg(script) .args(args) .current_dir(dir) .output() }; let output = output.unwrap_or_else(|e| panic!("Failed to execute {}: {}", script, e)); let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string(); (output.status.success(), stdout, stderr) } /// Platform-appropriate script paths. fn root_test_script() -> &'static str { if cfg!(windows) { "test.bat" } else { "test.sh" } } fn inner_test_script() -> &'static str { if cfg!(windows) { "test\\run_tests.bat" } else { "test/run_tests.sh" } } fn build_script() -> &'static str { if cfg!(windows) { "build.bat" } else { "build.sh" } } /// Recursively list directory contents using only std::fs (no external crates). /// Used for diagnostic output when a test assertion fails. fn list_dir_recursive(dir: &Path) -> String { if !dir.exists() { return format!(" (directory does not exist: {})", dir.display()); } let mut lines = Vec::new(); collect_dir_entries(dir, 0, 4, &mut lines); lines.join("\n") } fn collect_dir_entries(dir: &Path, depth: usize, max_depth: usize, lines: &mut Vec) { if depth > max_depth { return; } let entries = match fs::read_dir(dir) { Ok(e) => e, Err(_) => return, }; let mut sorted: Vec<_> = entries.filter_map(|e| e.ok()).collect(); sorted.sort_by_key(|e| e.file_name()); for entry in sorted { let indent = " ".repeat(depth + 1); let name = entry.file_name(); let name_str = name.to_string_lossy(); lines.push(format!("{}{}", indent, name_str)); let path = entry.path(); if path.is_dir() { collect_dir_entries(&path, depth + 1, max_depth, lines); } } } /// Search recursively for a file whose name starts with the given prefix. /// Uses only std::fs (no external crates). fn find_file_recursive(dir: &Path, prefix: &str) -> bool { let entries = match fs::read_dir(dir) { Ok(e) => e, Err(_) => return false, }; for entry in entries.filter_map(|e| e.ok()) { let path = entry.path(); let name = entry.file_name(); if name.to_string_lossy().starts_with(prefix) { return true; } if path.is_dir() && find_file_recursive(&path, prefix) { return true; } } false } // ========================================================================== // ROOT test.sh / test.bat EXECUTION // // The root-level test script is the primary test entry point for the // generated project. It must work out of the box. // // Required on build machine: cmake, g++ or clang++, git // ========================================================================== #[test] #[cfg_attr(not(has_cmake), ignore = "cmake not found")] #[cfg_attr(not(has_cpp_compiler), ignore = "C++ compiler not found")] #[cfg_attr(not(has_git), ignore = "git not found")] fn test_root_test_script_executes_successfully() { let tmp = extract_project("root_test"); #[cfg(unix)] chmod_scripts(tmp.path()); let (success, stdout, stderr) = run_script(tmp.path(), root_test_script()); println!("--- {} stdout ---\n{}", root_test_script(), stdout); if !stderr.is_empty() { eprintln!("--- {} stderr ---\n{}", root_test_script(), stderr); } assert!( success, "{} failed!\n\nstdout:\n{}\n\nstderr:\n{}", root_test_script(), stdout, stderr ); } #[test] #[cfg_attr(not(has_cmake), ignore = "cmake not found")] #[cfg_attr(not(has_cpp_compiler), ignore = "C++ compiler not found")] #[cfg_attr(not(has_git), ignore = "git not found")] fn test_root_test_script_tests_actually_ran() { let tmp = extract_project("root_verify"); #[cfg(unix)] chmod_scripts(tmp.path()); let (success, stdout, stderr) = run_script(tmp.path(), root_test_script()); assert!( success, "{} failed.\nstdout:\n{}\nstderr:\n{}", root_test_script(), stdout, stderr ); // Verify that tests actually executed -- not a silent no-op let combined = format!("{}{}", stdout, stderr); let tests_ran = combined.contains("passed") || combined.contains("PASSED") || combined.contains("tests passed") || combined.contains("100%") || combined.contains("PASS"); assert!( tests_ran, "{} output does not indicate any tests actually executed.\n\n\ stdout:\n{}\n\nstderr:\n{}", root_test_script(), stdout, stderr ); } #[test] #[cfg_attr(not(has_cmake), ignore = "cmake not found")] #[cfg_attr(not(has_cpp_compiler), ignore = "C++ compiler not found")] #[cfg_attr(not(has_git), ignore = "git not found")] fn test_root_test_script_idempotent() { let tmp = extract_project("root_idem"); #[cfg(unix)] chmod_scripts(tmp.path()); let (success1, _, _) = run_script(tmp.path(), root_test_script()); assert!(success1, "First run of {} failed", root_test_script()); let (success2, stdout2, stderr2) = run_script(tmp.path(), root_test_script()); assert!( success2, "Second run of {} failed (should be idempotent).\nstdout:\n{}\nstderr:\n{}", root_test_script(), stdout2, stderr2 ); } // ========================================================================== // INNER test/run_tests.sh / test\run_tests.bat EXECUTION // // The test/ subdirectory script builds and runs the C++ unit tests // directly. It must also work standalone. // // Required on build machine: cmake, g++ or clang++, git // ========================================================================== #[test] #[cfg_attr(not(has_cmake), ignore = "cmake not found")] #[cfg_attr(not(has_cpp_compiler), ignore = "C++ compiler not found")] #[cfg_attr(not(has_git), ignore = "git not found")] fn test_inner_run_tests_script_executes_successfully() { let tmp = extract_project("inner_test"); #[cfg(unix)] chmod_scripts(tmp.path()); let (success, stdout, stderr) = run_script(tmp.path(), inner_test_script()); println!("--- {} stdout ---\n{}", inner_test_script(), stdout); if !stderr.is_empty() { eprintln!("--- {} stderr ---\n{}", inner_test_script(), stderr); } assert!( success, "{} failed!\n\nstdout:\n{}\n\nstderr:\n{}", inner_test_script(), stdout, stderr ); } #[test] #[cfg_attr(not(has_cmake), ignore = "cmake not found")] #[cfg_attr(not(has_cpp_compiler), ignore = "C++ compiler not found")] #[cfg_attr(not(has_git), ignore = "git not found")] fn test_inner_run_tests_google_tests_actually_ran() { let tmp = extract_project("inner_gtest"); #[cfg(unix)] chmod_scripts(tmp.path()); let (success, stdout, stderr) = run_script(tmp.path(), inner_test_script()); assert!( success, "{} failed.\nstdout:\n{}\nstderr:\n{}", inner_test_script(), stdout, stderr ); let combined = format!("{}{}", stdout, stderr); let tests_ran = combined.contains("passed") || combined.contains("PASSED") || combined.contains("tests passed") || combined.contains("100%") || combined.contains("PASS"); assert!( tests_ran, "{} output does not indicate any Google Tests actually executed.\n\n\ stdout:\n{}\n\nstderr:\n{}", inner_test_script(), stdout, stderr ); } #[test] #[cfg_attr(not(has_cmake), ignore = "cmake not found")] #[cfg_attr(not(has_cpp_compiler), ignore = "C++ compiler not found")] #[cfg_attr(not(has_git), ignore = "git not found")] fn test_inner_run_tests_clean_flag_rebuilds() { let tmp = extract_project("inner_clean"); #[cfg(unix)] chmod_scripts(tmp.path()); // First run -- populates build dir and fetches gtest let (success, _, _) = run_script(tmp.path(), inner_test_script()); assert!(success, "First run of {} failed", inner_test_script()); // Verify build artifacts exist let build_dir = tmp.path().join("test").join("build"); assert!( build_dir.exists(), "test/build/ directory should exist after first run" ); // Second run with --clean -- should nuke build dir and rebuild let (success, stdout, stderr) = run_script_with_args(tmp.path(), inner_test_script(), &["--clean"]); println!("--- clean rebuild stdout ---\n{}", stdout); if !stderr.is_empty() { eprintln!("--- clean rebuild stderr ---\n{}", stderr); } assert!( success, "Clean rebuild of {} failed.\nstdout:\n{}\nstderr:\n{}", inner_test_script(), stdout, stderr ); } #[test] #[cfg_attr(not(has_cmake), ignore = "cmake not found")] #[cfg_attr(not(has_cpp_compiler), ignore = "C++ compiler not found")] #[cfg_attr(not(has_git), ignore = "git not found")] fn test_inner_run_tests_produces_test_binary() { let tmp = extract_project("inner_bin"); #[cfg(unix)] chmod_scripts(tmp.path()); let (success, _, _) = run_script(tmp.path(), inner_test_script()); assert!(success, "{} failed", inner_test_script()); // The cmake build should produce a test_unit binary somewhere // under test/build/ let build_dir = tmp.path().join("test").join("build"); let has_binary = find_file_recursive(&build_dir, "test_unit"); assert!( has_binary, "Expected test_unit binary under test/build/ after running tests.\n\ Contents of test/build/:\n{}", list_dir_recursive(&build_dir) ); } #[test] #[cfg_attr(not(has_cmake), ignore = "cmake not found")] #[cfg_attr(not(has_cpp_compiler), ignore = "C++ compiler not found")] #[cfg_attr(not(has_git), ignore = "git not found")] fn test_inner_run_tests_idempotent() { let tmp = extract_project("inner_idem"); #[cfg(unix)] chmod_scripts(tmp.path()); let (success1, _, _) = run_script(tmp.path(), inner_test_script()); assert!(success1, "First run failed"); let (success2, stdout2, stderr2) = run_script(tmp.path(), inner_test_script()); assert!( success2, "Second run failed (should be idempotent).\nstdout:\n{}\nstderr:\n{}", stdout2, stderr2 ); } // ========================================================================== // BUILD SCRIPT EXECUTION (arduino-cli compile) // // Extracts a project, then runs build.sh/build.bat which: // 1. Reads .anvil.toml for FQBN, include_dirs, extra_flags // 2. Invokes arduino-cli compile with those settings // // Required on build machine: arduino-cli with arduino:avr core installed // ========================================================================== #[test] #[serial] #[cfg_attr(not(has_arduino_cli), ignore = "arduino-cli not found")] fn test_build_script_compiles_sketch() { let tmp = extract_project("build_test"); #[cfg(unix)] chmod_scripts(tmp.path()); let (success, stdout, stderr) = run_script(tmp.path(), build_script()); println!("--- {} stdout ---\n{}", build_script(), stdout); if !stderr.is_empty() { eprintln!("--- {} stderr ---\n{}", build_script(), stderr); } assert!( success, "{} failed!\n\nstdout:\n{}\n\nstderr:\n{}", build_script(), stdout, stderr ); } #[test] #[serial] #[cfg_attr(not(has_arduino_cli), ignore = "arduino-cli not found")] fn test_build_script_produces_compilation_output() { let tmp = extract_project("compile_out"); #[cfg(unix)] chmod_scripts(tmp.path()); let (success, stdout, stderr) = run_script(tmp.path(), build_script()); assert!( success, "{} failed.\nstdout:\n{}\nstderr:\n{}", build_script(), stdout, stderr ); // arduino-cli compile produces output indicating sketch size let combined = format!("{}{}", stdout, stderr); let compiled = combined.contains("Sketch uses") || combined.contains("bytes") || combined.contains("Compiling") || combined.contains("Used") || combined.contains("compiled") || combined.contains("flash"); assert!( compiled, "{} output does not indicate a successful arduino-cli compilation.\n\n\ stdout:\n{}\n\nstderr:\n{}", build_script(), stdout, stderr ); } #[test] #[serial] #[cfg_attr(not(has_arduino_cli), ignore = "arduino-cli not found")] fn test_build_script_idempotent() { let tmp = extract_project("build_idem"); #[cfg(unix)] chmod_scripts(tmp.path()); let (success1, _, _) = run_script(tmp.path(), build_script()); assert!(success1, "First build failed"); let (success2, stdout2, stderr2) = run_script(tmp.path(), build_script()); assert!( success2, "Second build failed (should be idempotent).\nstdout:\n{}\nstderr:\n{}", stdout2, stderr2 ); } // ========================================================================== // COMBINED: build + test scripts all succeed on the same project // // Full end-to-end: one extracted project, all testable scripts pass. // ========================================================================== #[test] #[serial] #[cfg_attr(not(has_cmake), ignore = "cmake not found")] #[cfg_attr(not(has_cpp_compiler), ignore = "C++ compiler not found")] #[cfg_attr(not(has_git), ignore = "git not found")] #[cfg_attr(not(has_arduino_cli), ignore = "arduino-cli not found")] fn test_full_project_all_scripts_pass() { let tmp = extract_project("full_e2e"); #[cfg(unix)] chmod_scripts(tmp.path()); // 1. Build the sketch with arduino-cli let (build_ok, build_out, build_err) = run_script(tmp.path(), build_script()); println!("--- {} stdout ---\n{}", build_script(), build_out); if !build_err.is_empty() { eprintln!("--- {} stderr ---\n{}", build_script(), build_err); } assert!( build_ok, "{} failed in full E2E.\nstdout:\n{}\nstderr:\n{}", build_script(), build_out, build_err ); // 2. Run root-level test script let (root_ok, root_out, root_err) = run_script(tmp.path(), root_test_script()); println!("--- {} stdout ---\n{}", root_test_script(), root_out); if !root_err.is_empty() { eprintln!("--- {} stderr ---\n{}", root_test_script(), root_err); } assert!( root_ok, "{} failed in full E2E.\nstdout:\n{}\nstderr:\n{}", root_test_script(), root_out, root_err ); // 3. Run inner test/run_tests script let (inner_ok, inner_out, inner_err) = run_script(tmp.path(), inner_test_script()); println!("--- {} stdout ---\n{}", inner_test_script(), inner_out); if !inner_err.is_empty() { eprintln!("--- {} stderr ---\n{}", inner_test_script(), inner_err); } assert!( inner_ok, "{} failed in full E2E.\nstdout:\n{}\nstderr:\n{}", inner_test_script(), inner_out, inner_err ); } // ========================================================================== // SCRIPT CONTENT SANITY CHECKS // // Verify the scripts are well-formed before even executing them. // These tests have NO external dependencies. // ========================================================================== #[test] fn test_all_sh_scripts_have_strict_error_handling() { let tmp = extract_project("strict_mode"); let sh_scripts = vec![ "build.sh", "upload.sh", "monitor.sh", "test.sh", "test/run_tests.sh", ]; for script in &sh_scripts { let path = tmp.path().join(script); if !path.exists() { continue; } let content = fs::read_to_string(&path) .unwrap_or_else(|_| panic!("Failed to read {}", script)); assert!( content.contains("set -e") || content.contains("set -euo pipefail"), "{} must use 'set -e' or 'set -euo pipefail' for strict error handling", script ); } } #[test] fn test_all_sh_scripts_have_shebangs() { let tmp = extract_project("shebang"); let sh_scripts = vec![ "build.sh", "upload.sh", "monitor.sh", "test.sh", "test/run_tests.sh", ]; for script in &sh_scripts { let path = tmp.path().join(script); if !path.exists() { continue; } let content = fs::read_to_string(&path) .unwrap_or_else(|_| panic!("Failed to read {}", script)); assert!( content.starts_with("#!/"), "{} must start with a shebang line (#!/...)", script ); } } #[test] fn test_bat_scripts_exist_for_windows_parity() { let tmp = extract_project("win_parity"); let pairs = vec![ ("build.sh", "build.bat"), ("upload.sh", "upload.bat"), ("monitor.sh", "monitor.bat"), ("test.sh", "test.bat"), ("test/run_tests.sh", "test/run_tests.bat"), ]; for (sh, bat) in &pairs { let sh_exists = tmp.path().join(sh).exists(); let bat_exists = tmp.path().join(bat).exists(); if sh_exists { assert!( bat_exists, "{} exists but {} is missing -- Windows parity broken", sh, bat ); } } } #[test] fn test_cmake_lists_fetches_google_test() { let tmp = extract_project("cmake_gtest"); let cmake_path = tmp.path().join("test").join("CMakeLists.txt"); assert!(cmake_path.exists(), "test/CMakeLists.txt must exist"); let content = fs::read_to_string(&cmake_path).unwrap(); assert!( content.contains("FetchContent") || content.contains("fetchcontent"), "CMakeLists.txt should use FetchContent to download Google Test" ); assert!( content.contains("googletest") || content.contains("GTest"), "CMakeLists.txt should reference Google Test" ); } #[test] fn test_scripts_all_reference_anvil_toml() { let tmp = extract_project("toml_refs"); // Build and upload scripts must read .anvil.toml for configuration. // On Windows, build.bat is a thin wrapper that calls build.ps1, // so we check the .ps1 file for content. let config_scripts = vec![ "build.sh", "build.ps1", "upload.sh", "upload.bat", ]; for script in &config_scripts { let path = tmp.path().join(script); if !path.exists() { continue; } let content = fs::read_to_string(&path) .unwrap_or_else(|_| panic!("Failed to read {}", script)); assert!( content.contains(".anvil.toml"), "{} should reference .anvil.toml for project configuration", script ); } } #[test] fn test_scripts_invoke_arduino_cli_not_anvil() { let tmp = extract_project("no_anvil_dep"); // Build/upload/monitor scripts must invoke arduino-cli directly. // On Windows, build.bat is a thin wrapper calling build.ps1, // so we check the .ps1 file for content. let scripts = vec![ "build.sh", "build.ps1", "upload.sh", "upload.bat", "monitor.sh", "monitor.bat", ]; for script in &scripts { let path = tmp.path().join(script); if !path.exists() { continue; } let content = fs::read_to_string(&path) .unwrap_or_else(|_| panic!("Failed to read {}", script)); assert!( content.contains("arduino-cli"), "{} should invoke arduino-cli directly", script ); // No line should shell out to the anvil binary let has_anvil_cmd = content.lines().any(|line| { let trimmed = line.trim(); // Skip comments if trimmed.starts_with('#') || trimmed.starts_with("::") || trimmed.starts_with("REM") || trimmed.starts_with("rem") { return false; } // Skip output/diagnostic lines -- these often contain // suggestions like "Run: anvil doctor" which are messages // to the user, not command invocations. if trimmed.starts_with("echo") || trimmed.starts_with("Echo") || trimmed.starts_with("ECHO") || trimmed.starts_with("printf") || trimmed.starts_with("die ") || trimmed.starts_with("die(") || trimmed.starts_with("warn ") || trimmed.starts_with("warn(") || trimmed.starts_with("info ") || trimmed.starts_with("info(") || trimmed.starts_with("ok ") || trimmed.starts_with("ok(") || trimmed.starts_with(">&2") || trimmed.starts_with("1>&2") || trimmed.starts_with("Write-Host") || trimmed.starts_with("Write-Error") || trimmed.starts_with("Write-Warning") || trimmed.starts_with("Fail ") || trimmed.starts_with("Fail(") || trimmed.starts_with("Fail \"") || trimmed.starts_with("Fail @") { return false; } // Skip string assignments that contain suggestion text // e.g. MSG="Run: anvil devices" if trimmed.contains("=\"") && trimmed.contains("anvil ") { return false; } // Check for "anvil " as a command invocation trimmed.contains("anvil ") && !trimmed.contains("anvil.toml") && !trimmed.contains("Anvil") && !trimmed.contains("anvilignore") && !trimmed.contains("\"anvil ") // quoted suggestion text && !trimmed.contains("'anvil ") // single-quoted suggestion }); assert!( !has_anvil_cmd, "{} should not invoke the anvil binary -- project must be self-contained", script ); } } #[test] fn test_all_expected_scripts_exist() { let tmp = extract_project("all_scripts"); let expected = vec![ "build.sh", "build.bat", "build.ps1", "upload.sh", "upload.bat", "monitor.sh", "monitor.bat", "test.sh", "test.bat", "test/run_tests.sh", "test/run_tests.bat", ]; for script in &expected { let path = tmp.path().join(script); assert!( path.exists(), "Expected script missing: {}\n\nProject contents:\n{}", script, list_dir_recursive(tmp.path()) ); } }