foundry_test_utils/
util.rs

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