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#[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 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 pub const fn style(mut self, style: PathStyle) -> Self {
45 self.style = style;
46 self
47 }
48
49 pub const fn fork_block(mut self, fork_block: u64) -> Self {
51 self.fork_block = Some(fork_block);
52 self
53 }
54
55 pub const fn fuzz_runs(mut self, fuzz_runs: u32) -> Self {
57 self.fuzz_runs = fuzz_runs;
58 self
59 }
60
61 pub fn arg(mut self, arg: impl Into<String>) -> Self {
63 self.args.push(arg.into());
64 self
65 }
66
67 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 pub fn verbosity(mut self, verbosity: usize) -> Self {
79 self.verbosity = format!("-{}", "v".repeat(verbosity));
80 self
81 }
82
83 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 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 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 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 prj.wipe();
120
121 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 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 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 pub fn run(&self) {
207 let (prj, mut test_cmd) = self.setup_forge_prj(true);
208
209 self.run_install_commands(prj.root().to_str().unwrap());
211
212 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
233fn 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 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 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}