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