Skip to main content

foundry_test_utils/
ext.rs

1use crate::prj::{TestCommand, TestProject, clone_remote, setup_forge};
2use foundry_compilers::PathStyle;
3use std::{
4    path::{Path, PathBuf},
5    process::Command,
6};
7
8/// External test builder
9#[derive(Clone, Debug)]
10#[must_use = "ExtTester does nothing unless you `run` it"]
11pub struct ExtTester {
12    pub org: &'static str,
13    pub name: &'static str,
14    pub rev: &'static str,
15    pub style: PathStyle,
16    pub fork_block: Option<u64>,
17    pub fuzz_runs: u32,
18    pub args: Vec<String>,
19    pub envs: Vec<(String, String)>,
20    pub install_commands: Vec<Vec<String>>,
21    pub python_packages: Vec<String>,
22    pub verbosity: String,
23}
24
25impl ExtTester {
26    /// Creates a new external test builder.
27    pub fn new(org: &'static str, name: &'static str, rev: &'static str) -> Self {
28        Self {
29            org,
30            name,
31            rev,
32            style: PathStyle::Dapptools,
33            fork_block: None,
34            fuzz_runs: 32,
35            args: vec![],
36            envs: vec![],
37            install_commands: vec![],
38            python_packages: vec![],
39            verbosity: "-vvv".to_string(),
40        }
41    }
42
43    /// Sets the path style.
44    pub const fn style(mut self, style: PathStyle) -> Self {
45        self.style = style;
46        self
47    }
48
49    /// Sets the fork block.
50    pub const fn fork_block(mut self, fork_block: u64) -> Self {
51        self.fork_block = Some(fork_block);
52        self
53    }
54
55    /// Sets the number of fuzz runs.
56    pub const fn fuzz_runs(mut self, fuzz_runs: u32) -> Self {
57        self.fuzz_runs = fuzz_runs;
58        self
59    }
60
61    /// Adds an argument to the forge command.
62    pub fn arg(mut self, arg: impl Into<String>) -> Self {
63        self.args.push(arg.into());
64        self
65    }
66
67    /// Adds multiple arguments to the forge command.
68    pub fn args<I, A>(mut self, args: I) -> Self
69    where
70        I: IntoIterator<Item = A>,
71        A: Into<String>,
72    {
73        self.args.extend(args.into_iter().map(Into::into));
74        self
75    }
76
77    /// Sets the verbosity
78    pub fn verbosity(mut self, verbosity: usize) -> Self {
79        self.verbosity = format!("-{}", "v".repeat(verbosity));
80        self
81    }
82
83    /// Adds an environment variable to the forge command.
84    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
85        self.envs.push((key.into(), value.into()));
86        self
87    }
88
89    /// Adds multiple environment variables to the forge command.
90    pub fn envs<I, K, V>(mut self, envs: I) -> Self
91    where
92        I: IntoIterator<Item = (K, V)>,
93        K: Into<String>,
94        V: Into<String>,
95    {
96        self.envs.extend(envs.into_iter().map(|(k, v)| (k.into(), v.into())));
97        self
98    }
99
100    /// Adds a command to run after the project is cloned.
101    ///
102    /// Commands run in the project's root directory as alternatives, stopping at the first success.
103    /// If commands are configured and all fail, the test fails before running Forge.
104    pub fn install_command(mut self, command: &[&str]) -> Self {
105        self.install_commands.push(command.iter().map(|s| s.to_string()).collect());
106        self
107    }
108
109    /// Adds a Python package to install in a fixture-local virtual environment.
110    pub fn python_package(mut self, package: impl Into<String>) -> Self {
111        self.python_packages.push(package.into());
112        self
113    }
114
115    pub fn setup_forge_prj(&self, recursive: bool) -> (TestProject, TestCommand) {
116        let (prj, mut test_cmd) = setup_forge(self.name, self.style.clone());
117
118        // Wipe the default structure.
119        prj.wipe();
120
121        // Clone the external repository.
122        let repo_url = format!("https://github.com/{}/{}.git", self.org, self.name);
123        let root = prj.root().to_str().unwrap();
124        clone_remote(&repo_url, root, recursive);
125
126        // Checkout the revision.
127        if self.rev.is_empty() {
128            let mut git = Command::new("git");
129            git.current_dir(root).args(["log", "-n", "1"]);
130            test_debug!("$ {git:?}");
131            let output = git.output().unwrap();
132            assert!(output.status.success(), "git log failed: {output:?}");
133            let stdout = String::from_utf8(output.stdout).unwrap();
134            let commit = stdout.lines().next().unwrap().split_whitespace().nth(1).unwrap();
135            panic!("pin to latest commit: {commit}");
136        }
137        checkout_revision(root, self.rev, recursive);
138
139        // Export fixture-local Python packages, vyper, and forge in the test command.
140        let mut new_paths = Vec::new();
141        if let Some(python_bin_dir) = self.install_python_packages(root) {
142            new_paths.push(python_bin_dir);
143        }
144        if let Some(vyper) = &prj.inner.project().compiler.vyper {
145            let vyper_dir = vyper.path.parent().expect("vyper path should have a parent");
146            new_paths.push(vyper_dir.to_path_buf());
147        }
148        let forge_bin = prj.foundry_bin_path("forge");
149        let forge_dir = forge_bin.parent().expect("forge path should have a parent");
150        new_paths.push(forge_dir.to_path_buf());
151        let existing_path = std::env::var_os("PATH").unwrap_or_default();
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        (prj, test_cmd)
158    }
159
160    fn install_python_packages(&self, root: &str) -> Option<PathBuf> {
161        if self.python_packages.is_empty() {
162            return None;
163        }
164
165        let venv = Path::new(root).join(".foundry-ext-venv");
166        let mut venv_cmd = Command::new("python3");
167        venv_cmd.args(["-m", "venv"]).arg(&venv);
168        test_debug!("cd {root}; {venv_cmd:?}");
169        let status = venv_cmd.current_dir(root).status().expect("failed to create Python venv");
170        assert!(status.success(), "python venv creation failed: {status}");
171
172        let bin_dir = venv.join(if cfg!(windows) { "Scripts" } else { "bin" });
173        let pip = bin_dir.join(if cfg!(windows) { "pip.exe" } else { "pip" });
174        for package in &self.python_packages {
175            let mut pip_cmd = Command::new(&pip);
176            pip_cmd.args(["install", "--disable-pip-version-check"]).arg(package).current_dir(root);
177            test_debug!("cd {root}; {pip_cmd:?}");
178            let status = pip_cmd.status().expect("failed to install Python package");
179            assert!(status.success(), "Python package install failed: {status}");
180        }
181
182        Some(bin_dir)
183    }
184
185    pub fn run_install_commands(&self, root: &str) {
186        for install_command in &self.install_commands {
187            let mut install_cmd = Command::new(&install_command[0]);
188            install_cmd.args(&install_command[1..]).current_dir(root);
189            test_debug!("cd {root}; {install_cmd:?}");
190            match install_cmd.status() {
191                Ok(s) => {
192                    test_debug!("\n\n{install_cmd:?}: {s}");
193                    if s.success() {
194                        return;
195                    }
196                }
197                Err(e) => {
198                    eprintln!("\n\n{install_cmd:?}: {e}");
199                }
200            }
201        }
202        assert!(self.install_commands.is_empty(), "all dependency installation commands failed");
203    }
204
205    /// Runs the test.
206    pub fn run(&self) {
207        let (prj, mut test_cmd) = self.setup_forge_prj(true);
208
209        // Run installation command.
210        self.run_install_commands(prj.root().to_str().unwrap());
211
212        // Run the tests.
213        test_cmd.arg("test");
214        test_cmd.args(&self.args);
215        test_cmd.args([
216            format!("--fuzz-runs={}", self.fuzz_runs),
217            "--ffi".to_string(),
218            self.verbosity.clone(),
219        ]);
220
221        test_cmd.envs(self.envs.iter().map(|(k, v)| (k, v)));
222        if let Some(fork_block) = self.fork_block {
223            test_cmd.env("FOUNDRY_ETH_RPC_URL", crate::rpc::next_http_archive_rpc_url());
224            test_cmd.env("FOUNDRY_FORK_BLOCK_NUMBER", fork_block.to_string());
225        }
226        test_cmd.env("FOUNDRY_INVARIANT_DEPTH", "15");
227        test_cmd.env("FOUNDRY_ALLOW_INTERNAL_EXPECT_REVERT", "true");
228
229        test_cmd.assert_success();
230    }
231}
232
233/// Checks out a fixture revision and restores its pinned submodule revisions.
234fn checkout_revision(root: &str, rev: &str, recursive: bool) {
235    checkout_revision_inner(root, rev, recursive, None);
236}
237
238fn checkout_revision_inner(root: &str, rev: &str, recursive: bool, allowed_protocol: Option<&str>) {
239    let mut git = Command::new("git");
240    if let Some(protocol) = allowed_protocol {
241        git.env("GIT_ALLOW_PROTOCOL", protocol);
242    }
243    git.current_dir(root).args(["checkout", rev]);
244    test_debug!("$ {git:?}");
245    let status = git.status().unwrap();
246    assert!(status.success(), "git checkout failed: {status}");
247
248    if recursive {
249        // The clone initialized submodules from the default branch, not the pinned revision.
250        for args in [
251            &["-c", "submodule.recurse=false", "submodule", "sync"][..],
252            &["-c", "submodule.recurse=false", "submodule", "update", "--init", "--checkout"][..],
253            &[
254                "submodule",
255                "foreach",
256                "--recursive",
257                "git -c submodule.recurse=false submodule sync && git -c submodule.recurse=false \
258                 submodule update --init --checkout",
259            ][..],
260        ] {
261            let mut git = Command::new("git");
262            if let Some(protocol) = allowed_protocol {
263                git.env("GIT_ALLOW_PROTOCOL", protocol);
264            }
265            git.current_dir(root).args(args);
266            test_debug!("$ {git:?}");
267            let status = git.status().unwrap();
268            assert!(status.success(), "git {args:?} failed: {status}");
269        }
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use std::fs;
277
278    fn git(root: &Path, args: &[&str]) -> String {
279        let output = Command::new("git")
280            .current_dir(root)
281            .args([
282                "-c",
283                "user.name=Test",
284                "-c",
285                "user.email=test@example.com",
286                "-c",
287                "commit.gpgsign=false",
288            ])
289            .args(args)
290            .output()
291            .unwrap();
292        assert!(output.status.success(), "{args:?}: {}", String::from_utf8_lossy(&output.stderr));
293        String::from_utf8(output.stdout).unwrap().trim().to_string()
294    }
295
296    #[test]
297    fn checkout_revision_restores_pinned_submodule() {
298        let temp = tempfile::tempdir().unwrap();
299        let dependency = temp.path().join("dependency");
300        let fixture = temp.path().join("fixture");
301        fs::create_dir(&dependency).unwrap();
302        fs::create_dir(&fixture).unwrap();
303        git(&dependency, &["init"]);
304        fs::write(dependency.join("draft.sol"), "old interface").unwrap();
305        git(&dependency, &["add", "."]);
306        git(&dependency, &["commit", "-m", "old interface"]);
307        let pinned_dependency = git(&dependency, &["rev-parse", "HEAD"]);
308
309        git(&fixture, &["init"]);
310        git(
311            &fixture,
312            &[
313                "-c",
314                "protocol.file.allow=always",
315                "submodule",
316                "add",
317                dependency.to_str().unwrap(),
318                "lib/dependency",
319            ],
320        );
321        git(&fixture, &["commit", "-am", "pin old dependency"]);
322        let pinned_fixture = git(&fixture, &["rev-parse", "HEAD"]);
323        git(&fixture, &["config", "submodule.recurse", "false"]);
324
325        let submodule = fixture.join("lib/dependency");
326        git(&submodule, &["mv", "draft.sol", "interface.sol"]);
327        git(&submodule, &["commit", "-m", "rename interface"]);
328        git(&fixture, &["commit", "-am", "update dependency"]);
329        let updated_fixture = git(&fixture, &["rev-parse", "HEAD"]);
330
331        // Checking out only the parent leaves the dependency at the newer revision.
332        checkout_revision(fixture.to_str().unwrap(), &pinned_fixture, false);
333        assert!(!submodule.join("draft.sol").exists());
334        assert!(submodule.join("interface.sol").exists());
335
336        checkout_revision(fixture.to_str().unwrap(), &updated_fixture, false);
337        git(&fixture, &["config", "submodule.lib/dependency.update", "merge"]);
338        checkout_revision(fixture.to_str().unwrap(), &pinned_fixture, true);
339        assert_eq!(git(&submodule, &["rev-parse", "HEAD"]), pinned_dependency);
340        assert!(submodule.join("draft.sol").exists());
341        assert!(!submodule.join("interface.sol").exists());
342        assert!(git(&fixture, &["status", "--porcelain"]).is_empty());
343    }
344
345    #[test]
346    fn checkout_revision_syncs_nested_submodule_after_parent_checkout() {
347        let temp = tempfile::tempdir().unwrap();
348        let old_nested = temp.path().join("old-nested");
349        let new_nested = temp.path().join("new-nested");
350        let dependency = temp.path().join("dependency");
351        let fixture = temp.path().join("fixture");
352        let checkout = temp.path().join("checkout");
353
354        for (repository, file) in [(&old_nested, "old.sol"), (&new_nested, "new.sol")] {
355            fs::create_dir(repository).unwrap();
356            git(repository, &["init"]);
357            fs::write(repository.join(file), file).unwrap();
358            git(repository, &["add", "."]);
359            git(repository, &["commit", "-m", file]);
360        }
361        let pinned_nested = git(&old_nested, &["rev-parse", "HEAD"]);
362
363        fs::create_dir(&dependency).unwrap();
364        git(&dependency, &["init"]);
365        git(
366            &dependency,
367            &[
368                "-c",
369                "protocol.file.allow=always",
370                "submodule",
371                "add",
372                old_nested.to_str().unwrap(),
373                "lib/nested",
374            ],
375        );
376        git(&dependency, &["commit", "-am", "pin old nested dependency"]);
377        let pinned_dependency = git(&dependency, &["rev-parse", "HEAD"]);
378
379        fs::create_dir(&fixture).unwrap();
380        git(&fixture, &["init"]);
381        git(
382            &fixture,
383            &[
384                "-c",
385                "protocol.file.allow=always",
386                "submodule",
387                "add",
388                dependency.to_str().unwrap(),
389                "lib/dependency",
390            ],
391        );
392        git(&fixture, &["commit", "-am", "pin old dependency"]);
393        let pinned_fixture = git(&fixture, &["rev-parse", "HEAD"]);
394
395        git(
396            &dependency,
397            &[
398                "config",
399                "--file",
400                ".gitmodules",
401                "submodule.lib/nested.url",
402                new_nested.to_str().unwrap(),
403            ],
404        );
405        git(&dependency, &["submodule", "sync"]);
406        let nested = dependency.join("lib/nested");
407        let updated_nested = git(&new_nested, &["rev-parse", "HEAD"]);
408        git(&nested, &["fetch", "origin"]);
409        git(&nested, &["checkout", &updated_nested]);
410        git(&dependency, &["add", "."]);
411        git(&dependency, &["commit", "-m", "use new nested dependency"]);
412        let updated_dependency = git(&dependency, &["rev-parse", "HEAD"]);
413        let fixture_dependency = fixture.join("lib/dependency");
414        git(&fixture_dependency, &["fetch", "origin"]);
415        git(&fixture_dependency, &["checkout", &updated_dependency]);
416        git(&fixture, &["add", "lib/dependency"]);
417        git(&fixture, &["commit", "-m", "update dependency"]);
418
419        git(
420            temp.path(),
421            &[
422                "-c",
423                "protocol.file.allow=always",
424                "clone",
425                "--recursive",
426                fixture.to_str().unwrap(),
427                checkout.to_str().unwrap(),
428            ],
429        );
430        git(&checkout, &["config", "submodule.recurse", "false"]);
431
432        checkout_revision_inner(checkout.to_str().unwrap(), &pinned_fixture, true, Some("file"));
433
434        let checked_out_dependency = checkout.join("lib/dependency");
435        let checked_out_nested = checked_out_dependency.join("lib/nested");
436        assert_eq!(git(&checked_out_dependency, &["rev-parse", "HEAD"]), pinned_dependency);
437        assert_eq!(git(&checked_out_nested, &["rev-parse", "HEAD"]), pinned_nested);
438        assert_eq!(
439            git(&checked_out_nested, &["remote", "get-url", "origin"]),
440            old_nested.to_str().unwrap()
441        );
442        assert!(checked_out_nested.join("old.sol").exists());
443        assert!(!checked_out_nested.join("new.sol").exists());
444        assert!(git(&checkout, &["status", "--porcelain"]).is_empty());
445    }
446}