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