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