foundry_test_utils/
prj.rs

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