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    /// Note that the command is run in the project's root directory, and it won't fail the test if
103    /// it fails.
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        let mut git = Command::new("git");
138        git.current_dir(root).args(["checkout", self.rev]);
139        test_debug!("$ {git:?}");
140        let status = git.status().unwrap();
141        assert!(status.success(), "git checkout failed: {status}");
142
143        // Export fixture-local Python packages, vyper, and forge in the test command.
144        let mut new_paths = Vec::new();
145        if let Some(python_bin_dir) = self.install_python_packages(root) {
146            new_paths.push(python_bin_dir);
147        }
148        if let Some(vyper) = &prj.inner.project().compiler.vyper {
149            let vyper_dir = vyper.path.parent().expect("vyper path should have a parent");
150            new_paths.push(vyper_dir.to_path_buf());
151        }
152        let forge_bin = prj.foundry_bin_path("forge");
153        let forge_dir = forge_bin.parent().expect("forge path should have a parent");
154        new_paths.push(forge_dir.to_path_buf());
155        let existing_path = std::env::var_os("PATH").unwrap_or_default();
156        new_paths.extend(std::env::split_paths(&existing_path));
157
158        let joined_path = std::env::join_paths(new_paths).expect("failed to join PATH");
159        test_cmd.env("PATH", joined_path);
160
161        (prj, test_cmd)
162    }
163
164    fn install_python_packages(&self, root: &str) -> Option<PathBuf> {
165        if self.python_packages.is_empty() {
166            return None;
167        }
168
169        let venv = Path::new(root).join(".foundry-ext-venv");
170        let mut venv_cmd = Command::new("python3");
171        venv_cmd.args(["-m", "venv"]).arg(&venv);
172        test_debug!("cd {root}; {venv_cmd:?}");
173        let status = venv_cmd.current_dir(root).status().expect("failed to create Python venv");
174        assert!(status.success(), "python venv creation failed: {status}");
175
176        let bin_dir = venv.join(if cfg!(windows) { "Scripts" } else { "bin" });
177        let pip = bin_dir.join(if cfg!(windows) { "pip.exe" } else { "pip" });
178        for package in &self.python_packages {
179            let mut pip_cmd = Command::new(&pip);
180            pip_cmd.args(["install", "--disable-pip-version-check"]).arg(package).current_dir(root);
181            test_debug!("cd {root}; {pip_cmd:?}");
182            let status = pip_cmd.status().expect("failed to install Python package");
183            assert!(status.success(), "Python package install failed: {status}");
184        }
185
186        Some(bin_dir)
187    }
188
189    pub fn run_install_commands(&self, root: &str) {
190        for install_command in &self.install_commands {
191            let mut install_cmd = Command::new(&install_command[0]);
192            install_cmd.args(&install_command[1..]).current_dir(root);
193            test_debug!("cd {root}; {install_cmd:?}");
194            match install_cmd.status() {
195                Ok(s) => {
196                    test_debug!("\n\n{install_cmd:?}: {s}");
197                    if s.success() {
198                        break;
199                    }
200                }
201                Err(e) => {
202                    eprintln!("\n\n{install_cmd:?}: {e}");
203                }
204            }
205        }
206    }
207
208    /// Runs the test.
209    pub fn run(&self) {
210        let (prj, mut test_cmd) = self.setup_forge_prj(true);
211
212        // Run installation command.
213        self.run_install_commands(prj.root().to_str().unwrap());
214
215        // Run the tests.
216        test_cmd.arg("test");
217        test_cmd.args(&self.args);
218        test_cmd.args([
219            format!("--fuzz-runs={}", self.fuzz_runs),
220            "--ffi".to_string(),
221            self.verbosity.clone(),
222        ]);
223
224        test_cmd.envs(self.envs.iter().map(|(k, v)| (k, v)));
225        if let Some(fork_block) = self.fork_block {
226            test_cmd.env("FOUNDRY_ETH_RPC_URL", crate::rpc::next_http_archive_rpc_url());
227            test_cmd.env("FOUNDRY_FORK_BLOCK_NUMBER", fork_block.to_string());
228        }
229        test_cmd.env("FOUNDRY_INVARIANT_DEPTH", "15");
230        test_cmd.env("FOUNDRY_ALLOW_INTERNAL_EXPECT_REVERT", "true");
231
232        test_cmd.assert_success();
233    }
234}