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 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 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.forge_path());
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    pub(crate) fn forge_path(&self) -> PathBuf {
459        canonicalize(self.exe_root.join(format!("../forge{}", env::consts::EXE_SUFFIX)))
460    }
461
462    /// Returns the path to the cast executable.
463    pub fn cast_bin(&self) -> Command {
464        let cast = canonicalize(self.exe_root.join(format!("../cast{}", env::consts::EXE_SUFFIX)));
465        let mut cmd = Command::new(cast);
466        // disable color output for comparisons
467        cmd.env("NO_COLOR", "1");
468        cmd
469    }
470
471    /// Returns the `Config` as spit out by `forge config`
472    pub fn config_from_output<I, A>(&self, args: I) -> Config
473    where
474        I: IntoIterator<Item = A>,
475        A: AsRef<OsStr>,
476    {
477        let mut cmd = self.forge_bin();
478        cmd.arg("config").arg("--root").arg(self.root()).args(args).arg("--json");
479        let output = cmd.output().unwrap();
480        let c = lossy_string(&output.stdout);
481        let config: Config = serde_json::from_str(c.as_ref()).unwrap();
482        config.sanitized()
483    }
484
485    /// Removes all files and dirs inside the project's root dir
486    pub fn wipe(&self) {
487        pretty_err(self.root(), fs::remove_dir_all(self.root()));
488        pretty_err(self.root(), fs::create_dir_all(self.root()));
489    }
490
491    /// Removes all contract files from `src`, `test`, `script`
492    pub fn wipe_contracts(&self) {
493        fn rm_create(path: &Path) {
494            pretty_err(path, fs::remove_dir_all(path));
495            pretty_err(path, fs::create_dir(path));
496        }
497        rm_create(&self.paths().sources);
498        rm_create(&self.paths().tests);
499        rm_create(&self.paths().scripts);
500    }
501
502    /// Initializes the default contracts (Counter.sol, Counter.t.sol, Counter.s.sol).
503    ///
504    /// This is useful for tests that need the default contracts created by `forge init`.
505    /// Most tests should not need this method, as the default behavior is to create an empty
506    /// project.
507    pub fn initialize_default_contracts(&self) {
508        self.add_raw_source(
509            "Counter.sol",
510            include_str!("../../forge/assets/solidity/CounterTemplate.sol"),
511        );
512        self.add_raw_test(
513            "Counter.t.sol",
514            include_str!("../../forge/assets/solidity/CounterTemplate.t.sol"),
515        );
516        self.add_raw_script(
517            "Counter.s.sol",
518            include_str!("../../forge/assets/solidity/CounterTemplate.s.sol"),
519        );
520    }
521}
522
523fn config_paths_exist(paths: &ProjectPathsConfig, cached: bool) {
524    if cached {
525        assert!(paths.cache.exists());
526    }
527    assert!(paths.sources.exists());
528    assert!(paths.artifacts.exists());
529    paths.libraries.iter().for_each(|lib| assert!(lib.exists()));
530}
531
532/// A simple wrapper around a Command with some conveniences.
533pub struct TestCommand {
534    saved_cwd: PathBuf,
535    /// The project used to launch this command.
536    project: TestProject,
537    /// The actual command we use to control the process.
538    cmd: Command,
539    // initial: Command,
540    current_dir_lock: Option<parking_lot::MutexGuard<'static, ()>>,
541    stdin: Option<Vec<u8>>,
542    /// If true, command output is redacted.
543    redact_output: bool,
544}
545
546impl TestCommand {
547    /// Returns a mutable reference to the underlying command.
548    pub fn cmd(&mut self) -> &mut Command {
549        &mut self.cmd
550    }
551
552    /// Replaces the underlying command.
553    pub fn set_cmd(&mut self, cmd: Command) -> &mut Self {
554        self.cmd = cmd;
555        self
556    }
557
558    /// Resets the command to the default `forge` command.
559    pub fn forge_fuse(&mut self) -> &mut Self {
560        self.set_cmd(self.project.forge_bin())
561    }
562
563    /// Resets the command to the default `cast` command.
564    pub fn cast_fuse(&mut self) -> &mut Self {
565        self.set_cmd(self.project.cast_bin())
566    }
567
568    /// Sets the current working directory.
569    pub fn set_current_dir(&mut self, p: impl AsRef<Path>) {
570        drop(self.current_dir_lock.take());
571        let lock = CURRENT_DIR_LOCK.lock();
572        self.current_dir_lock = Some(lock);
573        let p = p.as_ref();
574        pretty_err(p, std::env::set_current_dir(p));
575    }
576
577    /// Add an argument to pass to the command.
578    pub fn arg<A: AsRef<OsStr>>(&mut self, arg: A) -> &mut Self {
579        self.cmd.arg(arg);
580        self
581    }
582
583    /// Add any number of arguments to the command.
584    pub fn args<I, A>(&mut self, args: I) -> &mut Self
585    where
586        I: IntoIterator<Item = A>,
587        A: AsRef<OsStr>,
588    {
589        self.cmd.args(args);
590        self
591    }
592
593    /// Set the stdin bytes for the next command.
594    pub fn stdin(&mut self, stdin: impl Into<Vec<u8>>) -> &mut Self {
595        self.stdin = Some(stdin.into());
596        self
597    }
598
599    /// Convenience function to add `--root project.root()` argument
600    pub fn root_arg(&mut self) -> &mut Self {
601        let root = self.project.root().to_path_buf();
602        self.arg("--root").arg(root)
603    }
604
605    /// Set the environment variable `k` to value `v` for the command.
606    pub fn env(&mut self, k: impl AsRef<OsStr>, v: impl AsRef<OsStr>) {
607        self.cmd.env(k, v);
608    }
609
610    /// Set the environment variable `k` to value `v` for the command.
611    pub fn envs<I, K, V>(&mut self, envs: I)
612    where
613        I: IntoIterator<Item = (K, V)>,
614        K: AsRef<OsStr>,
615        V: AsRef<OsStr>,
616    {
617        self.cmd.envs(envs);
618    }
619
620    /// Unsets the environment variable `k` for the command.
621    pub fn unset_env(&mut self, k: impl AsRef<OsStr>) {
622        self.cmd.env_remove(k);
623    }
624
625    /// Set the working directory for this command.
626    ///
627    /// Note that this does not need to be called normally, since the creation
628    /// of this TestCommand causes its working directory to be set to the
629    /// test's directory automatically.
630    pub fn current_dir<P: AsRef<Path>>(&mut self, dir: P) -> &mut Self {
631        self.cmd.current_dir(dir);
632        self
633    }
634
635    /// Returns the `Config` as spit out by `forge config`
636    #[track_caller]
637    pub fn config(&mut self) -> Config {
638        self.cmd.args(["config", "--json"]);
639        let output = self.assert().success().get_output().stdout_lossy();
640        self.forge_fuse();
641        serde_json::from_str(output.as_ref()).unwrap()
642    }
643
644    /// Runs `git init` inside the project's dir
645    #[track_caller]
646    pub fn git_init(&self) {
647        let mut cmd = Command::new("git");
648        cmd.arg("init").current_dir(self.project.root());
649        let output = OutputAssert::new(cmd.output().unwrap());
650        output.success();
651    }
652
653    /// Runs `git submodule status` inside the project's dir
654    #[track_caller]
655    pub fn git_submodule_status(&self) -> Output {
656        let mut cmd = Command::new("git");
657        cmd.arg("submodule").arg("status").current_dir(self.project.root());
658        cmd.output().unwrap()
659    }
660
661    /// Runs `git add .` inside the project's dir
662    #[track_caller]
663    pub fn git_add(&self) {
664        let mut cmd = Command::new("git");
665        cmd.current_dir(self.project.root());
666        cmd.arg("add").arg(".");
667        let output = OutputAssert::new(cmd.output().unwrap());
668        output.success();
669    }
670
671    /// Runs `git commit .` inside the project's dir
672    #[track_caller]
673    pub fn git_commit(&self, msg: &str) {
674        let mut cmd = Command::new("git");
675        cmd.current_dir(self.project.root());
676        cmd.arg("commit").arg("-m").arg(msg);
677        let output = OutputAssert::new(cmd.output().unwrap());
678        output.success();
679    }
680
681    /// Runs the command, returning a [`snapbox`] object to assert the command output.
682    #[track_caller]
683    pub fn assert_with(&mut self, f: &[RegexRedaction]) -> OutputAssert {
684        let assert = OutputAssert::new(self.execute());
685        if self.redact_output {
686            let mut redactions = test_redactions();
687            insert_redactions(f, &mut redactions);
688            return assert.with_assert(
689                snapbox::Assert::new()
690                    .action_env(snapbox::assert::DEFAULT_ACTION_ENV)
691                    .redact_with(redactions),
692            );
693        }
694        assert
695    }
696
697    /// Runs the command, returning a [`snapbox`] object to assert the command output.
698    #[track_caller]
699    pub fn assert(&mut self) -> OutputAssert {
700        self.assert_with(&[])
701    }
702
703    /// Runs the command and asserts that it resulted in success.
704    #[track_caller]
705    pub fn assert_success(&mut self) -> OutputAssert {
706        self.assert().success()
707    }
708
709    /// Runs the command and asserts that it resulted in success, with expected JSON data.
710    #[track_caller]
711    pub fn assert_json_stdout(&mut self, expected: impl IntoData) {
712        let expected = expected.is(snapbox::data::DataFormat::Json).unordered();
713        let stdout = self.assert_success().get_output().stdout.clone();
714        let actual = stdout.into_data().is(snapbox::data::DataFormat::Json).unordered();
715        assert_data_eq!(actual, expected);
716    }
717
718    /// Runs the command and asserts that it resulted in the expected outcome and JSON data.
719    #[track_caller]
720    pub fn assert_json_stderr(&mut self, success: bool, expected: impl IntoData) {
721        let expected = expected.is(snapbox::data::DataFormat::Json).unordered();
722        let stderr = if success { self.assert_success() } else { self.assert_failure() }
723            .get_output()
724            .stderr
725            .clone();
726        let actual = stderr.into_data().is(snapbox::data::DataFormat::Json).unordered();
727        assert_data_eq!(actual, expected);
728    }
729
730    /// Runs the command and asserts that it **succeeded** nothing was printed to stdout.
731    #[track_caller]
732    pub fn assert_empty_stdout(&mut self) {
733        self.assert_success().stdout_eq(Data::new());
734    }
735
736    /// Runs the command and asserts that it failed.
737    #[track_caller]
738    pub fn assert_failure(&mut self) -> OutputAssert {
739        self.assert().failure()
740    }
741
742    /// Runs the command and asserts that the exit code is `expected`.
743    #[track_caller]
744    pub fn assert_code(&mut self, expected: i32) -> OutputAssert {
745        self.assert().code(expected)
746    }
747
748    /// Runs the command and asserts that it **failed** nothing was printed to stderr.
749    #[track_caller]
750    pub fn assert_empty_stderr(&mut self) {
751        self.assert_failure().stderr_eq(Data::new());
752    }
753
754    /// Runs the command with a temporary file argument and asserts that the contents of the file
755    /// match the given data.
756    #[track_caller]
757    pub fn assert_file(&mut self, data: impl IntoData) {
758        self.assert_file_with(|this, path| _ = this.arg(path).assert_success(), data);
759    }
760
761    /// Creates a temporary file, passes it to `f`, then asserts that the contents of the file match
762    /// the given data.
763    #[track_caller]
764    pub fn assert_file_with(&mut self, f: impl FnOnce(&mut Self, &Path), data: impl IntoData) {
765        let file = tempfile::NamedTempFile::new().expect("couldn't create temporary file");
766        f(self, file.path());
767        assert_data_eq!(Data::read_from(file.path(), None), data);
768    }
769
770    /// Does not apply [`snapbox`] redactions to the command output.
771    pub fn with_no_redact(&mut self) -> &mut Self {
772        self.redact_output = false;
773        self
774    }
775
776    /// Executes command, applies stdin function and returns output
777    #[track_caller]
778    pub fn execute(&mut self) -> Output {
779        self.try_execute().unwrap()
780    }
781
782    #[track_caller]
783    pub fn try_execute(&mut self) -> std::io::Result<Output> {
784        test_debug!("executing {:?}", self.cmd);
785        let mut child =
786            self.cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).stdin(Stdio::piped()).spawn()?;
787        if let Some(bytes) = self.stdin.take() {
788            child.stdin.take().unwrap().write_all(&bytes)?;
789        }
790        let output = child.wait_with_output()?;
791        test_debug!("exited with {}", output.status);
792        test_trace!("\n--- stdout ---\n{}\n--- /stdout ---", output.stdout_lossy());
793        test_trace!("\n--- stderr ---\n{}\n--- /stderr ---", output.stderr_lossy());
794        Ok(output)
795    }
796}
797
798impl Drop for TestCommand {
799    fn drop(&mut self) {
800        let _lock = self.current_dir_lock.take().unwrap_or_else(|| CURRENT_DIR_LOCK.lock());
801        if self.saved_cwd.exists() {
802            let _ = std::env::set_current_dir(&self.saved_cwd);
803        }
804    }
805}
806
807fn test_redactions() -> snapbox::Redactions {
808    static REDACTIONS: LazyLock<snapbox::Redactions> = LazyLock::new(|| {
809        make_redactions(&[
810            ("[SOLC_VERSION]", r"Solc( version)? \d+.\d+.\d+"),
811            ("[ELAPSED]", r"(finished )?in \d+(\.\d+)?\w?s( \(.*?s CPU time\))?"),
812            ("[GAS]", r"[Gg]as( used)?: \d+"),
813            ("[GAS_COST]", r"[Gg]as cost\s*\(\d+\)"),
814            ("[GAS_LIMIT]", r"[Gg]as limit\s*\(\d+\)"),
815            ("[AVG_GAS]", r"μ: \d+, ~: \d+"),
816            ("[FILE]", r"(-->|╭▸).*\.sol"),
817            ("[FILE]", r"Location(.|\n)*\.rs(.|\n)*Backtrace"),
818            ("[COMPILING_FILES]", r"Compiling \d+ files?"),
819            ("[TX_HASH]", r"Transaction hash: 0x[0-9A-Fa-f]{64}"),
820            ("[ADDRESS]", r"Address: +0x[0-9A-Fa-f]{40}"),
821            ("[PUBLIC_KEY]", r"Public key: +0x[0-9A-Fa-f]{128}"),
822            ("[PRIVATE_KEY]", r"Private key: +0x[0-9A-Fa-f]{64}"),
823            ("[UPDATING_DEPENDENCIES]", r"Updating dependencies in .*"),
824            ("[SAVED_TRANSACTIONS]", r"Transactions saved to: .*\.json"),
825            ("[SAVED_SENSITIVE_VALUES]", r"Sensitive values saved to: .*\.json"),
826            ("[ESTIMATED_GAS_PRICE]", r"Estimated gas price:\s*(\d+(\.\d+)?)\s*gwei"),
827            ("[ESTIMATED_TOTAL_GAS_USED]", r"Estimated total gas used for script: \d+"),
828            (
829                "[ESTIMATED_AMOUNT_REQUIRED]",
830                r"Estimated amount required:\s*(\d+(\.\d+)?)\s*[A-Z]{3}",
831            ),
832            ("[SEED]", r"Fuzz seed: 0x[0-9A-Fa-f]+"),
833        ])
834    });
835    REDACTIONS.clone()
836}
837
838/// A tuple of a placeholder and a regex replacement string.
839pub type RegexRedaction = (&'static str, &'static str);
840
841/// Creates a [`snapbox`] redactions object from a list of regex redactions.
842fn make_redactions(redactions: &[RegexRedaction]) -> snapbox::Redactions {
843    let mut r = snapbox::Redactions::new();
844    insert_redactions(redactions, &mut r);
845    r
846}
847
848fn insert_redactions(redactions: &[RegexRedaction], r: &mut snapbox::Redactions) {
849    for &(placeholder, re) in redactions {
850        r.insert(placeholder, Regex::new(re).expect(re)).expect(re);
851    }
852}
853
854/// Extension trait for [`Output`].
855pub trait OutputExt {
856    /// Returns the stdout as lossy string
857    fn stdout_lossy(&self) -> String;
858
859    /// Returns the stderr as lossy string
860    fn stderr_lossy(&self) -> String;
861}
862
863impl OutputExt for Output {
864    fn stdout_lossy(&self) -> String {
865        lossy_string(&self.stdout)
866    }
867
868    fn stderr_lossy(&self) -> String {
869        lossy_string(&self.stderr)
870    }
871}
872
873pub fn lossy_string(bytes: &[u8]) -> String {
874    String::from_utf8_lossy(bytes).replace("\r\n", "\n")
875}
876
877fn canonicalize(path: impl AsRef<Path>) -> PathBuf {
878    foundry_common::fs::canonicalize_path(path.as_ref())
879        .unwrap_or_else(|_| path.as_ref().to_path_buf())
880}