foundry_test_utils/
util.rs

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