Skip to main content

foundry_test_utils/
ext.rs

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/// External test builder
13#[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    /// Creates a new external test builder.
31    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    /// Sets the path style.
48    pub const fn style(mut self, style: PathStyle) -> Self {
49        self.style = style;
50        self
51    }
52
53    /// Sets the fork block.
54    pub const fn fork_block(mut self, fork_block: u64) -> Self {
55        self.fork_block = Some(fork_block);
56        self
57    }
58
59    /// Sets the number of fuzz runs.
60    pub const fn fuzz_runs(mut self, fuzz_runs: u32) -> Self {
61        self.fuzz_runs = fuzz_runs;
62        self
63    }
64
65    /// Adds an argument to the forge command.
66    pub fn arg(mut self, arg: impl Into<String>) -> Self {
67        self.args.push(arg.into());
68        self
69    }
70
71    /// Adds multiple arguments to the forge command.
72    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    /// Sets the verbosity
82    pub fn verbosity(mut self, verbosity: usize) -> Self {
83        self.verbosity = format!("-{}", "v".repeat(verbosity));
84        self
85    }
86
87    /// Adds an environment variable to the forge command.
88    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    /// Adds multiple environment variables to the forge command.
94    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    /// Adds a command to run after the project is cloned.
105    ///
106    /// Commands run in the project's root directory as alternatives, stopping at the first success.
107    /// If commands are configured and all fail, the test fails before running Forge.
108    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    /// Adds a Python package to install in a fixture-local virtual environment.
114    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        // Wipe the default structure.
123        prj.wipe();
124
125        // Clone the external repository.
126        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        // Checkout the revision.
131        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        // 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                        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    /// Runs the test, reusing compiler artifacts when `FOUNDRY_EXTERNAL_TEST_CACHE` is set.
210    ///
211    /// Fixtures must use the default `out` and `cache` directories. Sources and test-state caches
212    /// are refreshed for every run; Forge still validates compiler settings and source hashes.
213    pub fn run(&self) {
214        let (prj, mut test_cmd) = self.setup_forge_prj(true);
215
216        // Stable paths preserve compiler-cache remappings across fresh fixture checkouts.
217        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        // Run installation command.
233        self.run_install_commands(root.to_str().unwrap());
234
235        // Run the tests.
236        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
259/// Refreshes a cached fixture, retaining only compiler artifacts and their cache metadata.
260fn 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
292/// Clones fixture history without downloading unused file contents or default-branch submodules.
293fn 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
302/// Checks out a fixture revision and restores its pinned submodule revisions.
303fn 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        // Initialize submodules only after selecting the fixture revision. Sync each parent's
319        // URLs before updating its children, including when revisiting an existing checkout.
320        // Filter blobs rather than history so install/update regressions can resolve branch refs.
321        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        // Checking out only the parent leaves the dependency at the newer revision.
414        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        // Use the file protocol so Git honors clone filters instead of making a local hardlink
556        // copy.
557        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        // The default branch points at an unavailable dependency, which must never be cloned.
575        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        // Install/update fixtures need named branches as well as the pinned commit.
595        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}