Skip to main content

foundry_test_utils/
util.rs

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