integration.rs
1 // Copyright (C) 2019-2025 ADnet Contributors 2 // This file is part of the ADL library. 3 4 // The ADL library is free software: you can redistribute it and/or modify 5 // it under the terms of the GNU General Public License as published by 6 // the Free Software Foundation, either version 3 of the License, or 7 // (at your option) any later version. 8 9 // The ADL library is distributed in the hope that it will be useful, 10 // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 // GNU General Public License for more details. 13 14 // You should have received a copy of the GNU General Public License 15 // along with the ADL library. If not, see <https://www.gnu.org/licenses/>. 16 17 //! This code will examine the tests in `tests/tests/cli` and, for each, 18 //! execute its COMMAND file, comparing the output and resulting directory 19 //! structure to the corresponding directory in `tests/expectations/cli`. 20 //! 21 //! It relies on a snarkOS with the `test_network` feature and uses `leo devnet` 22 //! to start a local devnet using snarkOS. 23 24 #[cfg(unix)] 25 use std::os::unix::process::CommandExt; 26 use std::{ 27 borrow::Cow, 28 collections::HashSet, 29 env, 30 fs, 31 io, 32 path::{Path, PathBuf}, 33 process::{Child, Command, Stdio}, 34 }; 35 36 use anyhow::anyhow; 37 use snarkvm::prelude::ConsensusVersion; 38 39 const VALIDATOR_COUNT: usize = 4usize; 40 41 struct Test { 42 test_directory: PathBuf, 43 expectation_directory: PathBuf, 44 mismatch_directory: PathBuf, 45 } 46 47 fn find_tests() -> Vec<Test> { 48 let cli_test_directory: PathBuf = [env!("CARGO_MANIFEST_DIR"), "tests", "tests", "cli"].iter().collect::<PathBuf>(); 49 let cli_expectation_directory: PathBuf = 50 [env!("CARGO_MANIFEST_DIR"), "tests", "expectations", "cli"].iter().collect::<PathBuf>(); 51 let mismatch_directory: PathBuf = 52 [env!("CARGO_MANIFEST_DIR"), "tests", "mismatches", "cli"].iter().collect::<PathBuf>(); 53 54 let filter_string = env::var("TEST_FILTER").unwrap_or_default(); 55 56 let mut tests = Vec::new(); 57 58 for entry in cli_test_directory 59 .read_dir() 60 .unwrap_or_else(|e| panic!("Failed to read directory {}: {e}", cli_test_directory.display())) 61 { 62 let entry = entry.unwrap_or_else(|e| panic!("Failed to read directory {}: {e}", cli_test_directory.display())); 63 let path = entry.path().canonicalize().expect("Failed to canonicalize"); 64 65 let path_str = path.to_str().unwrap_or_else(|| panic!("Path not unicode: {}", path.display())); 66 67 if !path_str.contains(&filter_string) { 68 continue; 69 } 70 71 let expectation_directory = cli_expectation_directory.join(path.file_name().unwrap()); 72 let mismatch_directory = mismatch_directory.join(path.file_name().unwrap()); 73 74 tests.push(Test { test_directory: path, expectation_directory, mismatch_directory }) 75 } 76 77 tests 78 } 79 80 struct CwdRaii { 81 previous: PathBuf, 82 } 83 84 impl CwdRaii { 85 fn cwd(new: &Path) -> Self { 86 let previous = env::current_dir().expect("Can't find current directory."); 87 env::set_current_dir(new).expect("Can't change directory."); 88 Self { previous } 89 } 90 } 91 92 impl Drop for CwdRaii { 93 fn drop(&mut self) { 94 let _ = env::set_current_dir(&self.previous); 95 } 96 } 97 98 fn run_test(test: &Test, force_rewrite: bool) -> Option<String> { 99 let test_context_directory = tempfile::TempDir::new().expect("Failed to create temporary directory."); 100 101 copy_recursively(&test.test_directory, test_context_directory.path()).expect("Failed to copy test directory."); 102 103 let contents_path = test_context_directory.path().join("contents"); 104 105 let _raii = CwdRaii::cwd(&contents_path); 106 107 let commands_path = test_context_directory.path().join("COMMANDS"); 108 109 let output = Command::new(&commands_path).arg(BINARY_PATH).output().expect("Failed to execute COMMANDS"); 110 111 let stdout_path = test_context_directory.path().join("STDOUT"); 112 let stdout_utf8 = std::str::from_utf8(&output.stdout).expect("stdout should be utf8"); 113 fs::write(&stdout_path, filter_stdout(stdout_utf8).as_bytes()).expect("Failed to write STDOUT"); 114 let stderr_path = test_context_directory.path().join("STDERR"); 115 let stderr_utf8 = std::str::from_utf8(&output.stderr).expect("stderr should be utf8"); 116 fs::write(&stderr_path, filter_stderr(stderr_utf8).as_bytes()).expect("Failed to write STDERR"); 117 118 if force_rewrite { 119 copy_recursively(test_context_directory.path(), &test.expectation_directory) 120 .expect("Failed to copy directory."); 121 None 122 } else if let Some(error) = 123 dirs_equal(test_context_directory.path(), &test.expectation_directory).expect("Failed to compare directories.") 124 { 125 Some(error) 126 } else { 127 copy_recursively(test_context_directory.path(), &test.mismatch_directory).expect("Failed to copy directory."); 128 None 129 } 130 } 131 132 /// Replace strings in the stdout of a Leo execution that we don't need to match exactly. 133 fn filter_stdout(data: &str) -> String { 134 use regex::Regex; 135 let regexes = [ 136 (Regex::new(" - transaction ID: '[a-zA-Z0-9]*'").unwrap(), " - transaction ID: 'XXXXXX'"), 137 (Regex::new(" - fee ID: '[a-zA-Z0-9]*'").unwrap(), " - fee ID: 'XXXXXX'"), 138 (Regex::new(" - fee transaction ID: '[a-zA-Z0-9]*'").unwrap(), " - fee transaction ID: 'XXXXXX'"), 139 ( 140 Regex::new("💰Your current public balance is [0-9.]* credits.").unwrap(), 141 "💰Your current public balance is XXXXXX credits.", 142 ), 143 (Regex::new("Explored [0-9]* blocks.").unwrap(), "Explored XXXXXX blocks."), 144 (Regex::new("Max Variables: [0-9,]*").unwrap(), "Max Variables: XXXXXX"), 145 (Regex::new("Max Constraints: [0-9,]*").unwrap(), "Max Constraints: XXXXXX"), 146 // These are filtered out since the cache can frequently differ between local and CI runs. 147 (Regex::new("Warning: The cached file.*\n").unwrap(), ""), 148 ( 149 Regex::new(r" • The program '[A-Za-z0-9_]+\.alpha' on the network does not match the local copy.*\n") 150 .unwrap(), 151 "", 152 ), 153 (Regex::new(r" • The program '[A-Za-z0-9_]+\.alpha' does not exist on the network.*\n").unwrap(), ""), 154 ]; 155 156 let mut cow = Cow::Borrowed(data); 157 for (regex, replacement) in regexes { 158 if let Cow::Owned(s) = regex.replace_all(&cow, replacement) { 159 cow = Cow::Owned(s); 160 } 161 } 162 163 cow.into_owned() 164 } 165 166 /// Replace strings in the stderr of a Leo execution that we don't need to match exactly. 167 fn filter_stderr(data: &str) -> String { 168 use regex::Regex; 169 use std::borrow::Cow; 170 171 // Match `-->` followed by any path, capture only the filename with line/col 172 let path_regex = Regex::new(r"-->\s+.*?/([^/]+\.adl:\d+:\d+)").unwrap(); 173 174 let mut cow = Cow::Borrowed(data); 175 if let Cow::Owned(s) = path_regex.replace_all(&cow, "--> SOURCE_DIRECTORY/$1") { 176 cow = Cow::Owned(s); 177 } 178 179 cow.into_owned() 180 } 181 182 const BINARY_PATH: &str = env!("CARGO_BIN_EXE_adl"); 183 184 #[test] 185 fn integration_tests() { 186 if !cfg!(target_family = "unix") { 187 println!("Skipping CLI integration tests (they only run on unix systems)."); 188 return; 189 } 190 191 let rewrite_expectations = !env::var("REWRITE_EXPECTATIONS").unwrap_or_default().trim().is_empty(); 192 193 let directory = tempfile::TempDir::new().expect("Failed to create temporary directory."); 194 remove_all_in_dir(directory.path()).expect("Should be able to remove."); 195 196 let _raii = CwdRaii::cwd(directory.path()); 197 198 let mut devnet_process = run_adl_devnet(VALIDATOR_COUNT).expect("OK"); 199 200 // Wait for snarkos to start listening on port 3030. 201 let height_url = "http://localhost:3030/testnet/block/height/latest"; 202 let start = std::time::Instant::now(); 203 let timeout = std::time::Duration::from_secs(90); 204 205 loop { 206 match adl_package::fetch_from_network_plain(height_url) { 207 Ok(_) => { 208 break; 209 } 210 Err(_) if start.elapsed() < timeout => { 211 std::thread::sleep(std::time::Duration::from_secs(1)); 212 } 213 Err(e) => panic!("snarkos did not start within {timeout:?}: {e}"), 214 } 215 } 216 217 // Wait until the appropriate block height. 218 loop { 219 let height = current_height().expect("net"); 220 if height > ConsensusVersion::latest() as usize { 221 break; 222 } 223 // Avoid rate limits. 224 std::thread::sleep(std::time::Duration::from_millis(200)); 225 } 226 227 let tests = find_tests(); 228 let mut passed = Vec::new(); 229 let mut failed = Vec::new(); 230 for test in tests.into_iter() { 231 if let Some(err) = run_test(&test, rewrite_expectations) { 232 failed.push((test, err)); 233 } else { 234 passed.push(test); 235 } 236 } 237 238 #[cfg(unix)] 239 unsafe { 240 // Kill the entire process group: devnet_process + all its children 241 libc::killpg(devnet_process.id() as i32, libc::SIGTERM); 242 } 243 244 // Wait to reap the main devnet_process (avoid zombie) 245 let _ = devnet_process.wait(); 246 247 if failed.is_empty() { 248 println!("CLI Integration tests: All {} tests passed.", passed.len()); 249 } else { 250 println!("CLI Integration tests: {}/{} tests failed.", failed.len(), failed.len() + passed.len()); 251 for (test, err) in &failed { 252 println!( 253 "FAILED: {}; produced files written to {}\nERROR: {err}", 254 test.test_directory.file_name().unwrap().display(), 255 test.mismatch_directory.display() 256 ); 257 } 258 panic!() 259 } 260 } 261 262 fn copy_recursively(src: &Path, dst: &Path) -> io::Result<()> { 263 // Ensure destination directory exists 264 if !dst.exists() { 265 fs::create_dir_all(dst)?; 266 } 267 268 for entry in fs::read_dir(src)? { 269 let entry = entry?; 270 let file_type = entry.file_type()?; 271 let src_path = entry.path(); 272 let dst_path = dst.join(entry.file_name()); 273 274 if file_type.is_dir() { 275 copy_recursively(&src_path, &dst_path)?; 276 } else if file_type.is_file() { 277 fs::copy(&src_path, &dst_path)?; 278 } else { 279 panic!("Unexpected file type at {}", src_path.display()) 280 } 281 } 282 283 Ok(()) 284 } 285 286 /// Recursively compares the contents of two directories 287 fn dirs_equal(actual: &Path, expected: &Path) -> io::Result<Option<String>> { 288 let entries1 = collect_files(actual)?; 289 let entries2 = collect_files(expected)?; 290 291 // Check both directories have the same files 292 if entries1 != entries2 { 293 return Ok(Some(format!( 294 "Directory entries differ:\n - Actual: {}\n - Expected: {:?}", 295 entries1.iter().map(|p| p.to_string_lossy()).collect::<Vec<_>>().join(","), 296 entries2.iter().map(|p| p.to_string_lossy()).collect::<Vec<_>>().join(",") 297 ))); 298 } 299 300 // Compare contents of each file 301 for relative_path in &entries1 { 302 let path1 = actual.join(relative_path); 303 let path2 = expected.join(relative_path); 304 305 let bytes1 = fs::read(&path1)?; 306 let bytes2 = fs::read(&path2)?; 307 308 if bytes1 != bytes2 { 309 let actual = String::from_utf8_lossy(&bytes1); 310 let expected = String::from_utf8_lossy(&bytes2); 311 return Ok(Some(format!( 312 "File contents differ: {}\n - Actual: {actual}\n - Expected: {expected}", 313 relative_path.display() 314 ))); 315 } 316 } 317 318 Ok(None) 319 } 320 321 /// Collects all file paths relative to the base directory 322 fn collect_files(base: &Path) -> io::Result<HashSet<PathBuf>> { 323 let mut files = HashSet::new(); 324 for entry in walkdir::WalkDir::new(base).into_iter().filter_map(Result::ok) { 325 let path = entry.path(); 326 if path.is_file() { 327 let rel_path = path.strip_prefix(base).unwrap().to_path_buf(); 328 files.insert(rel_path); 329 } 330 } 331 Ok(files) 332 } 333 334 fn remove_all_in_dir(dir: &Path) -> io::Result<()> { 335 for entry in fs::read_dir(dir)? { 336 let entry = entry?; 337 let path = entry.path(); 338 339 if path.is_dir() { 340 fs::remove_dir_all(path)?; 341 } else { 342 fs::remove_file(path)?; 343 } 344 } 345 Ok(()) 346 } 347 348 /// Starts a Leo devnet for integration testing purposes. 349 /// 350 /// This function launches a local devnet using the ADL CLI with snarkOS as the backend. 351 /// The devnet is configured specifically for testing with predefined consensus heights 352 /// and validators. 353 fn run_adl_devnet(num_validators: usize) -> io::Result<Child> { 354 // Locate the path to the snarkOS binary using the `which` crate 355 let snarkos_path: PathBuf = which::which("snarkos").unwrap_or_else(|_| panic!("Cannot find snarkos path.")); // fallback for CI 356 assert!(snarkos_path.exists(), "snarkos binary not found at {snarkos_path:?}"); 357 358 // Create a new command using the Leo binary (defined by BINARY_PATH constant) 359 let mut adl_devnet_cmd = Command::new(BINARY_PATH); 360 361 let consensus_heights: String = 362 (0..ConsensusVersion::latest() as usize).map(|n| n.to_string()).collect::<Vec<_>>().join(","); 363 364 // Configure the Leo devnet command with all necessary arguments 365 adl_devnet_cmd.arg("devnet") 366 .arg("-y") 367 .arg("--snarkos") 368 .arg(&snarkos_path) 369 .arg("--snarkos-features") 370 .arg("test_network") // Use test network configuration 371 .arg("--num-validators") 372 .arg(num_validators.to_string()) 373 .arg("--consensus-heights") // Define consensus heights for testing 374 .arg(&consensus_heights) 375 .arg("--clear-storage") 376 .stdout(Stdio::null()) 377 .stderr(Stdio::null()); 378 379 // On Unix systems, configure the child process to be its own process group leader 380 // This allows us to later kill the entire process group (parent + all children) 381 // which is important for properly cleaning up the devnet and all its validator processes 382 #[cfg(unix)] 383 unsafe { 384 adl_devnet_cmd.pre_exec(|| { 385 libc::setpgid(0, 0); // make child its own process group leader 386 Ok(()) 387 }); 388 } 389 390 adl_devnet_cmd.spawn() 391 } 392 393 fn current_height() -> Result<usize, anyhow::Error> { 394 let height_url = "http://localhost:3030/testnet/block/height/latest".to_string(); 395 let height_str = adl_package::fetch_from_network_plain(&height_url)?; 396 height_str.parse().map_err(|e| anyhow!("error parsing height: {e}")) 397 }