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