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