foundry_test_utils/
util.rs

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