foundry_test_utils/
util.rs

1use foundry_compilers::{
2    Project, ProjectCompileOutput, Vyper, project_util::copy_dir, utils::RuntimeOrHandle,
3};
4use foundry_config::Config;
5use std::{
6    env,
7    fs::{self, File},
8    io::{IsTerminal, Read, Seek, Write},
9    path::{Path, PathBuf},
10    process::Command,
11    sync::LazyLock,
12};
13
14pub use crate::{ext::*, prj::*};
15
16/// The commit of forge-std to use.
17pub const FORGE_STD_REVISION: &str = include_str!("../../../testdata/forge-std-rev");
18
19/// Stores whether `stdout` is a tty / terminal.
20pub static IS_TTY: LazyLock<bool> = LazyLock::new(|| std::io::stdout().is_terminal());
21
22/// Global default template path. Contains the global template project from which all other
23/// temp projects are initialized. See [`initialize()`] for more info.
24static TEMPLATE_PATH: LazyLock<PathBuf> =
25    LazyLock::new(|| env::temp_dir().join("foundry-forge-test-template"));
26
27/// Global default template lock. If its contents are not exactly `"1"`, the global template will
28/// be re-initialized. See [`initialize()`] for more info.
29static TEMPLATE_LOCK: LazyLock<PathBuf> =
30    LazyLock::new(|| env::temp_dir().join("foundry-forge-test-template.lock"));
31
32/// The default Solc version used when compiling tests.
33pub const SOLC_VERSION: &str = "0.8.30";
34
35/// Another Solc version used when compiling tests.
36///
37/// Necessary to avoid downloading multiple versions.
38pub const OTHER_SOLC_VERSION: &str = "0.8.26";
39
40/// Initializes a project with `forge init` at the given path from a template directory.
41///
42/// This should be called after an empty project is created like in
43/// [some of this crate's macros](crate::forgetest_init).
44///
45/// ## Note
46///
47/// This doesn't always run `forge init`, instead opting to copy an already-initialized template
48/// project from a global template path. This is done to speed up tests.
49///
50/// This used to use a `static` `Lazy`, but this approach does not with `cargo-nextest` because it
51/// runs each test in a separate process. Instead, we use a global lock file to ensure that only one
52/// test can initialize the template at a time.
53///
54/// This sets the project's solc version to the [`SOLC_VERSION`].
55pub fn initialize(target: &Path) {
56    test_debug!("initializing {}", target.display());
57
58    let tpath = TEMPLATE_PATH.as_path();
59    pretty_err(tpath, fs::create_dir_all(tpath));
60
61    // Initialize the global template if necessary.
62    let mut lock = crate::fd_lock::new_lock(TEMPLATE_LOCK.as_path());
63    let mut _read = lock.read().unwrap();
64    if !crate::fd_lock::lock_exists(TEMPLATE_LOCK.as_path()) {
65        // We are the first to acquire the lock:
66        // - initialize a new empty temp project;
67        // - run `forge init`;
68        // - run `forge build`;
69        // - copy it over to the global template;
70        // Ideally we would be able to initialize a temp project directly in the global template,
71        // but `TempProject` does not currently allow this: https://github.com/foundry-rs/compilers/issues/22
72
73        // Release the read lock and acquire a write lock, initializing the lock file.
74        drop(_read);
75        let mut write = lock.write().unwrap();
76
77        let mut data = Vec::new();
78        write.read_to_end(&mut data).unwrap();
79        if data != crate::fd_lock::LOCK_TOKEN {
80            // Initialize and build.
81            let (prj, mut cmd) = setup_forge("template", foundry_compilers::PathStyle::Dapptools);
82            test_debug!("- initializing template dir in {}", prj.root().display());
83
84            cmd.args(["init", "--force", "--empty"]).assert_success();
85            prj.write_config(Config {
86                solc: Some(foundry_config::SolcReq::Version(SOLC_VERSION.parse().unwrap())),
87                ..Default::default()
88            });
89
90            // Checkout forge-std.
91            let output = Command::new("git")
92                .current_dir(prj.root().join("lib/forge-std"))
93                .args(["checkout", FORGE_STD_REVISION])
94                .output()
95                .expect("failed to checkout forge-std");
96            assert!(output.status.success(), "{output:#?}");
97
98            // Build the project.
99            cmd.forge_fuse().arg("build").assert_success();
100
101            // Remove the existing template, if any.
102            let _ = fs::remove_dir_all(tpath);
103
104            // Copy the template to the global template path.
105            pretty_err(tpath, copy_dir(prj.root(), tpath));
106
107            // Update lockfile to mark that template is initialized.
108            write.set_len(0).unwrap();
109            write.seek(std::io::SeekFrom::Start(0)).unwrap();
110            write.write_all(crate::fd_lock::LOCK_TOKEN).unwrap();
111        }
112
113        // Release the write lock and acquire a new read lock.
114        drop(write);
115        _read = lock.read().unwrap();
116    }
117
118    test_debug!("- copying template dir from {}", tpath.display());
119    pretty_err(target, fs::create_dir_all(target));
120    pretty_err(target, copy_dir(tpath, target));
121}
122
123/// Compile the project with a lock for the cache.
124pub fn get_compiled(project: &mut Project) -> ProjectCompileOutput {
125    let lock_file_path = project.sources_path().join(".lock");
126    // We need to use a file lock because `cargo-nextest` runs tests in different processes.
127    // This is similar to `initialize`, see its comments for more details.
128    let mut lock = crate::fd_lock::new_lock(&lock_file_path);
129    let read = lock.read().unwrap();
130    let out;
131
132    let mut write = None;
133    if !project.cache_path().exists() || !crate::fd_lock::lock_exists(&lock_file_path) {
134        drop(read);
135        write = Some(lock.write().unwrap());
136        test_debug!("cache miss for {}", lock_file_path.display());
137    } else {
138        test_debug!("cache hit for {}", lock_file_path.display());
139    }
140
141    if project.compiler.vyper.is_none() {
142        project.compiler.vyper = Some(get_vyper());
143    }
144
145    test_debug!("compiling {}", lock_file_path.display());
146    out = project.compile().unwrap();
147    test_debug!("compiled {}", lock_file_path.display());
148
149    if out.has_compiler_errors() {
150        panic!("Compiled with errors:\n{out}");
151    }
152
153    if let Some(write) = &mut write {
154        write.write_all(crate::fd_lock::LOCK_TOKEN).unwrap();
155    }
156
157    out
158}
159
160/// Installs Vyper if it's not already present.
161pub fn get_vyper() -> Vyper {
162    static VYPER: LazyLock<PathBuf> = LazyLock::new(|| std::env::temp_dir().join("vyper"));
163
164    if let Ok(vyper) = Vyper::new("vyper") {
165        return vyper;
166    }
167    if let Ok(vyper) = Vyper::new(&*VYPER) {
168        return vyper;
169    }
170    return RuntimeOrHandle::new().block_on(install());
171
172    async fn install() -> Vyper {
173        #[cfg(target_family = "unix")]
174        use std::{fs::Permissions, os::unix::fs::PermissionsExt};
175
176        let path = VYPER.as_path();
177        let mut file = File::create(path).unwrap();
178        if let Err(e) = file.try_lock() {
179            if let fs::TryLockError::WouldBlock = e {
180                file.lock().unwrap();
181                assert!(path.exists());
182                return Vyper::new(path).unwrap();
183            }
184            file.lock().unwrap();
185        }
186
187        let suffix = match svm::platform() {
188            svm::Platform::MacOsAarch64 => "darwin",
189            svm::Platform::LinuxAmd64 => "linux",
190            svm::Platform::WindowsAmd64 => "windows.exe",
191            platform => panic!(
192                "unsupported platform {platform:?} for installing vyper, \
193                 install it manually and add it to $PATH"
194            ),
195        };
196        let url = format!(
197            "https://github.com/vyperlang/vyper/releases/download/v0.4.3/vyper.0.4.3+commit.bff19ea2.{suffix}"
198        );
199
200        test_debug!("downloading vyper from {url}");
201        let res = reqwest::Client::builder().build().unwrap().get(url).send().await.unwrap();
202
203        assert!(res.status().is_success());
204
205        let bytes = res.bytes().await.unwrap();
206
207        file.write_all(&bytes).unwrap();
208
209        #[cfg(target_family = "unix")]
210        file.set_permissions(Permissions::from_mode(0o755)).unwrap();
211
212        Vyper::new(path).unwrap()
213    }
214}
215
216#[track_caller]
217pub fn pretty_err<T, E: std::error::Error>(path: impl AsRef<Path>, res: Result<T, E>) -> T {
218    match res {
219        Ok(t) => t,
220        Err(err) => panic!("{}: {err}", path.as_ref().display()),
221    }
222}
223
224pub fn read_string(path: impl AsRef<Path>) -> String {
225    let path = path.as_ref();
226    pretty_err(path, std::fs::read_to_string(path))
227}