/ adl / tests / integration.rs
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  }