1use crate::init_tracing;
2use eyre::{Result, WrapErr};
3use foundry_compilers::{
4 ArtifactOutput, ConfigurableArtifacts, PathStyle, ProjectPathsConfig,
5 artifacts::Contract,
6 cache::CompilerCache,
7 compilers::multi::MultiCompiler,
8 project_util::{TempProject, copy_dir},
9 solc::SolcSettings,
10};
11use foundry_config::Config;
12use parking_lot::Mutex;
13use regex::Regex;
14use snapbox::{Data, IntoData, assert_data_eq, cmd::OutputAssert};
15use std::{
16 env,
17 ffi::OsStr,
18 fs::{self, File},
19 io::{BufWriter, IsTerminal, Read, Seek, Write},
20 path::{Path, PathBuf},
21 process::{ChildStdin, Command, Output, Stdio},
22 sync::{
23 Arc, LazyLock,
24 atomic::{AtomicUsize, Ordering},
25 },
26};
27
28static CURRENT_DIR_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
29
30pub const FORGE_STD_REVISION: &str = include_str!("../../../testdata/forge-std-rev");
32
33pub static IS_TTY: LazyLock<bool> = LazyLock::new(|| std::io::stdout().is_terminal());
35
36static TEMPLATE_PATH: LazyLock<PathBuf> =
39 LazyLock::new(|| env::temp_dir().join("foundry-forge-test-template"));
40
41static TEMPLATE_LOCK: LazyLock<PathBuf> =
44 LazyLock::new(|| env::temp_dir().join("foundry-forge-test-template.lock"));
45
46static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
48
49pub const SOLC_VERSION: &str = "0.8.30";
51
52pub const OTHER_SOLC_VERSION: &str = "0.8.26";
56
57#[derive(Clone, Debug)]
59#[must_use = "ExtTester does nothing unless you `run` it"]
60pub struct ExtTester {
61 pub org: &'static str,
62 pub name: &'static str,
63 pub rev: &'static str,
64 pub style: PathStyle,
65 pub fork_block: Option<u64>,
66 pub args: Vec<String>,
67 pub envs: Vec<(String, String)>,
68 pub install_commands: Vec<Vec<String>>,
69}
70
71impl ExtTester {
72 pub fn new(org: &'static str, name: &'static str, rev: &'static str) -> Self {
74 Self {
75 org,
76 name,
77 rev,
78 style: PathStyle::Dapptools,
79 fork_block: None,
80 args: vec![],
81 envs: vec![],
82 install_commands: vec![],
83 }
84 }
85
86 pub fn style(mut self, style: PathStyle) -> Self {
88 self.style = style;
89 self
90 }
91
92 pub fn fork_block(mut self, fork_block: u64) -> Self {
94 self.fork_block = Some(fork_block);
95 self
96 }
97
98 pub fn arg(mut self, arg: impl Into<String>) -> Self {
100 self.args.push(arg.into());
101 self
102 }
103
104 pub fn args<I, A>(mut self, args: I) -> Self
106 where
107 I: IntoIterator<Item = A>,
108 A: Into<String>,
109 {
110 self.args.extend(args.into_iter().map(Into::into));
111 self
112 }
113
114 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
116 self.envs.push((key.into(), value.into()));
117 self
118 }
119
120 pub fn envs<I, K, V>(mut self, envs: I) -> Self
122 where
123 I: IntoIterator<Item = (K, V)>,
124 K: Into<String>,
125 V: Into<String>,
126 {
127 self.envs.extend(envs.into_iter().map(|(k, v)| (k.into(), v.into())));
128 self
129 }
130
131 pub fn install_command(mut self, command: &[&str]) -> Self {
136 self.install_commands.push(command.iter().map(|s| s.to_string()).collect());
137 self
138 }
139
140 pub fn setup_forge_prj(&self) -> (TestProject, TestCommand) {
141 let (prj, mut test_cmd) = setup_forge(self.name, self.style.clone());
142
143 if let Some(vyper) = &prj.inner.project().compiler.vyper {
145 let vyper_dir = vyper.path.parent().expect("vyper path should have a parent");
146 let forge_bin = prj.exe_root.join(format!("../forge{}", env::consts::EXE_SUFFIX));
147 let forge_dir = forge_bin.parent().expect("forge path should have a parent");
148
149 let existing_path = std::env::var_os("PATH").unwrap_or_default();
150 let mut new_paths = vec![vyper_dir.to_path_buf(), forge_dir.to_path_buf()];
151 new_paths.extend(std::env::split_paths(&existing_path));
152
153 let joined_path = std::env::join_paths(new_paths).expect("failed to join PATH");
154 test_cmd.env("PATH", joined_path);
155 }
156
157 prj.wipe();
159
160 let repo_url = format!("https://github.com/{}/{}.git", self.org, self.name);
162 let root = prj.root().to_str().unwrap();
163 clone_remote(&repo_url, root);
164
165 if self.rev.is_empty() {
167 let mut git = Command::new("git");
168 git.current_dir(root).args(["log", "-n", "1"]);
169 println!("$ {git:?}");
170 let output = git.output().unwrap();
171 if !output.status.success() {
172 panic!("git log failed: {output:?}");
173 }
174 let stdout = String::from_utf8(output.stdout).unwrap();
175 let commit = stdout.lines().next().unwrap().split_whitespace().nth(1).unwrap();
176 panic!("pin to latest commit: {commit}");
177 } else {
178 let mut git = Command::new("git");
179 git.current_dir(root).args(["checkout", self.rev]);
180 println!("$ {git:?}");
181 let status = git.status().unwrap();
182 if !status.success() {
183 panic!("git checkout failed: {status}");
184 }
185 }
186
187 (prj, test_cmd)
188 }
189
190 pub fn run_install_commands(&self, root: &str) {
191 for install_command in &self.install_commands {
192 let mut install_cmd = Command::new(&install_command[0]);
193 install_cmd.args(&install_command[1..]).current_dir(root);
194 println!("cd {root}; {install_cmd:?}");
195 match install_cmd.status() {
196 Ok(s) => {
197 println!("\n\n{install_cmd:?}: {s}");
198 if s.success() {
199 break;
200 }
201 }
202 Err(e) => {
203 eprintln!("\n\n{install_cmd:?}: {e}");
204 }
205 }
206 }
207 }
208
209 pub fn run(&self) {
211 if self.fork_block.is_some() && std::env::var_os("ETH_RPC_URL").is_none() {
213 eprintln!("ETH_RPC_URL is not set; skipping");
214 return;
215 }
216
217 let (prj, mut test_cmd) = self.setup_forge_prj();
218
219 self.run_install_commands(prj.root().to_str().unwrap());
221
222 test_cmd.arg("test");
224 test_cmd.args(&self.args);
225 test_cmd.args(["--fuzz-runs=32", "--ffi", "-vvv"]);
226
227 test_cmd.envs(self.envs.iter().map(|(k, v)| (k, v)));
228 if let Some(fork_block) = self.fork_block {
229 test_cmd.env("FOUNDRY_ETH_RPC_URL", crate::rpc::next_http_archive_rpc_url());
230 test_cmd.env("FOUNDRY_FORK_BLOCK_NUMBER", fork_block.to_string());
231 }
232 test_cmd.env("FOUNDRY_INVARIANT_DEPTH", "15");
233 test_cmd.env("FOUNDRY_ALLOW_INTERNAL_EXPECT_REVERT", "true");
234
235 test_cmd.assert_success();
236 }
237}
238
239#[expect(clippy::disallowed_macros)]
255pub fn initialize(target: &Path) {
256 println!("initializing {}", target.display());
257
258 let tpath = TEMPLATE_PATH.as_path();
259 pretty_err(tpath, fs::create_dir_all(tpath));
260
261 let mut lock = crate::fd_lock::new_lock(TEMPLATE_LOCK.as_path());
263 let mut _read = lock.read().unwrap();
264 if fs::read(&*TEMPLATE_LOCK).unwrap() != b"1" {
265 drop(_read);
275
276 let mut write = lock.write().unwrap();
277
278 let mut data = String::new();
279 write.read_to_string(&mut data).unwrap();
280
281 if data != "1" {
282 let (prj, mut cmd) = setup_forge("template", foundry_compilers::PathStyle::Dapptools);
284 println!("- initializing template dir in {}", prj.root().display());
285
286 cmd.args(["init", "--force"]).assert_success();
287 prj.write_config(Config {
288 solc: Some(foundry_config::SolcReq::Version(SOLC_VERSION.parse().unwrap())),
289 ..Default::default()
290 });
291
292 let output = Command::new("git")
294 .current_dir(prj.root().join("lib/forge-std"))
295 .args(["checkout", FORGE_STD_REVISION])
296 .output()
297 .expect("failed to checkout forge-std");
298 assert!(output.status.success(), "{output:#?}");
299
300 cmd.forge_fuse().arg("build").assert_success();
302
303 let _ = fs::remove_dir_all(tpath);
305
306 pretty_err(tpath, copy_dir(prj.root(), tpath));
308
309 write.set_len(0).unwrap();
311 write.seek(std::io::SeekFrom::Start(0)).unwrap();
312 write.write_all(b"1").unwrap();
313 }
314
315 drop(write);
317 _read = lock.read().unwrap();
318 }
319
320 println!("- copying template dir from {}", tpath.display());
321 pretty_err(target, fs::create_dir_all(target));
322 pretty_err(target, copy_dir(tpath, target));
323}
324
325pub fn clone_remote(repo_url: &str, target_dir: &str) {
327 let mut cmd = Command::new("git");
328 cmd.args(["clone", "--recursive", "--shallow-submodules"]);
329 cmd.args([repo_url, target_dir]);
330 println!("{cmd:?}");
331 let status = cmd.status().unwrap();
332 if !status.success() {
333 panic!("git clone failed: {status}");
334 }
335 println!();
336}
337
338#[track_caller]
344pub fn setup_forge(name: &str, style: PathStyle) -> (TestProject, TestCommand) {
345 setup_forge_project(TestProject::new(name, style))
346}
347
348pub fn setup_forge_project(test: TestProject) -> (TestProject, TestCommand) {
349 let cmd = test.forge_command();
350 (test, cmd)
351}
352
353#[derive(Clone, Debug)]
355pub struct RemoteProject {
356 id: String,
357 run_build: bool,
358 run_commands: Vec<Vec<String>>,
359 path_style: PathStyle,
360}
361
362impl RemoteProject {
363 pub fn new(id: impl Into<String>) -> Self {
364 Self {
365 id: id.into(),
366 run_build: true,
367 run_commands: vec![],
368 path_style: PathStyle::Dapptools,
369 }
370 }
371
372 pub fn set_build(mut self, run_build: bool) -> Self {
374 self.run_build = run_build;
375 self
376 }
377
378 pub fn path_style(mut self, path_style: PathStyle) -> Self {
380 self.path_style = path_style;
381 self
382 }
383
384 pub fn cmd(mut self, cmd: impl IntoIterator<Item = impl Into<String>>) -> Self {
386 self.run_commands.push(cmd.into_iter().map(Into::into).collect());
387 self
388 }
389}
390
391impl<T: Into<String>> From<T> for RemoteProject {
392 fn from(id: T) -> Self {
393 Self::new(id)
394 }
395}
396
397pub fn setup_forge_remote(prj: impl Into<RemoteProject>) -> (TestProject, TestCommand) {
408 try_setup_forge_remote(prj).unwrap()
409}
410
411pub fn try_setup_forge_remote(
413 config: impl Into<RemoteProject>,
414) -> Result<(TestProject, TestCommand)> {
415 let config = config.into();
416 let mut tmp = TempProject::checkout(&config.id).wrap_err("failed to checkout project")?;
417 tmp.project_mut().paths = config.path_style.paths(tmp.root())?;
418
419 let prj = TestProject::with_project(tmp);
420 if config.run_build {
421 let mut cmd = prj.forge_command();
422 cmd.arg("build").assert_success();
423 }
424 for addon in config.run_commands {
425 debug_assert!(!addon.is_empty());
426 let mut cmd = Command::new(&addon[0]);
427 if addon.len() > 1 {
428 cmd.args(&addon[1..]);
429 }
430 let status = cmd
431 .current_dir(prj.root())
432 .stdout(Stdio::null())
433 .stderr(Stdio::null())
434 .status()
435 .wrap_err_with(|| format!("Failed to execute {addon:?}"))?;
436 eyre::ensure!(status.success(), "Failed to execute command {:?}", addon);
437 }
438
439 let cmd = prj.forge_command();
440 Ok((prj, cmd))
441}
442
443pub fn setup_cast(name: &str, style: PathStyle) -> (TestProject, TestCommand) {
444 setup_cast_project(TestProject::new(name, style))
445}
446
447pub fn setup_cast_project(test: TestProject) -> (TestProject, TestCommand) {
448 let cmd = test.cast_command();
449 (test, cmd)
450}
451
452#[derive(Clone, Debug)]
456pub struct TestProject<
457 T: ArtifactOutput<CompilerContract = Contract> + Default = ConfigurableArtifacts,
458> {
459 exe_root: PathBuf,
461 inner: Arc<TempProject<MultiCompiler, T>>,
463}
464
465impl TestProject {
466 pub fn new(name: &str, style: PathStyle) -> Self {
470 let id = NEXT_ID.fetch_add(1, Ordering::SeqCst);
471 let project = pretty_err(name, TempProject::with_style(&format!("{name}-{id}"), style));
472 Self::with_project(project)
473 }
474
475 pub fn with_project(project: TempProject) -> Self {
476 init_tracing();
477 let this = env::current_exe().unwrap();
478 let exe_root = this.parent().expect("executable's directory").to_path_buf();
479 Self { exe_root, inner: Arc::new(project) }
480 }
481
482 pub fn root(&self) -> &Path {
484 self.inner.root()
485 }
486
487 pub fn paths(&self) -> &ProjectPathsConfig {
489 self.inner.paths()
490 }
491
492 pub fn config(&self) -> PathBuf {
494 self.root().join(Config::FILE_NAME)
495 }
496
497 pub fn cache(&self) -> &PathBuf {
499 &self.paths().cache
500 }
501
502 pub fn artifacts(&self) -> &PathBuf {
504 &self.paths().artifacts
505 }
506
507 pub fn clear(&self) {
509 self.clear_cache();
510 self.clear_artifacts();
511 }
512
513 pub fn clear_cache(&self) {
515 let _ = fs::remove_file(self.cache());
516 }
517
518 pub fn clear_artifacts(&self) {
520 let _ = fs::remove_dir_all(self.artifacts());
521 }
522
523 pub fn update_config(&self, f: impl FnOnce(&mut Config)) {
525 self._update_config(Box::new(f));
526 }
527
528 fn _update_config(&self, f: Box<dyn FnOnce(&mut Config) + '_>) {
529 let mut config = self
530 .config()
531 .exists()
532 .then_some(())
533 .and_then(|()| Config::load_with_root(self.root()).ok())
534 .unwrap_or_default();
535 config.remappings.clear();
536 f(&mut config);
537 self.write_config(config);
538 }
539
540 #[doc(hidden)] pub fn write_config(&self, config: Config) {
543 let file = self.config();
544 pretty_err(&file, fs::write(&file, config.to_string_pretty().unwrap()));
545 }
546
547 pub fn add_source(&self, name: &str, contents: &str) -> PathBuf {
549 self.inner.add_source(name, Self::add_source_prelude(contents)).unwrap()
550 }
551
552 pub fn add_raw_source(&self, name: &str, contents: &str) -> PathBuf {
554 self.inner.add_source(name, contents).unwrap()
555 }
556
557 pub fn add_script(&self, name: &str, contents: &str) -> PathBuf {
559 self.inner.add_script(name, Self::add_source_prelude(contents)).unwrap()
560 }
561
562 pub fn add_test(&self, name: &str, contents: &str) -> PathBuf {
564 self.inner.add_test(name, Self::add_source_prelude(contents)).unwrap()
565 }
566
567 pub fn add_lib(&self, name: &str, contents: &str) -> PathBuf {
569 self.inner.add_lib(name, Self::add_source_prelude(contents)).unwrap()
570 }
571
572 fn add_source_prelude(s: &str) -> String {
573 let mut s = s.to_string();
574 if !s.contains("pragma solidity") {
575 s = format!("pragma solidity ={SOLC_VERSION};\n{s}");
576 }
577 if !s.contains("// SPDX") {
578 s = format!("// SPDX-License-Identifier: MIT OR Apache-2.0\n{s}");
579 }
580 s
581 }
582
583 #[track_caller]
585 pub fn assert_config_exists(&self) {
586 assert!(self.config().exists());
587 }
588
589 #[track_caller]
591 pub fn assert_cache_exists(&self) {
592 assert!(self.cache().exists());
593 }
594
595 #[track_caller]
597 pub fn assert_artifacts_dir_exists(&self) {
598 assert!(self.paths().artifacts.exists());
599 }
600
601 #[track_caller]
603 pub fn assert_create_dirs_exists(&self) {
604 self.paths().create_all().unwrap_or_else(|_| panic!("Failed to create project paths"));
605 CompilerCache::<SolcSettings>::default()
606 .write(&self.paths().cache)
607 .expect("Failed to create cache");
608 self.assert_all_paths_exist();
609 }
610
611 #[track_caller]
613 pub fn assert_style_paths_exist(&self, style: PathStyle) {
614 let paths = style.paths(&self.paths().root).unwrap();
615 config_paths_exist(&paths, self.inner.project().cached);
616 }
617
618 #[track_caller]
620 pub fn copy_to(&self, target: impl AsRef<Path>) {
621 let target = target.as_ref();
622 pretty_err(target, fs::create_dir_all(target));
623 pretty_err(target, copy_dir(self.root(), target));
624 }
625
626 pub fn create_file(&self, path: impl AsRef<Path>, contents: &str) -> PathBuf {
629 let path = path.as_ref();
630 if !path.is_relative() {
631 panic!("create_file(): file path is absolute");
632 }
633 let path = self.root().join(path);
634 if let Some(parent) = path.parent() {
635 pretty_err(parent, std::fs::create_dir_all(parent));
636 }
637 let file = pretty_err(&path, File::create(&path));
638 let mut writer = BufWriter::new(file);
639 pretty_err(&path, writer.write_all(contents.as_bytes()));
640 path
641 }
642
643 pub fn insert_ds_test(&self) -> PathBuf {
645 let s = include_str!("../../../testdata/lib/ds-test/src/test.sol");
646 self.add_source("test.sol", s)
647 }
648
649 pub fn insert_console(&self) -> PathBuf {
651 let s = include_str!("../../../testdata/default/logs/console.sol");
652 self.add_source("console.sol", s)
653 }
654
655 pub fn insert_vm(&self) -> PathBuf {
657 let s = include_str!("../../../testdata/cheats/Vm.sol");
658 self.add_source("Vm.sol", s)
659 }
660
661 pub fn assert_all_paths_exist(&self) {
667 let paths = self.paths();
668 config_paths_exist(paths, self.inner.project().cached);
669 }
670
671 pub fn assert_cleaned(&self) {
673 let paths = self.paths();
674 assert!(!paths.cache.exists());
675 assert!(!paths.artifacts.exists());
676 }
677
678 #[track_caller]
680 pub fn forge_command(&self) -> TestCommand {
681 let cmd = self.forge_bin();
682 let _lock = CURRENT_DIR_LOCK.lock();
683 TestCommand {
684 project: self.clone(),
685 cmd,
686 current_dir_lock: None,
687 saved_cwd: pretty_err("<current dir>", std::env::current_dir()),
688 stdin_fun: None,
689 redact_output: true,
690 }
691 }
692
693 pub fn cast_command(&self) -> TestCommand {
695 let mut cmd = self.cast_bin();
696 cmd.current_dir(self.inner.root());
697 let _lock = CURRENT_DIR_LOCK.lock();
698 TestCommand {
699 project: self.clone(),
700 cmd,
701 current_dir_lock: None,
702 saved_cwd: pretty_err("<current dir>", std::env::current_dir()),
703 stdin_fun: None,
704 redact_output: true,
705 }
706 }
707
708 pub fn forge_bin(&self) -> Command {
710 let forge = self.exe_root.join(format!("../forge{}", env::consts::EXE_SUFFIX));
711 let forge = forge.canonicalize().unwrap_or_else(|_| forge.clone());
712 let mut cmd = Command::new(forge);
713 cmd.current_dir(self.inner.root());
714 cmd.env("NO_COLOR", "1");
716 cmd
717 }
718
719 pub fn cast_bin(&self) -> Command {
721 let cast = self.exe_root.join(format!("../cast{}", env::consts::EXE_SUFFIX));
722 let cast = cast.canonicalize().unwrap_or_else(|_| cast.clone());
723 let mut cmd = Command::new(cast);
724 cmd.env("NO_COLOR", "1");
726 cmd
727 }
728
729 pub fn config_from_output<I, A>(&self, args: I) -> Config
731 where
732 I: IntoIterator<Item = A>,
733 A: AsRef<OsStr>,
734 {
735 let mut cmd = self.forge_bin();
736 cmd.arg("config").arg("--root").arg(self.root()).args(args).arg("--json");
737 let output = cmd.output().unwrap();
738 let c = lossy_string(&output.stdout);
739 let config: Config = serde_json::from_str(c.as_ref()).unwrap();
740 config.sanitized()
741 }
742
743 pub fn wipe(&self) {
745 pretty_err(self.root(), fs::remove_dir_all(self.root()));
746 pretty_err(self.root(), fs::create_dir_all(self.root()));
747 }
748
749 pub fn wipe_contracts(&self) {
751 fn rm_create(path: &Path) {
752 pretty_err(path, fs::remove_dir_all(path));
753 pretty_err(path, fs::create_dir(path));
754 }
755 rm_create(&self.paths().sources);
756 rm_create(&self.paths().tests);
757 rm_create(&self.paths().scripts);
758 }
759}
760
761impl Drop for TestCommand {
762 fn drop(&mut self) {
763 let _lock = self.current_dir_lock.take().unwrap_or_else(|| CURRENT_DIR_LOCK.lock());
764 if self.saved_cwd.exists() {
765 let _ = std::env::set_current_dir(&self.saved_cwd);
766 }
767 }
768}
769
770fn config_paths_exist(paths: &ProjectPathsConfig, cached: bool) {
771 if cached {
772 assert!(paths.cache.exists());
773 }
774 assert!(paths.sources.exists());
775 assert!(paths.artifacts.exists());
776 paths.libraries.iter().for_each(|lib| assert!(lib.exists()));
777}
778
779#[track_caller]
780pub fn pretty_err<T, E: std::error::Error>(path: impl AsRef<Path>, res: Result<T, E>) -> T {
781 match res {
782 Ok(t) => t,
783 Err(err) => panic!("{}: {err}", path.as_ref().display()),
784 }
785}
786
787pub fn read_string(path: impl AsRef<Path>) -> String {
788 let path = path.as_ref();
789 pretty_err(path, std::fs::read_to_string(path))
790}
791
792pub struct TestCommand {
794 saved_cwd: PathBuf,
795 project: TestProject,
797 cmd: Command,
799 current_dir_lock: Option<parking_lot::MutexGuard<'static, ()>>,
801 stdin_fun: Option<Box<dyn FnOnce(ChildStdin)>>,
802 redact_output: bool,
804}
805
806impl TestCommand {
807 pub fn cmd(&mut self) -> &mut Command {
809 &mut self.cmd
810 }
811
812 pub fn set_cmd(&mut self, cmd: Command) -> &mut Self {
814 self.cmd = cmd;
815 self
816 }
817
818 pub fn forge_fuse(&mut self) -> &mut Self {
820 self.set_cmd(self.project.forge_bin())
821 }
822
823 pub fn cast_fuse(&mut self) -> &mut Self {
825 self.set_cmd(self.project.cast_bin())
826 }
827
828 pub fn set_current_dir(&mut self, p: impl AsRef<Path>) {
830 drop(self.current_dir_lock.take());
831 let lock = CURRENT_DIR_LOCK.lock();
832 self.current_dir_lock = Some(lock);
833 let p = p.as_ref();
834 pretty_err(p, std::env::set_current_dir(p));
835 }
836
837 pub fn arg<A: AsRef<OsStr>>(&mut self, arg: A) -> &mut Self {
839 self.cmd.arg(arg);
840 self
841 }
842
843 pub fn args<I, A>(&mut self, args: I) -> &mut Self
845 where
846 I: IntoIterator<Item = A>,
847 A: AsRef<OsStr>,
848 {
849 self.cmd.args(args);
850 self
851 }
852
853 pub fn stdin(&mut self, fun: impl FnOnce(ChildStdin) + 'static) -> &mut Self {
854 self.stdin_fun = Some(Box::new(fun));
855 self
856 }
857
858 pub fn root_arg(&mut self) -> &mut Self {
860 let root = self.project.root().to_path_buf();
861 self.arg("--root").arg(root)
862 }
863
864 pub fn env(&mut self, k: impl AsRef<OsStr>, v: impl AsRef<OsStr>) {
866 self.cmd.env(k, v);
867 }
868
869 pub fn envs<I, K, V>(&mut self, envs: I)
871 where
872 I: IntoIterator<Item = (K, V)>,
873 K: AsRef<OsStr>,
874 V: AsRef<OsStr>,
875 {
876 self.cmd.envs(envs);
877 }
878
879 pub fn unset_env(&mut self, k: impl AsRef<OsStr>) {
881 self.cmd.env_remove(k);
882 }
883
884 pub fn current_dir<P: AsRef<Path>>(&mut self, dir: P) -> &mut Self {
890 self.cmd.current_dir(dir);
891 self
892 }
893
894 #[track_caller]
896 pub fn config(&mut self) -> Config {
897 self.cmd.args(["config", "--json"]);
898 let output = self.assert().success().get_output().stdout_lossy();
899 self.forge_fuse();
900 serde_json::from_str(output.as_ref()).unwrap()
901 }
902
903 #[track_caller]
905 pub fn git_init(&self) {
906 let mut cmd = Command::new("git");
907 cmd.arg("init").current_dir(self.project.root());
908 let output = OutputAssert::new(cmd.output().unwrap());
909 output.success();
910 }
911
912 #[track_caller]
914 pub fn git_submodule_status(&self) -> Output {
915 let mut cmd = Command::new("git");
916 cmd.arg("submodule").arg("status").current_dir(self.project.root());
917 cmd.output().unwrap()
918 }
919
920 #[track_caller]
922 pub fn git_add(&self) {
923 let mut cmd = Command::new("git");
924 cmd.current_dir(self.project.root());
925 cmd.arg("add").arg(".");
926 let output = OutputAssert::new(cmd.output().unwrap());
927 output.success();
928 }
929
930 #[track_caller]
932 pub fn git_commit(&self, msg: &str) {
933 let mut cmd = Command::new("git");
934 cmd.current_dir(self.project.root());
935 cmd.arg("commit").arg("-m").arg(msg);
936 let output = OutputAssert::new(cmd.output().unwrap());
937 output.success();
938 }
939
940 #[track_caller]
942 pub fn assert(&mut self) -> OutputAssert {
943 let assert = OutputAssert::new(self.execute());
944 if self.redact_output {
945 return assert.with_assert(test_assert());
946 }
947 assert
948 }
949
950 #[track_caller]
952 pub fn assert_success(&mut self) -> OutputAssert {
953 self.assert().success()
954 }
955
956 #[track_caller]
958 pub fn assert_json_stdout(&mut self, expected: impl IntoData) {
959 let expected = expected.is(snapbox::data::DataFormat::Json).unordered();
960 let stdout = self.assert_success().get_output().stdout.clone();
961 let actual = stdout.into_data().is(snapbox::data::DataFormat::Json).unordered();
962 assert_data_eq!(actual, expected);
963 }
964
965 #[track_caller]
967 pub fn assert_json_stderr(&mut self, success: bool, expected: impl IntoData) {
968 let expected = expected.is(snapbox::data::DataFormat::Json).unordered();
969 let stderr = if success { self.assert_success() } else { self.assert_failure() }
970 .get_output()
971 .stderr
972 .clone();
973 let actual = stderr.into_data().is(snapbox::data::DataFormat::Json).unordered();
974 assert_data_eq!(actual, expected);
975 }
976
977 #[track_caller]
979 pub fn assert_empty_stdout(&mut self) {
980 self.assert_success().stdout_eq(Data::new());
981 }
982
983 #[track_caller]
985 pub fn assert_failure(&mut self) -> OutputAssert {
986 self.assert().failure()
987 }
988
989 #[track_caller]
991 pub fn assert_code(&mut self, expected: i32) -> OutputAssert {
992 self.assert().code(expected)
993 }
994
995 #[track_caller]
997 pub fn assert_empty_stderr(&mut self) {
998 self.assert_failure().stderr_eq(Data::new());
999 }
1000
1001 #[track_caller]
1004 pub fn assert_file(&mut self, data: impl IntoData) {
1005 self.assert_file_with(|this, path| _ = this.arg(path).assert_success(), data);
1006 }
1007
1008 #[track_caller]
1011 pub fn assert_file_with(&mut self, f: impl FnOnce(&mut Self, &Path), data: impl IntoData) {
1012 let file = tempfile::NamedTempFile::new().expect("couldn't create temporary file");
1013 f(self, file.path());
1014 assert_data_eq!(Data::read_from(file.path(), None), data);
1015 }
1016
1017 pub fn with_no_redact(&mut self) -> &mut Self {
1019 self.redact_output = false;
1020 self
1021 }
1022
1023 #[track_caller]
1025 pub fn execute(&mut self) -> Output {
1026 self.try_execute().unwrap()
1027 }
1028
1029 #[track_caller]
1030 pub fn try_execute(&mut self) -> std::io::Result<Output> {
1031 println!("executing {:?}", self.cmd);
1032 let mut child =
1033 self.cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).stdin(Stdio::piped()).spawn()?;
1034 if let Some(fun) = self.stdin_fun.take() {
1035 fun(child.stdin.take().unwrap());
1036 }
1037 child.wait_with_output()
1038 }
1039}
1040
1041fn test_assert() -> snapbox::Assert {
1042 snapbox::Assert::new()
1043 .action_env(snapbox::assert::DEFAULT_ACTION_ENV)
1044 .redact_with(test_redactions())
1045}
1046
1047fn test_redactions() -> snapbox::Redactions {
1048 static REDACTIONS: LazyLock<snapbox::Redactions> = LazyLock::new(|| {
1049 let mut r = snapbox::Redactions::new();
1050 let redactions = [
1051 ("[SOLC_VERSION]", r"Solc( version)? \d+.\d+.\d+"),
1052 ("[ELAPSED]", r"(finished )?in \d+(\.\d+)?\w?s( \(.*?s CPU time\))?"),
1053 ("[GAS]", r"[Gg]as( used)?: \d+"),
1054 ("[GAS_COST]", r"[Gg]as cost\s*\(\d+\)"),
1055 ("[GAS_LIMIT]", r"[Gg]as limit\s*\(\d+\)"),
1056 ("[AVG_GAS]", r"μ: \d+, ~: \d+"),
1057 ("[FILE]", r"-->.*\.sol"),
1058 ("[FILE]", r"Location(.|\n)*\.rs(.|\n)*Backtrace"),
1059 ("[COMPILING_FILES]", r"Compiling \d+ files?"),
1060 ("[TX_HASH]", r"Transaction hash: 0x[0-9A-Fa-f]{64}"),
1061 ("[ADDRESS]", r"Address: +0x[0-9A-Fa-f]{40}"),
1062 ("[PUBLIC_KEY]", r"Public key: +0x[0-9A-Fa-f]{128}"),
1063 ("[PRIVATE_KEY]", r"Private key: +0x[0-9A-Fa-f]{64}"),
1064 ("[UPDATING_DEPENDENCIES]", r"Updating dependencies in .*"),
1065 ("[SAVED_TRANSACTIONS]", r"Transactions saved to: .*\.json"),
1066 ("[SAVED_SENSITIVE_VALUES]", r"Sensitive values saved to: .*\.json"),
1067 ("[ESTIMATED_GAS_PRICE]", r"Estimated gas price:\s*(\d+(\.\d+)?)\s*gwei"),
1068 ("[ESTIMATED_TOTAL_GAS_USED]", r"Estimated total gas used for script: \d+"),
1069 (
1070 "[ESTIMATED_AMOUNT_REQUIRED]",
1071 r"Estimated amount required:\s*(\d+(\.\d+)?)\s*[A-Z]{3}",
1072 ),
1073 ];
1074 for (placeholder, re) in redactions {
1075 r.insert(placeholder, Regex::new(re).expect(re)).expect(re);
1076 }
1077 r
1078 });
1079 REDACTIONS.clone()
1080}
1081
1082pub trait OutputExt {
1084 fn stdout_lossy(&self) -> String;
1086}
1087
1088impl OutputExt for Output {
1089 fn stdout_lossy(&self) -> String {
1090 lossy_string(&self.stdout)
1091 }
1092}
1093
1094pub fn lossy_string(bytes: &[u8]) -> String {
1095 String::from_utf8_lossy(bytes).replace("\r\n", "\n")
1096}