1use crate::init_tracing;
2use eyre::{Result, WrapErr};
3use foundry_compilers::{
4 ArtifactOutput, ConfigurableArtifacts, PathStyle, Project, ProjectCompileOutput,
5 ProjectPathsConfig, Vyper,
6 artifacts::Contract,
7 cache::CompilerCache,
8 compilers::multi::MultiCompiler,
9 project_util::{TempProject, copy_dir},
10 solc::SolcSettings,
11 utils::RuntimeOrHandle,
12};
13use foundry_config::Config;
14use parking_lot::Mutex;
15use regex::Regex;
16use snapbox::{Data, IntoData, assert_data_eq, cmd::OutputAssert};
17use std::{
18 env,
19 ffi::OsStr,
20 fs::{self, File},
21 io::{BufWriter, IsTerminal, Read, Seek, Write},
22 path::{Path, PathBuf},
23 process::{Command, Output, Stdio},
24 sync::{
25 Arc, LazyLock,
26 atomic::{AtomicUsize, Ordering},
27 },
28};
29
30static CURRENT_DIR_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
31
32pub const FORGE_STD_REVISION: &str = include_str!("../../../testdata/forge-std-rev");
34
35pub static IS_TTY: LazyLock<bool> = LazyLock::new(|| std::io::stdout().is_terminal());
37
38static TEMPLATE_PATH: LazyLock<PathBuf> =
41 LazyLock::new(|| env::temp_dir().join("foundry-forge-test-template"));
42
43static TEMPLATE_LOCK: LazyLock<PathBuf> =
46 LazyLock::new(|| env::temp_dir().join("foundry-forge-test-template.lock"));
47
48static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
50
51pub const SOLC_VERSION: &str = "0.8.30";
53
54pub const OTHER_SOLC_VERSION: &str = "0.8.26";
58
59#[derive(Clone, Debug)]
61#[must_use = "ExtTester does nothing unless you `run` it"]
62pub struct ExtTester {
63 pub org: &'static str,
64 pub name: &'static str,
65 pub rev: &'static str,
66 pub style: PathStyle,
67 pub fork_block: Option<u64>,
68 pub args: Vec<String>,
69 pub envs: Vec<(String, String)>,
70 pub install_commands: Vec<Vec<String>>,
71 pub verbosity: String,
72}
73
74impl ExtTester {
75 pub fn new(org: &'static str, name: &'static str, rev: &'static str) -> Self {
77 Self {
78 org,
79 name,
80 rev,
81 style: PathStyle::Dapptools,
82 fork_block: None,
83 args: vec![],
84 envs: vec![],
85 install_commands: vec![],
86 verbosity: "-vvv".to_string(),
87 }
88 }
89
90 pub fn style(mut self, style: PathStyle) -> Self {
92 self.style = style;
93 self
94 }
95
96 pub fn fork_block(mut self, fork_block: u64) -> Self {
98 self.fork_block = Some(fork_block);
99 self
100 }
101
102 pub fn arg(mut self, arg: impl Into<String>) -> Self {
104 self.args.push(arg.into());
105 self
106 }
107
108 pub fn args<I, A>(mut self, args: I) -> Self
110 where
111 I: IntoIterator<Item = A>,
112 A: Into<String>,
113 {
114 self.args.extend(args.into_iter().map(Into::into));
115 self
116 }
117
118 pub fn verbosity(mut self, verbosity: usize) -> Self {
120 self.verbosity = format!("-{}", "v".repeat(verbosity));
121 self
122 }
123
124 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
126 self.envs.push((key.into(), value.into()));
127 self
128 }
129
130 pub fn envs<I, K, V>(mut self, envs: I) -> Self
132 where
133 I: IntoIterator<Item = (K, V)>,
134 K: Into<String>,
135 V: Into<String>,
136 {
137 self.envs.extend(envs.into_iter().map(|(k, v)| (k.into(), v.into())));
138 self
139 }
140
141 pub fn install_command(mut self, command: &[&str]) -> Self {
146 self.install_commands.push(command.iter().map(|s| s.to_string()).collect());
147 self
148 }
149
150 pub fn setup_forge_prj(&self, recursive: bool) -> (TestProject, TestCommand) {
151 let (prj, mut test_cmd) = setup_forge(self.name, self.style.clone());
152
153 if let Some(vyper) = &prj.inner.project().compiler.vyper {
155 let vyper_dir = vyper.path.parent().expect("vyper path should have a parent");
156 let forge_bin = prj.exe_root.join(format!("../forge{}", env::consts::EXE_SUFFIX));
157 let forge_dir = forge_bin.parent().expect("forge path should have a parent");
158
159 let existing_path = std::env::var_os("PATH").unwrap_or_default();
160 let mut new_paths = vec![vyper_dir.to_path_buf(), forge_dir.to_path_buf()];
161 new_paths.extend(std::env::split_paths(&existing_path));
162
163 let joined_path = std::env::join_paths(new_paths).expect("failed to join PATH");
164 test_cmd.env("PATH", joined_path);
165 }
166
167 prj.wipe();
169
170 let repo_url = format!("https://github.com/{}/{}.git", self.org, self.name);
172 let root = prj.root().to_str().unwrap();
173 clone_remote(&repo_url, root, recursive);
174
175 if self.rev.is_empty() {
177 let mut git = Command::new("git");
178 git.current_dir(root).args(["log", "-n", "1"]);
179 test_debug!("$ {git:?}");
180 let output = git.output().unwrap();
181 if !output.status.success() {
182 panic!("git log failed: {output:?}");
183 }
184 let stdout = String::from_utf8(output.stdout).unwrap();
185 let commit = stdout.lines().next().unwrap().split_whitespace().nth(1).unwrap();
186 panic!("pin to latest commit: {commit}");
187 } else {
188 let mut git = Command::new("git");
189 git.current_dir(root).args(["checkout", self.rev]);
190 test_debug!("$ {git:?}");
191 let status = git.status().unwrap();
192 if !status.success() {
193 panic!("git checkout failed: {status}");
194 }
195 }
196
197 (prj, test_cmd)
198 }
199
200 pub fn run_install_commands(&self, root: &str) {
201 for install_command in &self.install_commands {
202 let mut install_cmd = Command::new(&install_command[0]);
203 install_cmd.args(&install_command[1..]).current_dir(root);
204 test_debug!("cd {root}; {install_cmd:?}");
205 match install_cmd.status() {
206 Ok(s) => {
207 test_debug!("\n\n{install_cmd:?}: {s}");
208 if s.success() {
209 break;
210 }
211 }
212 Err(e) => {
213 eprintln!("\n\n{install_cmd:?}: {e}");
214 }
215 }
216 }
217 }
218
219 pub fn run(&self) {
221 if self.fork_block.is_some() && std::env::var_os("ETH_RPC_URL").is_none() {
223 eprintln!("ETH_RPC_URL is not set; skipping");
224 return;
225 }
226
227 let (prj, mut test_cmd) = self.setup_forge_prj(true);
228
229 self.run_install_commands(prj.root().to_str().unwrap());
231
232 test_cmd.arg("test");
234 test_cmd.args(&self.args);
235 test_cmd.args(["--fuzz-runs=32", "--ffi", &self.verbosity]);
236
237 test_cmd.envs(self.envs.iter().map(|(k, v)| (k, v)));
238 if let Some(fork_block) = self.fork_block {
239 test_cmd.env("FOUNDRY_ETH_RPC_URL", crate::rpc::next_http_archive_rpc_url());
240 test_cmd.env("FOUNDRY_FORK_BLOCK_NUMBER", fork_block.to_string());
241 }
242 test_cmd.env("FOUNDRY_INVARIANT_DEPTH", "15");
243 test_cmd.env("FOUNDRY_ALLOW_INTERNAL_EXPECT_REVERT", "true");
244
245 test_cmd.assert_success();
246 }
247}
248
249pub fn initialize(target: &Path) {
265 test_debug!("initializing {}", target.display());
266
267 let tpath = TEMPLATE_PATH.as_path();
268 pretty_err(tpath, fs::create_dir_all(tpath));
269
270 let mut lock = crate::fd_lock::new_lock(TEMPLATE_LOCK.as_path());
272 let mut _read = lock.read().unwrap();
273 if !crate::fd_lock::lock_exists(TEMPLATE_LOCK.as_path()) {
274 drop(_read);
284 let mut write = lock.write().unwrap();
285
286 let mut data = Vec::new();
287 write.read_to_end(&mut data).unwrap();
288 if data != crate::fd_lock::LOCK_TOKEN {
289 let (prj, mut cmd) = setup_forge("template", foundry_compilers::PathStyle::Dapptools);
291 test_debug!("- initializing template dir in {}", prj.root().display());
292
293 cmd.args(["init", "--force"]).assert_success();
294 prj.write_config(Config {
295 solc: Some(foundry_config::SolcReq::Version(SOLC_VERSION.parse().unwrap())),
296 ..Default::default()
297 });
298
299 let output = Command::new("git")
301 .current_dir(prj.root().join("lib/forge-std"))
302 .args(["checkout", FORGE_STD_REVISION])
303 .output()
304 .expect("failed to checkout forge-std");
305 assert!(output.status.success(), "{output:#?}");
306
307 cmd.forge_fuse().arg("build").assert_success();
309
310 let _ = fs::remove_dir_all(tpath);
312
313 pretty_err(tpath, copy_dir(prj.root(), tpath));
315
316 write.set_len(0).unwrap();
318 write.seek(std::io::SeekFrom::Start(0)).unwrap();
319 write.write_all(crate::fd_lock::LOCK_TOKEN).unwrap();
320 }
321
322 drop(write);
324 _read = lock.read().unwrap();
325 }
326
327 test_debug!("- copying template dir from {}", tpath.display());
328 pretty_err(target, fs::create_dir_all(target));
329 pretty_err(target, copy_dir(tpath, target));
330}
331
332pub fn get_compiled(project: &mut Project) -> ProjectCompileOutput {
334 let lock_file_path = project.sources_path().join(".lock");
335 let mut lock = crate::fd_lock::new_lock(&lock_file_path);
338 let read = lock.read().unwrap();
339 let out;
340
341 let mut write = None;
342 if !project.cache_path().exists() || !crate::fd_lock::lock_exists(&lock_file_path) {
343 drop(read);
344 write = Some(lock.write().unwrap());
345 test_debug!("cache miss for {}", lock_file_path.display());
346 } else {
347 test_debug!("cache hit for {}", lock_file_path.display());
348 }
349
350 if project.compiler.vyper.is_none() {
351 project.compiler.vyper = Some(get_vyper());
352 }
353
354 test_debug!("compiling {}", lock_file_path.display());
355 out = project.compile().unwrap();
356 test_debug!("compiled {}", lock_file_path.display());
357
358 if out.has_compiler_errors() {
359 panic!("Compiled with errors:\n{out}");
360 }
361
362 if let Some(write) = &mut write {
363 write.write_all(crate::fd_lock::LOCK_TOKEN).unwrap();
364 }
365
366 out
367}
368
369pub fn get_vyper() -> Vyper {
371 static VYPER: LazyLock<PathBuf> = LazyLock::new(|| std::env::temp_dir().join("vyper"));
372
373 if let Ok(vyper) = Vyper::new("vyper") {
374 return vyper;
375 }
376 if let Ok(vyper) = Vyper::new(&*VYPER) {
377 return vyper;
378 }
379 return RuntimeOrHandle::new().block_on(install());
380
381 async fn install() -> Vyper {
382 #[cfg(target_family = "unix")]
383 use std::{fs::Permissions, os::unix::fs::PermissionsExt};
384
385 let path = VYPER.as_path();
386 let mut file = File::create(path).unwrap();
387 if let Err(e) = file.try_lock() {
388 if let fs::TryLockError::WouldBlock = e {
389 file.lock().unwrap();
390 assert!(path.exists());
391 return Vyper::new(path).unwrap();
392 }
393 file.lock().unwrap();
394 }
395
396 let suffix = match svm::platform() {
397 svm::Platform::MacOsAarch64 => "darwin",
398 svm::Platform::LinuxAmd64 => "linux",
399 svm::Platform::WindowsAmd64 => "windows.exe",
400 platform => panic!(
401 "unsupported platform {platform:?} for installing vyper, \
402 install it manually and add it to $PATH"
403 ),
404 };
405 let url = format!(
406 "https://github.com/vyperlang/vyper/releases/download/v0.4.3/vyper.0.4.3+commit.bff19ea2.{suffix}"
407 );
408
409 test_debug!("downloading vyper from {url}");
410 let res = reqwest::Client::builder().build().unwrap().get(url).send().await.unwrap();
411
412 assert!(res.status().is_success());
413
414 let bytes = res.bytes().await.unwrap();
415
416 file.write_all(&bytes).unwrap();
417
418 #[cfg(target_family = "unix")]
419 file.set_permissions(Permissions::from_mode(0o755)).unwrap();
420
421 Vyper::new(path).unwrap()
422 }
423}
424
425pub fn clone_remote(repo_url: &str, target_dir: &str, recursive: bool) {
427 let mut cmd = Command::new("git");
428 cmd.args(["clone"]);
429 if recursive {
430 cmd.args(["--recursive", "--shallow-submodules"]);
431 } else {
432 cmd.args(["--depth=1", "--no-checkout", "--filter=blob:none", "--no-recurse-submodules"]);
433 }
434 cmd.args([repo_url, target_dir]);
435 test_debug!("{cmd:?}");
436 let status = cmd.status().unwrap();
437 if !status.success() {
438 panic!("git clone failed: {status}");
439 }
440}
441
442#[track_caller]
448pub fn setup_forge(name: &str, style: PathStyle) -> (TestProject, TestCommand) {
449 setup_forge_project(TestProject::new(name, style))
450}
451
452pub fn setup_forge_project(test: TestProject) -> (TestProject, TestCommand) {
453 let cmd = test.forge_command();
454 (test, cmd)
455}
456
457#[derive(Clone, Debug)]
459pub struct RemoteProject {
460 id: String,
461 run_build: bool,
462 run_commands: Vec<Vec<String>>,
463 path_style: PathStyle,
464}
465
466impl RemoteProject {
467 pub fn new(id: impl Into<String>) -> Self {
468 Self {
469 id: id.into(),
470 run_build: true,
471 run_commands: vec![],
472 path_style: PathStyle::Dapptools,
473 }
474 }
475
476 pub fn set_build(mut self, run_build: bool) -> Self {
478 self.run_build = run_build;
479 self
480 }
481
482 pub fn path_style(mut self, path_style: PathStyle) -> Self {
484 self.path_style = path_style;
485 self
486 }
487
488 pub fn cmd(mut self, cmd: impl IntoIterator<Item = impl Into<String>>) -> Self {
490 self.run_commands.push(cmd.into_iter().map(Into::into).collect());
491 self
492 }
493}
494
495impl<T: Into<String>> From<T> for RemoteProject {
496 fn from(id: T) -> Self {
497 Self::new(id)
498 }
499}
500
501pub fn setup_forge_remote(prj: impl Into<RemoteProject>) -> (TestProject, TestCommand) {
512 try_setup_forge_remote(prj).unwrap()
513}
514
515pub fn try_setup_forge_remote(
517 config: impl Into<RemoteProject>,
518) -> Result<(TestProject, TestCommand)> {
519 let config = config.into();
520 let mut tmp = TempProject::checkout(&config.id).wrap_err("failed to checkout project")?;
521 tmp.project_mut().paths = config.path_style.paths(tmp.root())?;
522
523 let prj = TestProject::with_project(tmp);
524 if config.run_build {
525 let mut cmd = prj.forge_command();
526 cmd.arg("build").assert_success();
527 }
528 for addon in config.run_commands {
529 debug_assert!(!addon.is_empty());
530 let mut cmd = Command::new(&addon[0]);
531 if addon.len() > 1 {
532 cmd.args(&addon[1..]);
533 }
534 let status = cmd
535 .current_dir(prj.root())
536 .stdout(Stdio::null())
537 .stderr(Stdio::null())
538 .status()
539 .wrap_err_with(|| format!("Failed to execute {addon:?}"))?;
540 eyre::ensure!(status.success(), "Failed to execute command {:?}", addon);
541 }
542
543 let cmd = prj.forge_command();
544 Ok((prj, cmd))
545}
546
547pub fn setup_cast(name: &str, style: PathStyle) -> (TestProject, TestCommand) {
548 setup_cast_project(TestProject::new(name, style))
549}
550
551pub fn setup_cast_project(test: TestProject) -> (TestProject, TestCommand) {
552 let cmd = test.cast_command();
553 (test, cmd)
554}
555
556#[derive(Clone, Debug)]
560pub struct TestProject<
561 T: ArtifactOutput<CompilerContract = Contract> + Default = ConfigurableArtifacts,
562> {
563 exe_root: PathBuf,
565 inner: Arc<TempProject<MultiCompiler, T>>,
567}
568
569impl TestProject {
570 pub fn new(name: &str, style: PathStyle) -> Self {
574 let id = NEXT_ID.fetch_add(1, Ordering::SeqCst);
575 let project = pretty_err(name, TempProject::with_style(&format!("{name}-{id}"), style));
576 Self::with_project(project)
577 }
578
579 pub fn with_project(project: TempProject) -> Self {
580 init_tracing();
581 let this = env::current_exe().unwrap();
582 let exe_root = this.parent().expect("executable's directory").to_path_buf();
583 Self { exe_root, inner: Arc::new(project) }
584 }
585
586 pub fn root(&self) -> &Path {
588 self.inner.root()
589 }
590
591 pub fn paths(&self) -> &ProjectPathsConfig {
593 self.inner.paths()
594 }
595
596 pub fn config(&self) -> PathBuf {
598 self.root().join(Config::FILE_NAME)
599 }
600
601 pub fn cache(&self) -> &PathBuf {
603 &self.paths().cache
604 }
605
606 pub fn artifacts(&self) -> &PathBuf {
608 &self.paths().artifacts
609 }
610
611 pub fn clear(&self) {
613 self.clear_cache();
614 self.clear_artifacts();
615 }
616
617 pub fn clear_cache(&self) {
619 let _ = fs::remove_file(self.cache());
620 }
621
622 pub fn clear_artifacts(&self) {
624 let _ = fs::remove_dir_all(self.artifacts());
625 }
626
627 pub fn update_config(&self, f: impl FnOnce(&mut Config)) {
629 self._update_config(Box::new(f));
630 }
631
632 fn _update_config(&self, f: Box<dyn FnOnce(&mut Config) + '_>) {
633 let mut config = self
634 .config()
635 .exists()
636 .then_some(())
637 .and_then(|()| Config::load_with_root(self.root()).ok())
638 .unwrap_or_default();
639 config.remappings.clear();
640 f(&mut config);
641 self.write_config(config);
642 }
643
644 #[doc(hidden)] pub fn write_config(&self, config: Config) {
647 let file = self.config();
648 pretty_err(&file, fs::write(&file, config.to_string_pretty().unwrap()));
649 }
650
651 pub fn add_source(&self, name: &str, contents: &str) -> PathBuf {
653 self.inner.add_source(name, Self::add_source_prelude(contents)).unwrap()
654 }
655
656 pub fn add_raw_source(&self, name: &str, contents: &str) -> PathBuf {
658 self.inner.add_source(name, contents).unwrap()
659 }
660
661 pub fn add_script(&self, name: &str, contents: &str) -> PathBuf {
663 self.inner.add_script(name, Self::add_source_prelude(contents)).unwrap()
664 }
665
666 pub fn add_test(&self, name: &str, contents: &str) -> PathBuf {
668 self.inner.add_test(name, Self::add_source_prelude(contents)).unwrap()
669 }
670
671 pub fn add_lib(&self, name: &str, contents: &str) -> PathBuf {
673 self.inner.add_lib(name, Self::add_source_prelude(contents)).unwrap()
674 }
675
676 fn add_source_prelude(s: &str) -> String {
677 let mut s = s.to_string();
678 if !s.contains("pragma solidity") {
679 s = format!("pragma solidity ={SOLC_VERSION};\n{s}");
680 }
681 if !s.contains("// SPDX") {
682 s = format!("// SPDX-License-Identifier: MIT OR Apache-2.0\n{s}");
683 }
684 s
685 }
686
687 #[track_caller]
689 pub fn assert_config_exists(&self) {
690 assert!(self.config().exists());
691 }
692
693 #[track_caller]
695 pub fn assert_cache_exists(&self) {
696 assert!(self.cache().exists());
697 }
698
699 #[track_caller]
701 pub fn assert_artifacts_dir_exists(&self) {
702 assert!(self.paths().artifacts.exists());
703 }
704
705 #[track_caller]
707 pub fn assert_create_dirs_exists(&self) {
708 self.paths().create_all().unwrap_or_else(|_| panic!("Failed to create project paths"));
709 CompilerCache::<SolcSettings>::default()
710 .write(&self.paths().cache)
711 .expect("Failed to create cache");
712 self.assert_all_paths_exist();
713 }
714
715 #[track_caller]
717 pub fn assert_style_paths_exist(&self, style: PathStyle) {
718 let paths = style.paths(&self.paths().root).unwrap();
719 config_paths_exist(&paths, self.inner.project().cached);
720 }
721
722 #[track_caller]
724 pub fn copy_to(&self, target: impl AsRef<Path>) {
725 let target = target.as_ref();
726 pretty_err(target, fs::create_dir_all(target));
727 pretty_err(target, copy_dir(self.root(), target));
728 }
729
730 pub fn create_file(&self, path: impl AsRef<Path>, contents: &str) -> PathBuf {
733 let path = path.as_ref();
734 if !path.is_relative() {
735 panic!("create_file(): file path is absolute");
736 }
737 let path = self.root().join(path);
738 if let Some(parent) = path.parent() {
739 pretty_err(parent, std::fs::create_dir_all(parent));
740 }
741 let file = pretty_err(&path, File::create(&path));
742 let mut writer = BufWriter::new(file);
743 pretty_err(&path, writer.write_all(contents.as_bytes()));
744 path
745 }
746
747 pub fn insert_ds_test(&self) -> PathBuf {
749 let s = include_str!("../../../testdata/lib/ds-test/src/test.sol");
750 self.add_source("test.sol", s)
751 }
752
753 pub fn insert_console(&self) -> PathBuf {
755 let s = include_str!("../../../testdata/default/logs/console.sol");
756 self.add_source("console.sol", s)
757 }
758
759 pub fn insert_vm(&self) -> PathBuf {
761 let s = include_str!("../../../testdata/cheats/Vm.sol");
762 self.add_source("Vm.sol", s)
763 }
764
765 pub fn assert_all_paths_exist(&self) {
771 let paths = self.paths();
772 config_paths_exist(paths, self.inner.project().cached);
773 }
774
775 pub fn assert_cleaned(&self) {
777 let paths = self.paths();
778 assert!(!paths.cache.exists());
779 assert!(!paths.artifacts.exists());
780 }
781
782 #[track_caller]
784 pub fn forge_command(&self) -> TestCommand {
785 let cmd = self.forge_bin();
786 let _lock = CURRENT_DIR_LOCK.lock();
787 TestCommand {
788 project: self.clone(),
789 cmd,
790 current_dir_lock: None,
791 saved_cwd: pretty_err("<current dir>", std::env::current_dir()),
792 stdin: None,
793 redact_output: true,
794 }
795 }
796
797 pub fn cast_command(&self) -> TestCommand {
799 let mut cmd = self.cast_bin();
800 cmd.current_dir(self.inner.root());
801 let _lock = CURRENT_DIR_LOCK.lock();
802 TestCommand {
803 project: self.clone(),
804 cmd,
805 current_dir_lock: None,
806 saved_cwd: pretty_err("<current dir>", std::env::current_dir()),
807 stdin: None,
808 redact_output: true,
809 }
810 }
811
812 pub fn forge_bin(&self) -> Command {
814 let forge = self.exe_root.join(format!("../forge{}", env::consts::EXE_SUFFIX));
815 let forge = forge.canonicalize().unwrap_or_else(|_| forge.clone());
816 let mut cmd = Command::new(forge);
817 cmd.current_dir(self.inner.root());
818 cmd.env("NO_COLOR", "1");
820 cmd
821 }
822
823 pub fn cast_bin(&self) -> Command {
825 let cast = self.exe_root.join(format!("../cast{}", env::consts::EXE_SUFFIX));
826 let cast = cast.canonicalize().unwrap_or_else(|_| cast.clone());
827 let mut cmd = Command::new(cast);
828 cmd.env("NO_COLOR", "1");
830 cmd
831 }
832
833 pub fn config_from_output<I, A>(&self, args: I) -> Config
835 where
836 I: IntoIterator<Item = A>,
837 A: AsRef<OsStr>,
838 {
839 let mut cmd = self.forge_bin();
840 cmd.arg("config").arg("--root").arg(self.root()).args(args).arg("--json");
841 let output = cmd.output().unwrap();
842 let c = lossy_string(&output.stdout);
843 let config: Config = serde_json::from_str(c.as_ref()).unwrap();
844 config.sanitized()
845 }
846
847 pub fn wipe(&self) {
849 pretty_err(self.root(), fs::remove_dir_all(self.root()));
850 pretty_err(self.root(), fs::create_dir_all(self.root()));
851 }
852
853 pub fn wipe_contracts(&self) {
855 fn rm_create(path: &Path) {
856 pretty_err(path, fs::remove_dir_all(path));
857 pretty_err(path, fs::create_dir(path));
858 }
859 rm_create(&self.paths().sources);
860 rm_create(&self.paths().tests);
861 rm_create(&self.paths().scripts);
862 }
863}
864
865impl Drop for TestCommand {
866 fn drop(&mut self) {
867 let _lock = self.current_dir_lock.take().unwrap_or_else(|| CURRENT_DIR_LOCK.lock());
868 if self.saved_cwd.exists() {
869 let _ = std::env::set_current_dir(&self.saved_cwd);
870 }
871 }
872}
873
874fn config_paths_exist(paths: &ProjectPathsConfig, cached: bool) {
875 if cached {
876 assert!(paths.cache.exists());
877 }
878 assert!(paths.sources.exists());
879 assert!(paths.artifacts.exists());
880 paths.libraries.iter().for_each(|lib| assert!(lib.exists()));
881}
882
883#[track_caller]
884pub fn pretty_err<T, E: std::error::Error>(path: impl AsRef<Path>, res: Result<T, E>) -> T {
885 match res {
886 Ok(t) => t,
887 Err(err) => panic!("{}: {err}", path.as_ref().display()),
888 }
889}
890
891pub fn read_string(path: impl AsRef<Path>) -> String {
892 let path = path.as_ref();
893 pretty_err(path, std::fs::read_to_string(path))
894}
895
896pub struct TestCommand {
898 saved_cwd: PathBuf,
899 project: TestProject,
901 cmd: Command,
903 current_dir_lock: Option<parking_lot::MutexGuard<'static, ()>>,
905 stdin: Option<Vec<u8>>,
906 redact_output: bool,
908}
909
910impl TestCommand {
911 pub fn cmd(&mut self) -> &mut Command {
913 &mut self.cmd
914 }
915
916 pub fn set_cmd(&mut self, cmd: Command) -> &mut Self {
918 self.cmd = cmd;
919 self
920 }
921
922 pub fn forge_fuse(&mut self) -> &mut Self {
924 self.set_cmd(self.project.forge_bin())
925 }
926
927 pub fn cast_fuse(&mut self) -> &mut Self {
929 self.set_cmd(self.project.cast_bin())
930 }
931
932 pub fn set_current_dir(&mut self, p: impl AsRef<Path>) {
934 drop(self.current_dir_lock.take());
935 let lock = CURRENT_DIR_LOCK.lock();
936 self.current_dir_lock = Some(lock);
937 let p = p.as_ref();
938 pretty_err(p, std::env::set_current_dir(p));
939 }
940
941 pub fn arg<A: AsRef<OsStr>>(&mut self, arg: A) -> &mut Self {
943 self.cmd.arg(arg);
944 self
945 }
946
947 pub fn args<I, A>(&mut self, args: I) -> &mut Self
949 where
950 I: IntoIterator<Item = A>,
951 A: AsRef<OsStr>,
952 {
953 self.cmd.args(args);
954 self
955 }
956
957 pub fn stdin(&mut self, stdin: impl Into<Vec<u8>>) -> &mut Self {
959 self.stdin = Some(stdin.into());
960 self
961 }
962
963 pub fn root_arg(&mut self) -> &mut Self {
965 let root = self.project.root().to_path_buf();
966 self.arg("--root").arg(root)
967 }
968
969 pub fn env(&mut self, k: impl AsRef<OsStr>, v: impl AsRef<OsStr>) {
971 self.cmd.env(k, v);
972 }
973
974 pub fn envs<I, K, V>(&mut self, envs: I)
976 where
977 I: IntoIterator<Item = (K, V)>,
978 K: AsRef<OsStr>,
979 V: AsRef<OsStr>,
980 {
981 self.cmd.envs(envs);
982 }
983
984 pub fn unset_env(&mut self, k: impl AsRef<OsStr>) {
986 self.cmd.env_remove(k);
987 }
988
989 pub fn current_dir<P: AsRef<Path>>(&mut self, dir: P) -> &mut Self {
995 self.cmd.current_dir(dir);
996 self
997 }
998
999 #[track_caller]
1001 pub fn config(&mut self) -> Config {
1002 self.cmd.args(["config", "--json"]);
1003 let output = self.assert().success().get_output().stdout_lossy();
1004 self.forge_fuse();
1005 serde_json::from_str(output.as_ref()).unwrap()
1006 }
1007
1008 #[track_caller]
1010 pub fn git_init(&self) {
1011 let mut cmd = Command::new("git");
1012 cmd.arg("init").current_dir(self.project.root());
1013 let output = OutputAssert::new(cmd.output().unwrap());
1014 output.success();
1015 }
1016
1017 #[track_caller]
1019 pub fn git_submodule_status(&self) -> Output {
1020 let mut cmd = Command::new("git");
1021 cmd.arg("submodule").arg("status").current_dir(self.project.root());
1022 cmd.output().unwrap()
1023 }
1024
1025 #[track_caller]
1027 pub fn git_add(&self) {
1028 let mut cmd = Command::new("git");
1029 cmd.current_dir(self.project.root());
1030 cmd.arg("add").arg(".");
1031 let output = OutputAssert::new(cmd.output().unwrap());
1032 output.success();
1033 }
1034
1035 #[track_caller]
1037 pub fn git_commit(&self, msg: &str) {
1038 let mut cmd = Command::new("git");
1039 cmd.current_dir(self.project.root());
1040 cmd.arg("commit").arg("-m").arg(msg);
1041 let output = OutputAssert::new(cmd.output().unwrap());
1042 output.success();
1043 }
1044
1045 #[track_caller]
1047 pub fn assert(&mut self) -> OutputAssert {
1048 let assert = OutputAssert::new(self.execute());
1049 if self.redact_output {
1050 return assert.with_assert(test_assert());
1051 }
1052 assert
1053 }
1054
1055 #[track_caller]
1057 pub fn assert_success(&mut self) -> OutputAssert {
1058 self.assert().success()
1059 }
1060
1061 #[track_caller]
1063 pub fn assert_json_stdout(&mut self, expected: impl IntoData) {
1064 let expected = expected.is(snapbox::data::DataFormat::Json).unordered();
1065 let stdout = self.assert_success().get_output().stdout.clone();
1066 let actual = stdout.into_data().is(snapbox::data::DataFormat::Json).unordered();
1067 assert_data_eq!(actual, expected);
1068 }
1069
1070 #[track_caller]
1072 pub fn assert_json_stderr(&mut self, success: bool, expected: impl IntoData) {
1073 let expected = expected.is(snapbox::data::DataFormat::Json).unordered();
1074 let stderr = if success { self.assert_success() } else { self.assert_failure() }
1075 .get_output()
1076 .stderr
1077 .clone();
1078 let actual = stderr.into_data().is(snapbox::data::DataFormat::Json).unordered();
1079 assert_data_eq!(actual, expected);
1080 }
1081
1082 #[track_caller]
1084 pub fn assert_empty_stdout(&mut self) {
1085 self.assert_success().stdout_eq(Data::new());
1086 }
1087
1088 #[track_caller]
1090 pub fn assert_failure(&mut self) -> OutputAssert {
1091 self.assert().failure()
1092 }
1093
1094 #[track_caller]
1096 pub fn assert_code(&mut self, expected: i32) -> OutputAssert {
1097 self.assert().code(expected)
1098 }
1099
1100 #[track_caller]
1102 pub fn assert_empty_stderr(&mut self) {
1103 self.assert_failure().stderr_eq(Data::new());
1104 }
1105
1106 #[track_caller]
1109 pub fn assert_file(&mut self, data: impl IntoData) {
1110 self.assert_file_with(|this, path| _ = this.arg(path).assert_success(), data);
1111 }
1112
1113 #[track_caller]
1116 pub fn assert_file_with(&mut self, f: impl FnOnce(&mut Self, &Path), data: impl IntoData) {
1117 let file = tempfile::NamedTempFile::new().expect("couldn't create temporary file");
1118 f(self, file.path());
1119 assert_data_eq!(Data::read_from(file.path(), None), data);
1120 }
1121
1122 pub fn with_no_redact(&mut self) -> &mut Self {
1124 self.redact_output = false;
1125 self
1126 }
1127
1128 #[track_caller]
1130 pub fn execute(&mut self) -> Output {
1131 self.try_execute().unwrap()
1132 }
1133
1134 #[track_caller]
1135 pub fn try_execute(&mut self) -> std::io::Result<Output> {
1136 test_debug!("executing {:?}", self.cmd);
1137 let mut child =
1138 self.cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).stdin(Stdio::piped()).spawn()?;
1139 if let Some(bytes) = self.stdin.take() {
1140 child.stdin.take().unwrap().write_all(&bytes)?;
1141 }
1142 child.wait_with_output()
1143 }
1144}
1145
1146fn test_assert() -> snapbox::Assert {
1147 snapbox::Assert::new()
1148 .action_env(snapbox::assert::DEFAULT_ACTION_ENV)
1149 .redact_with(test_redactions())
1150}
1151
1152fn test_redactions() -> snapbox::Redactions {
1153 static REDACTIONS: LazyLock<snapbox::Redactions> = LazyLock::new(|| {
1154 let mut r = snapbox::Redactions::new();
1155 let redactions = [
1156 ("[SOLC_VERSION]", r"Solc( version)? \d+.\d+.\d+"),
1157 ("[ELAPSED]", r"(finished )?in \d+(\.\d+)?\w?s( \(.*?s CPU time\))?"),
1158 ("[GAS]", r"[Gg]as( used)?: \d+"),
1159 ("[GAS_COST]", r"[Gg]as cost\s*\(\d+\)"),
1160 ("[GAS_LIMIT]", r"[Gg]as limit\s*\(\d+\)"),
1161 ("[AVG_GAS]", r"μ: \d+, ~: \d+"),
1162 ("[FILE]", r"-->.*\.sol"),
1163 ("[FILE]", r"Location(.|\n)*\.rs(.|\n)*Backtrace"),
1164 ("[COMPILING_FILES]", r"Compiling \d+ files?"),
1165 ("[TX_HASH]", r"Transaction hash: 0x[0-9A-Fa-f]{64}"),
1166 ("[ADDRESS]", r"Address: +0x[0-9A-Fa-f]{40}"),
1167 ("[PUBLIC_KEY]", r"Public key: +0x[0-9A-Fa-f]{128}"),
1168 ("[PRIVATE_KEY]", r"Private key: +0x[0-9A-Fa-f]{64}"),
1169 ("[UPDATING_DEPENDENCIES]", r"Updating dependencies in .*"),
1170 ("[SAVED_TRANSACTIONS]", r"Transactions saved to: .*\.json"),
1171 ("[SAVED_SENSITIVE_VALUES]", r"Sensitive values saved to: .*\.json"),
1172 ("[ESTIMATED_GAS_PRICE]", r"Estimated gas price:\s*(\d+(\.\d+)?)\s*gwei"),
1173 ("[ESTIMATED_TOTAL_GAS_USED]", r"Estimated total gas used for script: \d+"),
1174 (
1175 "[ESTIMATED_AMOUNT_REQUIRED]",
1176 r"Estimated amount required:\s*(\d+(\.\d+)?)\s*[A-Z]{3}",
1177 ),
1178 ];
1179 for (placeholder, re) in redactions {
1180 r.insert(placeholder, Regex::new(re).expect(re)).expect(re);
1181 }
1182 r
1183 });
1184 REDACTIONS.clone()
1185}
1186
1187pub trait OutputExt {
1189 fn stdout_lossy(&self) -> String;
1191}
1192
1193impl OutputExt for Output {
1194 fn stdout_lossy(&self) -> String {
1195 lossy_string(&self.stdout)
1196 }
1197}
1198
1199pub fn lossy_string(bytes: &[u8]) -> String {
1200 String::from_utf8_lossy(bytes).replace("\r\n", "\n")
1201}