Skip to main content

foundry_test_utils/
prj.rs

1use crate::{init_tracing, rpc::rpc_endpoints};
2use eyre::{Result, WrapErr};
3use foundry_compilers::{
4    ArtifactOutput, ConfigurableArtifacts, PathStyle, ProjectPathsConfig, artifacts::Contract,
5    cache::CompilerCache, compilers::multi::MultiCompiler, project_util::TempProject,
6    solc::SolcSettings,
7};
8use foundry_config::Config;
9use parking_lot::Mutex;
10use regex::Regex;
11use snapbox::{Data, IntoData, assert_data_eq, cmd::OutputAssert};
12use std::{
13    env,
14    ffi::OsStr,
15    fs::{self, File},
16    io::{BufWriter, Write},
17    path::{Path, PathBuf},
18    process::{Command, Output, Stdio},
19    sync::{
20        Arc, LazyLock,
21        atomic::{AtomicUsize, Ordering},
22    },
23};
24
25use crate::util::{SOLC_VERSION, copy_dir_filtered, pretty_err};
26
27static CURRENT_DIR_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
28
29/// Global test identifier.
30static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
31
32/// Clones a remote repository into the specified directory. Panics if the command fails.
33pub fn clone_remote(repo_url: &str, target_dir: &str, recursive: bool) {
34    let mut cmd = Command::new("git");
35    cmd.args(["clone"]);
36    if recursive {
37        cmd.args(["--recursive", "--shallow-submodules"]);
38    } else {
39        cmd.args(["--depth=1", "--no-checkout", "--filter=blob:none", "--no-recurse-submodules"]);
40    }
41    cmd.args([repo_url, target_dir]);
42    test_debug!("{cmd:?}");
43    let status = cmd.status().unwrap();
44    assert!(status.success(), "git clone failed: {status}")
45}
46
47/// Setup an empty test project and return a command pointing to the forge
48/// executable whose CWD is set to the project's root.
49///
50/// The name given will be used to create the directory. Generally, it should
51/// correspond to the test name.
52#[track_caller]
53pub fn setup_forge(name: &str, style: PathStyle) -> (TestProject, TestCommand) {
54    setup_forge_project(TestProject::new(name, style))
55}
56
57pub fn setup_forge_project(test: TestProject) -> (TestProject, TestCommand) {
58    let cmd = test.forge_command();
59    (test, cmd)
60}
61
62/// How to initialize a remote git project
63#[derive(Clone, Debug)]
64pub struct RemoteProject {
65    id: String,
66    run_build: bool,
67    run_commands: Vec<Vec<String>>,
68    path_style: PathStyle,
69}
70
71impl RemoteProject {
72    pub fn new(id: impl Into<String>) -> Self {
73        Self {
74            id: id.into(),
75            run_build: true,
76            run_commands: vec![],
77            path_style: PathStyle::Dapptools,
78        }
79    }
80
81    /// Whether to run `forge build`
82    pub const fn set_build(mut self, run_build: bool) -> Self {
83        self.run_build = run_build;
84        self
85    }
86
87    /// Configures the project's pathstyle
88    pub const fn path_style(mut self, path_style: PathStyle) -> Self {
89        self.path_style = path_style;
90        self
91    }
92
93    /// Add another command to run after cloning
94    pub fn cmd(mut self, cmd: impl IntoIterator<Item = impl Into<String>>) -> Self {
95        self.run_commands.push(cmd.into_iter().map(Into::into).collect());
96        self
97    }
98}
99
100impl<T: Into<String>> From<T> for RemoteProject {
101    fn from(id: T) -> Self {
102        Self::new(id)
103    }
104}
105
106/// Setups a new local forge project by cloning and initializing the `RemoteProject`
107///
108/// This will
109///   1. clone the prj, like "transmissions1/solmate"
110///   2. run `forge build`, if configured
111///   3. run additional commands
112///
113/// # Panics
114///
115/// If anything goes wrong during, checkout, build, or other commands are unsuccessful
116pub fn setup_forge_remote(prj: impl Into<RemoteProject>) -> (TestProject, TestCommand) {
117    try_setup_forge_remote(prj).unwrap()
118}
119
120/// Same as `setup_forge_remote` but not panicking
121pub fn try_setup_forge_remote(
122    config: impl Into<RemoteProject>,
123) -> Result<(TestProject, TestCommand)> {
124    let config = config.into();
125    let mut tmp = TempProject::checkout(&config.id).wrap_err("failed to checkout project")?;
126    tmp.project_mut().paths = config.path_style.paths(tmp.root())?;
127
128    let prj = TestProject::with_project(tmp);
129    if config.run_build {
130        let mut cmd = prj.forge_command();
131        cmd.arg("build").assert_success();
132    }
133    for addon in config.run_commands {
134        debug_assert!(!addon.is_empty());
135        let mut cmd = Command::new(&addon[0]);
136        if addon.len() > 1 {
137            cmd.args(&addon[1..]);
138        }
139        let status = cmd
140            .current_dir(prj.root())
141            .stdout(Stdio::null())
142            .stderr(Stdio::null())
143            .status()
144            .wrap_err_with(|| format!("Failed to execute {addon:?}"))?;
145        eyre::ensure!(status.success(), "Failed to execute command {:?}", addon);
146    }
147
148    let cmd = prj.forge_command();
149    Ok((prj, cmd))
150}
151
152pub fn setup_cast(name: &str, style: PathStyle) -> (TestProject, TestCommand) {
153    setup_cast_project(TestProject::new(name, style))
154}
155
156pub fn setup_cast_project(test: TestProject) -> (TestProject, TestCommand) {
157    let cmd = test.cast_command();
158    (test, cmd)
159}
160
161/// `TestProject` represents a temporary project to run tests against.
162///
163/// Test projects are created from a global atomic counter to avoid duplicates.
164#[derive(Clone, Debug)]
165pub struct TestProject<
166    T: ArtifactOutput<CompilerContract = Contract> + Default = ConfigurableArtifacts,
167> {
168    /// The directory in which this test executable is running.
169    exe_root: PathBuf,
170    /// The project in which the test should run.
171    pub(crate) inner: Arc<TempProject<MultiCompiler, T>>,
172}
173
174impl TestProject {
175    /// Create a new test project with the given name. The name
176    /// does not need to be distinct for each invocation, but should correspond
177    /// to a logical grouping of tests.
178    pub fn new(name: &str, style: PathStyle) -> Self {
179        let id = NEXT_ID.fetch_add(1, Ordering::SeqCst);
180        let project = pretty_err(name, TempProject::with_style(&format!("{name}-{id}"), style));
181        Self::with_project(project)
182    }
183
184    pub fn with_project(project: TempProject) -> Self {
185        init_tracing();
186        let this = env::current_exe().unwrap();
187        let exe_root = canonicalize(this.parent().expect("executable's directory"));
188        Self { exe_root, inner: Arc::new(project) }
189    }
190
191    /// Returns the root path of the project's workspace.
192    pub fn root(&self) -> &Path {
193        self.inner.root()
194    }
195
196    /// Returns the paths config.
197    pub fn paths(&self) -> &ProjectPathsConfig {
198        self.inner.paths()
199    }
200
201    /// Returns the path to the project's `foundry.toml` file.
202    pub fn config(&self) -> PathBuf {
203        self.root().join(Config::FILE_NAME)
204    }
205
206    /// Returns the path to the project's cache file.
207    pub fn cache(&self) -> &PathBuf {
208        &self.paths().cache
209    }
210
211    /// Returns the path to the project's artifacts directory.
212    pub fn artifacts(&self) -> &PathBuf {
213        &self.paths().artifacts
214    }
215
216    /// Removes the project's cache and artifacts directory.
217    pub fn clear(&self) {
218        self.clear_cache();
219        self.clear_artifacts();
220    }
221
222    /// Removes this project's cache file.
223    pub fn clear_cache(&self) {
224        let _ = fs::remove_file(self.cache());
225    }
226
227    /// Removes this project's artifacts directory.
228    pub fn clear_artifacts(&self) {
229        let _ = fs::remove_dir_all(self.artifacts());
230    }
231
232    /// Removes the entire cache directory (including fuzz, invariant, and test-failures caches).
233    pub fn clear_cache_dir(&self) {
234        let _ = fs::remove_dir_all(self.root().join("cache"));
235    }
236
237    /// Updates the project's config with the given function.
238    pub fn update_config(&self, f: impl FnOnce(&mut Config)) {
239        self._update_config(Box::new(f));
240    }
241
242    fn _update_config(&self, f: Box<dyn FnOnce(&mut Config) + '_>) {
243        let mut config = self
244            .config()
245            .exists()
246            .then_some(())
247            .and_then(|()| Config::load_with_root(self.root()).ok())
248            .unwrap_or_default();
249        config.remappings.clear();
250        f(&mut config);
251        self.write_config(config);
252    }
253
254    /// Writes the given config as toml to `foundry.toml`.
255    #[doc(hidden)] // Prefer `update_config`.
256    pub fn write_config(&self, config: Config) {
257        let file = self.config();
258        pretty_err(&file, fs::write(&file, config.to_string_pretty().unwrap()));
259    }
260
261    /// Writes [`rpc_endpoints`] to the project's config.
262    pub fn add_rpc_endpoints(&self) {
263        self.update_config(|config| {
264            config.rpc_endpoints = rpc_endpoints();
265        });
266    }
267
268    /// Adds a source file to the project.
269    pub fn add_source(&self, name: &str, contents: &str) -> PathBuf {
270        self.inner.add_source(name, Self::add_source_prelude(contents)).unwrap()
271    }
272
273    /// Adds a source file to the project. Prefer using `add_source` instead.
274    pub fn add_raw_source(&self, name: &str, contents: &str) -> PathBuf {
275        self.inner.add_source(name, contents).unwrap()
276    }
277
278    /// Adds a script file to the project.
279    pub fn add_script(&self, name: &str, contents: &str) -> PathBuf {
280        self.inner.add_script(name, Self::add_source_prelude(contents)).unwrap()
281    }
282
283    /// Adds a script file to the project. Prefer using `add_script` instead.
284    pub fn add_raw_script(&self, name: &str, contents: &str) -> PathBuf {
285        self.inner.add_script(name, contents).unwrap()
286    }
287
288    /// Adds a test file to the project.
289    pub fn add_test(&self, name: &str, contents: &str) -> PathBuf {
290        self.inner.add_test(name, Self::add_source_prelude(contents)).unwrap()
291    }
292
293    /// Adds a test file to the project. Prefer using `add_test` instead.
294    pub fn add_raw_test(&self, name: &str, contents: &str) -> PathBuf {
295        self.inner.add_test(name, contents).unwrap()
296    }
297
298    /// Adds a library file to the project.
299    pub fn add_lib(&self, name: &str, contents: &str) -> PathBuf {
300        self.inner.add_lib(name, Self::add_source_prelude(contents)).unwrap()
301    }
302
303    /// Adds a library file to the project. Prefer using `add_lib` instead.
304    pub fn add_raw_lib(&self, name: &str, contents: &str) -> PathBuf {
305        self.inner.add_lib(name, contents).unwrap()
306    }
307
308    fn add_source_prelude(s: &str) -> String {
309        let mut s = s.to_string();
310        if !s.contains("pragma solidity") {
311            s = format!("pragma solidity ={SOLC_VERSION};\n{s}");
312        }
313        if !s.contains("// SPDX") {
314            s = format!("// SPDX-License-Identifier: MIT OR Apache-2.0\n{s}");
315        }
316        s
317    }
318
319    /// Asserts that the `<root>/foundry.toml` file exists.
320    #[track_caller]
321    pub fn assert_config_exists(&self) {
322        assert!(self.config().exists());
323    }
324
325    /// Asserts that the `<root>/cache/sol-files-cache.json` file exists.
326    #[track_caller]
327    pub fn assert_cache_exists(&self) {
328        assert!(self.cache().exists());
329    }
330
331    /// Asserts that the `<root>/out` file exists.
332    #[track_caller]
333    pub fn assert_artifacts_dir_exists(&self) {
334        assert!(self.paths().artifacts.exists());
335    }
336
337    /// Creates all project dirs and ensure they were created
338    #[track_caller]
339    pub fn assert_create_dirs_exists(&self) {
340        self.paths().create_all().unwrap_or_else(|_| panic!("Failed to create project paths"));
341        CompilerCache::<SolcSettings>::default()
342            .write(&self.paths().cache)
343            .expect("Failed to create cache");
344        self.assert_all_paths_exist();
345    }
346
347    /// Ensures that the given layout exists
348    #[track_caller]
349    pub fn assert_style_paths_exist(&self, style: PathStyle) {
350        let paths = style.paths(&self.paths().root).unwrap();
351        config_paths_exist(&paths, self.inner.project().cached);
352    }
353
354    /// Copies the project's root directory to the given target, excluding build artifacts.
355    #[track_caller]
356    pub fn copy_to(&self, target: impl AsRef<Path>) {
357        let target = target.as_ref();
358        pretty_err(target, fs::create_dir_all(target));
359        pretty_err(target, copy_dir_filtered(self.root(), target));
360    }
361
362    /// Creates a file with contents `contents` in the test project's directory. The
363    /// file will be deleted when the project is dropped.
364    pub fn create_file(&self, path: impl AsRef<Path>, contents: &str) -> PathBuf {
365        let path = path.as_ref();
366        assert!(path.is_relative(), "create_file(): file path is absolute");
367        let path = self.root().join(path);
368        if let Some(parent) = path.parent() {
369            pretty_err(parent, std::fs::create_dir_all(parent));
370        }
371        let file = pretty_err(&path, File::create(&path));
372        let mut writer = BufWriter::new(file);
373        pretty_err(&path, writer.write_all(contents.as_bytes()));
374        path
375    }
376
377    /// Adds DSTest as a source under "test.sol"
378    pub fn insert_ds_test(&self) -> PathBuf {
379        self.add_source("test.sol", include_str!("../../../testdata/utils/DSTest.sol"))
380    }
381
382    /// Adds custom test utils under the "test/utils" directory.
383    pub fn insert_utils(&self) {
384        self.add_test("utils/DSTest.sol", include_str!("../../../testdata/utils/DSTest.sol"));
385        self.add_test("utils/Test.sol", include_str!("../../../testdata/utils/Test.sol"));
386        self.add_test("utils/Vm.sol", include_str!("../../../testdata/utils/Vm.sol"));
387        self.add_test("utils/console.sol", include_str!("../../../testdata/utils/console.sol"));
388    }
389
390    /// Adds `console.sol` as a source under "console.sol"
391    pub fn insert_console(&self) -> PathBuf {
392        let s = include_str!("../../../testdata/utils/console.sol");
393        self.add_source("console.sol", s)
394    }
395
396    /// Adds `Vm.sol` as a source under "Vm.sol"
397    pub fn insert_vm(&self) -> PathBuf {
398        let s = include_str!("../../../testdata/utils/Vm.sol");
399        self.add_source("Vm.sol", s)
400    }
401
402    /// Asserts all project paths exist. These are:
403    /// - sources
404    /// - artifacts
405    /// - libs
406    /// - cache
407    pub fn assert_all_paths_exist(&self) {
408        let paths = self.paths();
409        config_paths_exist(paths, self.inner.project().cached);
410    }
411
412    /// Asserts that the artifacts dir and cache don't exist
413    pub fn assert_cleaned(&self) {
414        let paths = self.paths();
415        assert!(!paths.cache.exists());
416        assert!(!paths.artifacts.exists());
417    }
418
419    /// Creates a new command that is set to use the forge executable for this project
420    #[track_caller]
421    pub fn forge_command(&self) -> TestCommand {
422        let cmd = self.forge_bin();
423        let _lock = CURRENT_DIR_LOCK.lock();
424        TestCommand {
425            project: self.clone(),
426            cmd,
427            current_dir_lock: None,
428            saved_cwd: pretty_err("<current dir>", std::env::current_dir()),
429            stdin: None,
430            redact_output: true,
431        }
432    }
433
434    /// Creates a new command that is set to use the cast executable for this project
435    pub fn cast_command(&self) -> TestCommand {
436        let mut cmd = self.cast_bin();
437        cmd.current_dir(self.inner.root());
438        let _lock = CURRENT_DIR_LOCK.lock();
439        TestCommand {
440            project: self.clone(),
441            cmd,
442            current_dir_lock: None,
443            saved_cwd: pretty_err("<current dir>", std::env::current_dir()),
444            stdin: None,
445            redact_output: true,
446        }
447    }
448
449    /// Returns the path to the forge executable.
450    pub fn forge_bin(&self) -> Command {
451        let mut cmd = Command::new(self.foundry_bin_path("forge"));
452        cmd.current_dir(self.inner.root());
453        // Disable color output for comparisons; can be overridden with `--color always`.
454        cmd.env("NO_COLOR", "1");
455        cmd
456    }
457
458    /// Returns the path to a sibling Foundry executable in the current test target directory.
459    pub fn foundry_bin_path(&self, name: &str) -> PathBuf {
460        canonicalize(self.exe_root.join(format!("../{name}{}", env::consts::EXE_SUFFIX)))
461    }
462
463    /// Returns the path to a sibling Foundry executable, building it when cargo did not.
464    pub fn ensure_foundry_bin(&self, name: &str) -> PathBuf {
465        let bin = self.foundry_bin_path(name);
466        if bin.exists() {
467            return bin;
468        }
469
470        let package = format!("{name}@{}", env!("CARGO_PKG_VERSION"));
471        let (target_dir, profile) = cargo_build_target_dir_and_profile(&self.exe_root);
472        let mut cmd = Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into()));
473        cmd.args(["build", "-p", &package, "--bin", name, "--manifest-path"])
474            .arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../Cargo.toml"))
475            .arg("--target-dir")
476            .arg(target_dir);
477        if let Some(profile) = profile {
478            cmd.arg("--profile").arg(profile);
479        }
480
481        let output = cmd.output().expect("build Foundry sibling binary");
482        assert!(
483            output.status.success(),
484            "failed to build {name} for CLI test\nstdout:\n{}\nstderr:\n{}",
485            output.stdout_lossy(),
486            output.stderr_lossy(),
487        );
488
489        bin
490    }
491
492    /// Returns the path to the cast executable.
493    pub fn cast_bin(&self) -> Command {
494        let mut cmd = Command::new(self.foundry_bin_path("cast"));
495        // disable color output for comparisons
496        cmd.env("NO_COLOR", "1");
497        cmd
498    }
499
500    /// Returns the `Config` as spit out by `forge config`
501    pub fn config_from_output<I, A>(&self, args: I) -> Config
502    where
503        I: IntoIterator<Item = A>,
504        A: AsRef<OsStr>,
505    {
506        let mut cmd = self.forge_bin();
507        cmd.arg("config").arg("--root").arg(self.root()).args(args).arg("--json");
508        let output = cmd.output().unwrap();
509        let c = lossy_string(&output.stdout);
510        let config: Config = serde_json::from_str(c.as_ref()).unwrap();
511        config.sanitized()
512    }
513
514    /// Removes all files and dirs inside the project's root dir
515    pub fn wipe(&self) {
516        pretty_err(self.root(), fs::remove_dir_all(self.root()));
517        pretty_err(self.root(), fs::create_dir_all(self.root()));
518    }
519
520    /// Removes all contract files from `src`, `test`, `script`
521    pub fn wipe_contracts(&self) {
522        fn rm_create(path: &Path) {
523            pretty_err(path, fs::remove_dir_all(path));
524            pretty_err(path, fs::create_dir(path));
525        }
526        rm_create(&self.paths().sources);
527        rm_create(&self.paths().tests);
528        rm_create(&self.paths().scripts);
529    }
530
531    /// Initializes the default contracts (Counter.sol, Counter.t.sol, Counter.s.sol).
532    ///
533    /// This is useful for tests that need the default contracts created by `forge init`.
534    /// Most tests should not need this method, as the default behavior is to create an empty
535    /// project.
536    pub fn initialize_default_contracts(&self) {
537        self.add_raw_source(
538            "Counter.sol",
539            include_str!("../../forge/assets/solidity/CounterTemplate.sol"),
540        );
541        self.add_raw_test(
542            "Counter.t.sol",
543            include_str!("../../forge/assets/solidity/CounterTemplate.t.sol"),
544        );
545        self.add_raw_script(
546            "Counter.s.sol",
547            include_str!("../../forge/assets/solidity/CounterTemplate.s.sol"),
548        );
549    }
550}
551
552fn config_paths_exist(paths: &ProjectPathsConfig, cached: bool) {
553    if cached {
554        assert!(paths.cache.exists());
555    }
556    assert!(paths.sources.exists());
557    assert!(paths.artifacts.exists());
558    paths.libraries.iter().for_each(|lib| assert!(lib.exists()));
559}
560
561/// A simple wrapper around a Command with some conveniences.
562pub struct TestCommand {
563    saved_cwd: PathBuf,
564    /// The project used to launch this command.
565    project: TestProject,
566    /// The actual command we use to control the process.
567    cmd: Command,
568    // initial: Command,
569    current_dir_lock: Option<parking_lot::MutexGuard<'static, ()>>,
570    stdin: Option<Vec<u8>>,
571    /// If true, command output is redacted.
572    redact_output: bool,
573}
574
575impl TestCommand {
576    /// Returns a mutable reference to the underlying command.
577    pub const fn cmd(&mut self) -> &mut Command {
578        &mut self.cmd
579    }
580
581    /// Replaces the underlying command.
582    pub fn set_cmd(&mut self, cmd: Command) -> &mut Self {
583        self.cmd = cmd;
584        self
585    }
586
587    /// Resets the command to the default `forge` command.
588    pub fn forge_fuse(&mut self) -> &mut Self {
589        self.set_cmd(self.project.forge_bin())
590    }
591
592    /// Resets the command to the default `cast` command.
593    pub fn cast_fuse(&mut self) -> &mut Self {
594        self.set_cmd(self.project.cast_bin())
595    }
596
597    /// Sets the current working directory.
598    pub fn set_current_dir(&mut self, p: impl AsRef<Path>) {
599        drop(self.current_dir_lock.take());
600        let lock = CURRENT_DIR_LOCK.lock();
601        self.current_dir_lock = Some(lock);
602        let p = p.as_ref();
603        pretty_err(p, std::env::set_current_dir(p));
604    }
605
606    /// Add an argument to pass to the command.
607    pub fn arg<A: AsRef<OsStr>>(&mut self, arg: A) -> &mut Self {
608        self.cmd.arg(arg);
609        self
610    }
611
612    /// Add any number of arguments to the command.
613    pub fn args<I, A>(&mut self, args: I) -> &mut Self
614    where
615        I: IntoIterator<Item = A>,
616        A: AsRef<OsStr>,
617    {
618        self.cmd.args(args);
619        self
620    }
621
622    /// Set the stdin bytes for the next command.
623    pub fn stdin(&mut self, stdin: impl Into<Vec<u8>>) -> &mut Self {
624        self.stdin = Some(stdin.into());
625        self
626    }
627
628    /// Convenience function to add `--root project.root()` argument
629    pub fn root_arg(&mut self) -> &mut Self {
630        let root = self.project.root().to_path_buf();
631        self.arg("--root").arg(root)
632    }
633
634    /// Set the environment variable `k` to value `v` for the command.
635    pub fn env(&mut self, k: impl AsRef<OsStr>, v: impl AsRef<OsStr>) {
636        self.cmd.env(k, v);
637    }
638
639    /// Set the environment variable `k` to value `v` for the command.
640    pub fn envs<I, K, V>(&mut self, envs: I)
641    where
642        I: IntoIterator<Item = (K, V)>,
643        K: AsRef<OsStr>,
644        V: AsRef<OsStr>,
645    {
646        self.cmd.envs(envs);
647    }
648
649    /// Unsets the environment variable `k` for the command.
650    pub fn unset_env(&mut self, k: impl AsRef<OsStr>) {
651        self.cmd.env_remove(k);
652    }
653
654    /// Set the working directory for this command.
655    ///
656    /// Note that this does not need to be called normally, since the creation
657    /// of this TestCommand causes its working directory to be set to the
658    /// test's directory automatically.
659    pub fn current_dir<P: AsRef<Path>>(&mut self, dir: P) -> &mut Self {
660        self.cmd.current_dir(dir);
661        self
662    }
663
664    /// Returns the `Config` as spit out by `forge config`
665    #[track_caller]
666    pub fn config(&mut self) -> Config {
667        self.cmd.args(["config", "--json"]);
668        let output = self.assert().success().get_output().stdout_lossy();
669        self.forge_fuse();
670        serde_json::from_str(output.as_ref()).unwrap()
671    }
672
673    /// Runs `git init` inside the project's dir
674    #[track_caller]
675    pub fn git_init(&self) {
676        let mut cmd = Command::new("git");
677        cmd.arg("init").current_dir(self.project.root());
678        let output = OutputAssert::new(cmd.output().unwrap());
679        output.success();
680    }
681
682    /// Runs `git submodule status` inside the project's dir
683    #[track_caller]
684    pub fn git_submodule_status(&self) -> Output {
685        let mut cmd = Command::new("git");
686        cmd.arg("submodule").arg("status").current_dir(self.project.root());
687        cmd.output().unwrap()
688    }
689
690    /// Runs `git add .` inside the project's dir
691    #[track_caller]
692    pub fn git_add(&self) {
693        let mut cmd = Command::new("git");
694        cmd.current_dir(self.project.root());
695        cmd.arg("add").arg(".");
696        let output = OutputAssert::new(cmd.output().unwrap());
697        output.success();
698    }
699
700    /// Runs `git commit .` inside the project's dir
701    #[track_caller]
702    pub fn git_commit(&self, msg: &str) {
703        let mut cmd = Command::new("git");
704        cmd.current_dir(self.project.root());
705        cmd.arg("commit").arg("-m").arg(msg);
706        let output = OutputAssert::new(cmd.output().unwrap());
707        output.success();
708    }
709
710    /// Runs the command, returning a [`snapbox`] object to assert the command output.
711    #[track_caller]
712    pub fn assert_with(&mut self, f: &[RegexRedaction]) -> OutputAssert {
713        let assert = OutputAssert::new(self.execute());
714        if self.redact_output {
715            let mut redactions = test_redactions();
716            insert_redactions(f, &mut redactions);
717            return assert.with_assert(
718                snapbox::Assert::new()
719                    .action_env(snapbox::assert::DEFAULT_ACTION_ENV)
720                    .redact_with(redactions),
721            );
722        }
723        assert
724    }
725
726    /// Runs the command, returning a [`snapbox`] object to assert the command output.
727    #[track_caller]
728    pub fn assert(&mut self) -> OutputAssert {
729        self.assert_with(&[])
730    }
731
732    /// Runs the command and asserts that it resulted in success.
733    #[track_caller]
734    pub fn assert_success(&mut self) -> OutputAssert {
735        self.assert().success()
736    }
737
738    /// Runs the command and asserts that it resulted in success, with expected JSON data.
739    #[track_caller]
740    pub fn assert_json_stdout(&mut self, expected: impl IntoData) {
741        let expected = expected.is(snapbox::data::DataFormat::Json).unordered();
742        let stdout = self.assert_success().get_output().stdout.clone();
743        let actual = stdout.into_data().is(snapbox::data::DataFormat::Json).unordered();
744        assert_data_eq!(actual, expected);
745    }
746
747    /// Runs the command and asserts that it resulted in the expected outcome and JSON data.
748    #[track_caller]
749    pub fn assert_json_stderr(&mut self, success: bool, expected: impl IntoData) {
750        let expected = expected.is(snapbox::data::DataFormat::Json).unordered();
751        let stderr = if success { self.assert_success() } else { self.assert_failure() }
752            .get_output()
753            .stderr
754            .clone();
755        let actual = stderr.into_data().is(snapbox::data::DataFormat::Json).unordered();
756        assert_data_eq!(actual, expected);
757    }
758
759    /// Runs the command and asserts that it **succeeded** nothing was printed to stdout.
760    #[track_caller]
761    pub fn assert_empty_stdout(&mut self) {
762        self.assert_success().stdout_eq(Data::new());
763    }
764
765    /// Runs the command and asserts that it failed.
766    #[track_caller]
767    pub fn assert_failure(&mut self) -> OutputAssert {
768        self.assert().failure()
769    }
770
771    /// Runs the command and asserts that the exit code is `expected`.
772    #[track_caller]
773    pub fn assert_code(&mut self, expected: i32) -> OutputAssert {
774        self.assert().code(expected)
775    }
776
777    /// Runs the command and asserts that it **failed** nothing was printed to stderr.
778    #[track_caller]
779    pub fn assert_empty_stderr(&mut self) {
780        self.assert_failure().stderr_eq(Data::new());
781    }
782
783    /// Runs the command with a temporary file argument and asserts that the contents of the file
784    /// match the given data.
785    #[track_caller]
786    pub fn assert_file(&mut self, data: impl IntoData) {
787        self.assert_file_with(|this, path| _ = this.arg(path).assert_success(), data);
788    }
789
790    /// Creates a temporary file, passes it to `f`, then asserts that the contents of the file match
791    /// the given data.
792    #[track_caller]
793    pub fn assert_file_with(&mut self, f: impl FnOnce(&mut Self, &Path), data: impl IntoData) {
794        let file = tempfile::NamedTempFile::new().expect("couldn't create temporary file");
795        f(self, file.path());
796        assert_data_eq!(Data::read_from(file.path(), None), data);
797    }
798
799    /// Does not apply [`snapbox`] redactions to the command output.
800    pub const fn with_no_redact(&mut self) -> &mut Self {
801        self.redact_output = false;
802        self
803    }
804
805    /// Executes command, applies stdin function and returns output
806    #[track_caller]
807    pub fn execute(&mut self) -> Output {
808        self.try_execute().unwrap()
809    }
810
811    #[track_caller]
812    pub fn try_execute(&mut self) -> std::io::Result<Output> {
813        test_debug!("executing {:?}", self.cmd);
814        let mut child =
815            self.cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).stdin(Stdio::piped()).spawn()?;
816        if let Some(bytes) = self.stdin.take() {
817            child.stdin.take().unwrap().write_all(&bytes)?;
818        }
819        let output = child.wait_with_output()?;
820        test_debug!("exited with {}", output.status);
821        test_trace!("\n--- stdout ---\n{}\n--- /stdout ---", output.stdout_lossy());
822        test_trace!("\n--- stderr ---\n{}\n--- /stderr ---", output.stderr_lossy());
823        Ok(output)
824    }
825}
826
827impl Drop for TestCommand {
828    fn drop(&mut self) {
829        let _lock = self.current_dir_lock.take().unwrap_or_else(|| CURRENT_DIR_LOCK.lock());
830        if self.saved_cwd.exists() {
831            let _ = std::env::set_current_dir(&self.saved_cwd);
832        }
833    }
834}
835
836fn test_redactions() -> snapbox::Redactions {
837    static REDACTIONS: LazyLock<snapbox::Redactions> = LazyLock::new(|| {
838        make_redactions(&[
839            ("[SOLC_VERSION]", r"Solc( version)? \d+.\d+.\d+"),
840            ("[ELAPSED]", r"(finished )?in (\d+m )?\d+(\.\d+)?\w?s( \(.*?s CPU time\))?"),
841            ("[GAS]", r"[Gg]as( used)?: \d+"),
842            ("[GAS_COST]", r"[Gg]as cost\s*\(\d+\)"),
843            ("[GAS_LIMIT]", r"[Gg]as limit\s*\(\d+\)"),
844            ("[AVG_GAS]", r"μ: \d+, ~: \d+"),
845            ("[FILE]", r"(-->|╭▸).*\.sol"),
846            ("[FILE]", r"Location(.|\n)*\.rs(.|\n)*Backtrace"),
847            ("[COMPILING_FILES]", r"Compiling \d+ files?"),
848            ("[TX_HASH]", r"Transaction hash: 0x[0-9A-Fa-f]{64}"),
849            ("[ADDRESS]", r"Address: +0x[0-9A-Fa-f]{40}"),
850            ("[PUBLIC_KEY]", r"Public key: +0x[0-9A-Fa-f]{128}"),
851            ("[PRIVATE_KEY]", r"Private key: +0x[0-9A-Fa-f]{64}"),
852            ("[UPDATING_DEPENDENCIES]", r"Updating dependencies in .*"),
853            ("[SAVED_TRANSACTIONS]", r"Transactions saved to: .*\.json"),
854            ("[SAVED_SENSITIVE_VALUES]", r"Sensitive values saved to: .*\.json"),
855            ("[ESTIMATED_GAS_PRICE]", r"Estimated gas price:\s*(\d+(\.\d+)?)\s*gwei"),
856            ("[ESTIMATED_TOTAL_GAS_USED]", r"Estimated total gas used for script: \d+"),
857            (
858                "[ESTIMATED_AMOUNT_REQUIRED]",
859                r"Estimated amount required:\s*(\d+(\.\d+)?)\s*[A-Z]{3}",
860            ),
861            ("[SEED]", r"Fuzz seed: 0x[0-9A-Fa-f]+"),
862        ])
863    });
864    REDACTIONS.clone()
865}
866
867/// A tuple of a placeholder and a regex replacement string.
868pub type RegexRedaction = (&'static str, &'static str);
869
870/// Creates a [`snapbox`] redactions object from a list of regex redactions.
871fn make_redactions(redactions: &[RegexRedaction]) -> snapbox::Redactions {
872    let mut r = snapbox::Redactions::new();
873    insert_redactions(redactions, &mut r);
874    r
875}
876
877fn insert_redactions(redactions: &[RegexRedaction], r: &mut snapbox::Redactions) {
878    for &(placeholder, re) in redactions {
879        r.insert(placeholder, Regex::new(re).expect(re)).expect(re);
880    }
881}
882
883/// Extension trait for [`Output`].
884pub trait OutputExt {
885    /// Returns the stdout as lossy string
886    fn stdout_lossy(&self) -> String;
887
888    /// Returns the stderr as lossy string
889    fn stderr_lossy(&self) -> String;
890}
891
892impl OutputExt for Output {
893    fn stdout_lossy(&self) -> String {
894        lossy_string(&self.stdout)
895    }
896
897    fn stderr_lossy(&self) -> String {
898        lossy_string(&self.stderr)
899    }
900}
901
902pub fn lossy_string(bytes: &[u8]) -> String {
903    String::from_utf8_lossy(bytes).replace("\r\n", "\n")
904}
905
906fn canonicalize(path: impl AsRef<Path>) -> PathBuf {
907    foundry_common::fs::canonicalize_path(path.as_ref())
908        .unwrap_or_else(|_| path.as_ref().to_path_buf())
909}
910
911fn cargo_build_target_dir_and_profile(exe_root: &Path) -> (&Path, Option<&str>) {
912    let profile_dir = exe_root.parent().expect("test executable profile directory");
913    let target_dir = profile_dir.parent().expect("Cargo target directory");
914    let profile = match profile_dir.file_name().and_then(OsStr::to_str) {
915        // Cargo's dev profile writes to `debug`, so the default `cargo build` profile is correct.
916        Some("debug") => None,
917        Some(profile) => Some(profile),
918        None => panic!("test executable profile directory must be UTF-8"),
919    };
920    (target_dir, profile)
921}