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