1use crate::{
2 prj::{TestCommand, TestProject, setup_forge},
3 util::copy_dir_filtered,
4};
5use foundry_compilers::PathStyle;
6use std::{
7 fs,
8 path::{Path, PathBuf},
9 process::Command,
10};
11
12#[derive(Clone, Debug)]
14#[must_use = "ExtTester does nothing unless you `run` it"]
15pub struct ExtTester {
16 pub org: &'static str,
17 pub name: &'static str,
18 pub rev: &'static str,
19 pub style: PathStyle,
20 pub fork_block: Option<u64>,
21 pub fuzz_runs: u32,
22 pub args: Vec<String>,
23 pub envs: Vec<(String, String)>,
24 pub install_commands: Vec<Vec<String>>,
25 pub python_packages: Vec<String>,
26 pub verbosity: String,
27}
28
29impl ExtTester {
30 pub fn new(org: &'static str, name: &'static str, rev: &'static str) -> Self {
32 Self {
33 org,
34 name,
35 rev,
36 style: PathStyle::Dapptools,
37 fork_block: None,
38 fuzz_runs: 32,
39 args: vec![],
40 envs: vec![],
41 install_commands: vec![],
42 python_packages: vec![],
43 verbosity: "-vvv".to_string(),
44 }
45 }
46
47 pub const fn style(mut self, style: PathStyle) -> Self {
49 self.style = style;
50 self
51 }
52
53 pub const fn fork_block(mut self, fork_block: u64) -> Self {
55 self.fork_block = Some(fork_block);
56 self
57 }
58
59 pub const fn fuzz_runs(mut self, fuzz_runs: u32) -> Self {
61 self.fuzz_runs = fuzz_runs;
62 self
63 }
64
65 pub fn arg(mut self, arg: impl Into<String>) -> Self {
67 self.args.push(arg.into());
68 self
69 }
70
71 pub fn args<I, A>(mut self, args: I) -> Self
73 where
74 I: IntoIterator<Item = A>,
75 A: Into<String>,
76 {
77 self.args.extend(args.into_iter().map(Into::into));
78 self
79 }
80
81 pub fn verbosity(mut self, verbosity: usize) -> Self {
83 self.verbosity = format!("-{}", "v".repeat(verbosity));
84 self
85 }
86
87 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
89 self.envs.push((key.into(), value.into()));
90 self
91 }
92
93 pub fn envs<I, K, V>(mut self, envs: I) -> Self
95 where
96 I: IntoIterator<Item = (K, V)>,
97 K: Into<String>,
98 V: Into<String>,
99 {
100 self.envs.extend(envs.into_iter().map(|(k, v)| (k.into(), v.into())));
101 self
102 }
103
104 pub fn install_command(mut self, command: &[&str]) -> Self {
109 self.install_commands.push(command.iter().map(|s| s.to_string()).collect());
110 self
111 }
112
113 pub fn python_package(mut self, package: impl Into<String>) -> Self {
115 self.python_packages.push(package.into());
116 self
117 }
118
119 pub fn setup_forge_prj(&self, recursive: bool) -> (TestProject, TestCommand) {
120 let (prj, mut test_cmd) = setup_forge(self.name, self.style.clone());
121
122 prj.wipe();
124
125 let repo_url = format!("https://github.com/{}/{}.git", self.org, self.name);
127 let root = prj.root().to_str().unwrap();
128 clone_fixture(&repo_url, root);
129
130 if self.rev.is_empty() {
132 let mut git = Command::new("git");
133 git.current_dir(root).args(["log", "-n", "1"]);
134 test_debug!("$ {git:?}");
135 let output = git.output().unwrap();
136 assert!(output.status.success(), "git log failed: {output:?}");
137 let stdout = String::from_utf8(output.stdout).unwrap();
138 let commit = stdout.lines().next().unwrap().split_whitespace().nth(1).unwrap();
139 panic!("pin to latest commit: {commit}");
140 }
141 checkout_revision(root, self.rev, recursive);
142
143 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 return;
199 }
200 }
201 Err(e) => {
202 eprintln!("\n\n{install_cmd:?}: {e}");
203 }
204 }
205 }
206 assert!(self.install_commands.is_empty(), "all dependency installation commands failed");
207 }
208
209 pub fn run(&self) {
214 let (prj, mut test_cmd) = self.setup_forge_prj(true);
215
216 let cached_root = std::env::var_os("FOUNDRY_EXTERNAL_TEST_CACHE")
218 .map(|root| PathBuf::from(root).join(self.org).join(self.name).join(self.rev));
219 let mut cache_lock = cached_root.as_ref().map(|root| {
220 fs::create_dir_all(root.parent().unwrap()).unwrap();
221 crate::fd_lock::new_lock(root.with_extension("lock"))
222 });
223 let _cache_guard = cache_lock.as_mut().map(|lock| lock.write().unwrap());
224 let root = if let Some(root) = &cached_root {
225 prepare_cached_project(prj.root(), root);
226 root.as_path()
227 } else {
228 prj.root()
229 };
230 test_cmd.current_dir(root);
231
232 self.run_install_commands(root.to_str().unwrap());
234
235 test_cmd.arg("test");
237 test_cmd.args(&self.args);
238 test_cmd.args([
239 format!("--fuzz-runs={}", self.fuzz_runs),
240 "--ffi".to_string(),
241 self.verbosity.clone(),
242 ]);
243
244 test_cmd.envs(self.envs.iter().map(|(k, v)| (k, v)));
245 if let Some(fork_block) = self.fork_block {
246 test_cmd.env("FOUNDRY_ETH_RPC_URL", crate::rpc::next_http_archive_rpc_url());
247 test_cmd.env("FOUNDRY_FORK_BLOCK_NUMBER", fork_block.to_string());
248 }
249 test_cmd.env("FOUNDRY_INVARIANT_DEPTH", "15");
250 test_cmd.env("FOUNDRY_ALLOW_INTERNAL_EXPECT_REVERT", "true");
251
252 test_cmd.assert_success();
253 if let Some(root) = &cached_root {
254 retain_compiler_artifacts(root);
255 }
256 }
257}
258
259fn prepare_cached_project(source: &Path, target: &Path) {
261 retain_compiler_artifacts(target);
262 copy_dir_filtered(source, target).unwrap();
263}
264
265fn retain_compiler_artifacts(target: &Path) {
266 fs::create_dir_all(target).unwrap();
267 for entry in fs::read_dir(target).unwrap() {
268 let entry = entry.unwrap();
269 if entry.file_name() != "out" && entry.file_name() != "cache" {
270 remove_cache_entry(&entry);
271 }
272 }
273 let cache = target.join("cache");
274 if cache.is_dir() {
275 for entry in fs::read_dir(&cache).unwrap() {
276 let entry = entry.unwrap();
277 if entry.file_name() != "solidity-files-cache.json" {
278 remove_cache_entry(&entry);
279 }
280 }
281 }
282}
283
284fn remove_cache_entry(entry: &fs::DirEntry) {
285 if entry.file_type().unwrap().is_dir() {
286 fs::remove_dir_all(entry.path()).unwrap();
287 } else {
288 fs::remove_file(entry.path()).unwrap();
289 }
290}
291
292fn clone_fixture(repo_url: &str, root: &str) {
294 let mut git = Command::new("git");
295 git.args(["clone", "--filter=blob:none", "--no-checkout", "--no-recurse-submodules"])
296 .args([repo_url, root]);
297 test_debug!("$ {git:?}");
298 let status = git.status().unwrap();
299 assert!(status.success(), "git clone failed: {status}");
300}
301
302fn checkout_revision(root: &str, rev: &str, recursive: bool) {
304 checkout_revision_inner(root, rev, recursive, None);
305}
306
307fn checkout_revision_inner(root: &str, rev: &str, recursive: bool, allowed_protocol: Option<&str>) {
308 let mut git = Command::new("git");
309 if let Some(protocol) = allowed_protocol {
310 git.env("GIT_ALLOW_PROTOCOL", protocol);
311 }
312 git.current_dir(root).args(["checkout", rev]);
313 test_debug!("$ {git:?}");
314 let status = git.status().unwrap();
315 assert!(status.success(), "git checkout failed: {status}");
316
317 if recursive {
318 for args in [
322 &["-c", "submodule.recurse=false", "submodule", "sync"][..],
323 &[
324 "-c",
325 "submodule.recurse=false",
326 "submodule",
327 "update",
328 "--init",
329 "--checkout",
330 "--filter=blob:none",
331 ][..],
332 &[
333 "submodule",
334 "foreach",
335 "--recursive",
336 "git -c submodule.recurse=false submodule sync && git -c submodule.recurse=false \
337 submodule update --init --checkout --filter=blob:none",
338 ][..],
339 ] {
340 let mut git = Command::new("git");
341 if let Some(protocol) = allowed_protocol {
342 git.env("GIT_ALLOW_PROTOCOL", protocol);
343 }
344 git.current_dir(root).args(args);
345 test_debug!("$ {git:?}");
346 let status = git.status().unwrap();
347 assert!(status.success(), "git {args:?} failed: {status}");
348 }
349 }
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355 use reqwest::Url;
356
357 #[cfg(unix)]
358 use std::os::unix::fs::symlink;
359
360 fn git(root: &Path, args: &[&str]) -> String {
361 let output = Command::new("git")
362 .current_dir(root)
363 .args([
364 "-c",
365 "user.name=Test",
366 "-c",
367 "user.email=test@example.com",
368 "-c",
369 "commit.gpgsign=false",
370 ])
371 .args(args)
372 .output()
373 .unwrap();
374 assert!(output.status.success(), "{args:?}: {}", String::from_utf8_lossy(&output.stderr));
375 String::from_utf8(output.stdout).unwrap().trim().to_string()
376 }
377
378 #[test]
379 fn checkout_revision_restores_pinned_submodule() {
380 let temp = tempfile::tempdir().unwrap();
381 let dependency = temp.path().join("dependency");
382 let fixture = temp.path().join("fixture");
383 fs::create_dir(&dependency).unwrap();
384 fs::create_dir(&fixture).unwrap();
385 git(&dependency, &["init"]);
386 fs::write(dependency.join("draft.sol"), "old interface").unwrap();
387 git(&dependency, &["add", "."]);
388 git(&dependency, &["commit", "-m", "old interface"]);
389 let pinned_dependency = git(&dependency, &["rev-parse", "HEAD"]);
390
391 git(&fixture, &["init"]);
392 git(
393 &fixture,
394 &[
395 "-c",
396 "protocol.file.allow=always",
397 "submodule",
398 "add",
399 dependency.to_str().unwrap(),
400 "lib/dependency",
401 ],
402 );
403 git(&fixture, &["commit", "-am", "pin old dependency"]);
404 let pinned_fixture = git(&fixture, &["rev-parse", "HEAD"]);
405 git(&fixture, &["config", "submodule.recurse", "false"]);
406
407 let submodule = fixture.join("lib/dependency");
408 git(&submodule, &["mv", "draft.sol", "interface.sol"]);
409 git(&submodule, &["commit", "-m", "rename interface"]);
410 git(&fixture, &["commit", "-am", "update dependency"]);
411 let updated_fixture = git(&fixture, &["rev-parse", "HEAD"]);
412
413 checkout_revision(fixture.to_str().unwrap(), &pinned_fixture, false);
415 assert!(!submodule.join("draft.sol").exists());
416 assert!(submodule.join("interface.sol").exists());
417
418 checkout_revision(fixture.to_str().unwrap(), &updated_fixture, false);
419 git(&fixture, &["config", "submodule.lib/dependency.update", "merge"]);
420 checkout_revision(fixture.to_str().unwrap(), &pinned_fixture, true);
421 assert_eq!(git(&submodule, &["rev-parse", "HEAD"]), pinned_dependency);
422 assert!(submodule.join("draft.sol").exists());
423 assert!(!submodule.join("interface.sol").exists());
424 assert!(git(&fixture, &["status", "--porcelain"]).is_empty());
425 }
426
427 #[test]
428 fn checkout_revision_syncs_nested_submodule_after_parent_checkout() {
429 let temp = tempfile::tempdir().unwrap();
430 let old_nested = temp.path().join("old-nested");
431 let new_nested = temp.path().join("new-nested");
432 let dependency = temp.path().join("dependency");
433 let fixture = temp.path().join("fixture");
434
435 for (repository, file) in [(&old_nested, "old.sol"), (&new_nested, "new.sol")] {
436 fs::create_dir(repository).unwrap();
437 git(repository, &["init"]);
438 fs::write(repository.join(file), file).unwrap();
439 git(repository, &["add", "."]);
440 git(repository, &["commit", "-m", file]);
441 }
442 let pinned_nested = git(&old_nested, &["rev-parse", "HEAD"]);
443
444 fs::create_dir(&dependency).unwrap();
445 git(&dependency, &["init"]);
446 git(
447 &dependency,
448 &[
449 "-c",
450 "protocol.file.allow=always",
451 "submodule",
452 "add",
453 old_nested.to_str().unwrap(),
454 "lib/nested",
455 ],
456 );
457 git(&dependency, &["commit", "-am", "pin old nested dependency"]);
458 let pinned_dependency = git(&dependency, &["rev-parse", "HEAD"]);
459
460 fs::create_dir(&fixture).unwrap();
461 git(&fixture, &["init"]);
462 git(
463 &fixture,
464 &[
465 "-c",
466 "protocol.file.allow=always",
467 "submodule",
468 "add",
469 dependency.to_str().unwrap(),
470 "lib/dependency",
471 ],
472 );
473 git(&fixture, &["commit", "-am", "pin old dependency"]);
474 let pinned_fixture = git(&fixture, &["rev-parse", "HEAD"]);
475
476 git(
477 &dependency,
478 &[
479 "config",
480 "--file",
481 ".gitmodules",
482 "submodule.lib/nested.url",
483 new_nested.to_str().unwrap(),
484 ],
485 );
486 git(&dependency, &["submodule", "sync"]);
487 let nested = dependency.join("lib/nested");
488 let updated_nested = git(&new_nested, &["rev-parse", "HEAD"]);
489 git(&nested, &["fetch", "origin"]);
490 git(&nested, &["checkout", &updated_nested]);
491 git(&dependency, &["add", "."]);
492 git(&dependency, &["commit", "-m", "use new nested dependency"]);
493 let updated_dependency = git(&dependency, &["rev-parse", "HEAD"]);
494 let fixture_dependency = fixture.join("lib/dependency");
495 git(&fixture_dependency, &["fetch", "origin"]);
496 git(&fixture_dependency, &["checkout", &updated_dependency]);
497 git(&fixture, &["add", "lib/dependency"]);
498 git(&fixture, &["commit", "-m", "update dependency"]);
499
500 for recursive in [false, true] {
501 let checkout = temp.path().join(format!("checkout-{recursive}"));
502 if recursive {
503 git(
504 temp.path(),
505 &[
506 "-c",
507 "protocol.file.allow=always",
508 "clone",
509 "--recursive",
510 fixture.to_str().unwrap(),
511 checkout.to_str().unwrap(),
512 ],
513 );
514 } else {
515 clone_fixture(fixture.to_str().unwrap(), checkout.to_str().unwrap());
516 }
517 git(&checkout, &["config", "submodule.recurse", "false"]);
518
519 checkout_revision_inner(
520 checkout.to_str().unwrap(),
521 &pinned_fixture,
522 true,
523 Some("file"),
524 );
525
526 let checked_out_dependency = checkout.join("lib/dependency");
527 let checked_out_nested = checked_out_dependency.join("lib/nested");
528 assert_eq!(git(&checked_out_dependency, &["rev-parse", "HEAD"]), pinned_dependency);
529 assert_eq!(git(&checked_out_nested, &["rev-parse", "HEAD"]), pinned_nested);
530 assert_eq!(
531 git(&checked_out_nested, &["remote", "get-url", "origin"]),
532 old_nested.to_str().unwrap()
533 );
534 assert!(checked_out_nested.join("old.sol").exists());
535 assert!(!checked_out_nested.join("new.sol").exists());
536 assert!(git(&checkout, &["status", "--porcelain"]).is_empty());
537 }
538 }
539
540 #[test]
541 fn clone_fixture_skips_default_branch_submodules() {
542 let temp = tempfile::tempdir().unwrap();
543 let dependency = temp.path().join("dependency");
544 let fixture = temp.path().join("fixture");
545 let checkout = temp.path().join("checkout");
546 fs::create_dir(&dependency).unwrap();
547 fs::create_dir(&fixture).unwrap();
548 git(&dependency, &["init"]);
549 fs::write(dependency.join("pinned.sol"), "pinned dependency").unwrap();
550 git(&dependency, &["add", "."]);
551 git(&dependency, &["commit", "-m", "pinned dependency"]);
552 let pinned_dependency = git(&dependency, &["rev-parse", "HEAD"]);
553 git(&dependency, &["branch", "v1"]);
554 git(&dependency, &["config", "uploadpack.allowFilter", "true"]);
555 let dependency_url = Url::from_directory_path(&dependency).unwrap();
558
559 git(&fixture, &["init"]);
560 git(
561 &fixture,
562 &[
563 "-c",
564 "protocol.file.allow=always",
565 "submodule",
566 "add",
567 dependency_url.as_str(),
568 "lib/dependency",
569 ],
570 );
571 git(&fixture, &["commit", "-am", "pin dependency"]);
572 let pinned_fixture = git(&fixture, &["rev-parse", "HEAD"]);
573
574 git(
576 &fixture,
577 &[
578 "config",
579 "--file",
580 ".gitmodules",
581 "submodule.lib/dependency.url",
582 temp.path().join("missing").to_str().unwrap(),
583 ],
584 );
585 git(&fixture, &["commit", "-am", "change default branch dependency"]);
586
587 clone_fixture(fixture.to_str().unwrap(), checkout.to_str().unwrap());
588 assert!(!checkout.join("lib").exists());
589 checkout_revision_inner(checkout.to_str().unwrap(), &pinned_fixture, true, Some("file"));
590
591 let submodule = checkout.join("lib/dependency");
592 assert_eq!(git(&submodule, &["rev-parse", "HEAD"]), pinned_dependency);
593 assert_eq!(fs::read_to_string(submodule.join("pinned.sol")).unwrap(), "pinned dependency");
594 git(&submodule, &["checkout", "v1"]);
596 assert_eq!(git(&submodule, &["rev-parse", "HEAD"]), pinned_dependency);
597 assert!(git(&checkout, &["status", "--porcelain"]).is_empty());
598 }
599
600 #[test]
601 fn cached_project_retains_only_compiler_state() {
602 let temp = tempfile::tempdir().unwrap();
603 let source = temp.path().join("source");
604 let target = temp.path().join("cached");
605 fs::create_dir_all(source.join("src")).unwrap();
606 fs::write(source.join("src/Current.sol"), "fresh source").unwrap();
607 prepare_cached_project(&source, &target);
608
609 for dir in ["out", "cache/invariant", "cache/fuzz", "src", "node_modules", "broadcast"] {
610 fs::create_dir_all(target.join(dir)).unwrap();
611 fs::write(target.join(dir).join("old"), "stale state").unwrap();
612 }
613 fs::write(target.join("cache/solidity-files-cache.json"), "compiler metadata").unwrap();
614 fs::write(target.join("src/Current.sol"), "modified by previous test").unwrap();
615 fs::write(target.join("unexpected"), "test side effect").unwrap();
616
617 prepare_cached_project(&source, &target);
618
619 assert_eq!(fs::read_to_string(target.join("src/Current.sol")).unwrap(), "fresh source");
620 assert_eq!(fs::read_to_string(target.join("out/old")).unwrap(), "stale state");
621 assert_eq!(
622 fs::read_to_string(target.join("cache/solidity-files-cache.json")).unwrap(),
623 "compiler metadata"
624 );
625 for path in
626 ["cache/invariant", "cache/fuzz", "src/old", "node_modules", "broadcast", "unexpected"]
627 {
628 assert!(!target.join(path).exists(), "retained test state: {path}");
629 }
630 retain_compiler_artifacts(&target);
631 assert!(!target.join("src").exists());
632 assert!(target.join("out/old").exists());
633 assert!(target.join("cache/solidity-files-cache.json").exists());
634 }
635
636 #[test]
637 #[cfg(unix)]
638 fn cached_project_skips_fixture_python_environment() {
639 let temp = tempfile::tempdir().unwrap();
640 let source = temp.path().join("source");
641 let target = temp.path().join("cached");
642 let venv = source.join(".foundry-ext-venv");
643 fs::create_dir_all(venv.join("lib")).unwrap();
644 symlink("lib", venv.join("lib64")).unwrap();
645 fs::write(source.join("Current.sol"), "fresh source").unwrap();
646
647 prepare_cached_project(&source, &target);
648
649 assert_eq!(fs::read_to_string(target.join("Current.sol")).unwrap(), "fresh source");
650 assert!(!target.join(".foundry-ext-venv").exists());
651 assert_eq!(fs::read_link(venv.join("lib64")).unwrap(), Path::new("lib"));
652 }
653}