diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index d31f2b96b007..36ed3ae598bd 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -1058,6 +1058,8 @@ impl Config { }; remove_test_dir(&self.fuzz.failure_persist_dir); remove_test_dir(&self.invariant.failure_persist_dir); + remove_test_dir(&Some(self.cache_path.clone().join("coverage"))); + remove_test_dir(&Some(self.cache_path.clone())); // Remove snapshot directory. let snapshot_dir = project.root().join(&self.snapshots); diff --git a/crates/evm/coverage/Cargo.toml b/crates/evm/coverage/Cargo.toml index e38d33e2a590..569942d4f519 100644 --- a/crates/evm/coverage/Cargo.toml +++ b/crates/evm/coverage/Cargo.toml @@ -21,6 +21,6 @@ foundry-evm-core.workspace = true alloy-primitives.workspace = true eyre.workspace = true revm.workspace = true -semver.workspace = true tracing.workspace = true rayon.workspace = true +semver.workspace = true diff --git a/crates/evm/coverage/src/analysis.rs b/crates/evm/coverage/src/analysis.rs index f8cc746c5a10..729cdbc45e32 100644 --- a/crates/evm/coverage/src/analysis.rs +++ b/crates/evm/coverage/src/analysis.rs @@ -1,18 +1,19 @@ use super::{CoverageItem, CoverageItemKind, SourceLocation}; use alloy_primitives::map::HashMap; +use core::fmt; use foundry_common::TestFunctionExt; use foundry_compilers::artifacts::{ ast::{self, Ast, Node, NodeType}, Source, }; use rayon::prelude::*; -use std::sync::Arc; +use std::{borrow::Cow, sync::Arc}; /// A visitor that walks the AST of a single contract and finds coverage items. #[derive(Clone, Debug)] pub struct ContractVisitor<'a> { /// The source ID of the contract. - source_id: usize, + source_id: SourceIdentifier, /// The source code that contains the AST being walked. source: &'a str, @@ -29,7 +30,7 @@ pub struct ContractVisitor<'a> { } impl<'a> ContractVisitor<'a> { - pub fn new(source_id: usize, source: &'a str, contract_name: &'a Arc) -> Self { + pub fn new(source_id: SourceIdentifier, source: &'a str, contract_name: &'a Arc) -> Self { Self { source_id, source, contract_name, branch_id: 0, last_line: 0, items: Vec::new() } } @@ -484,7 +485,7 @@ impl<'a> ContractVisitor<'a> { let n_lines = self.source[bytes.start as usize..bytes.end as usize].lines().count() as u32; let lines = start_line..start_line + n_lines; SourceLocation { - source_id: self.source_id, + source_id: self.source_id.clone(), contract_name: self.contract_name.clone(), bytes, lines, @@ -545,7 +546,7 @@ impl<'a> SourceAnalyzer<'a> { .sources .sources .par_iter() - .flat_map_iter(|(&source_id, SourceFile { source, ast })| { + .flat_map_iter(|(source_id, SourceFile { source, ast })| { ast.nodes.iter().map(move |node| { if !matches!(node.node_type, NodeType::ContractDefinition) { return Ok(vec![]); @@ -563,7 +564,7 @@ impl<'a> SourceAnalyzer<'a> { .attribute("name") .ok_or_else(|| eyre::eyre!("Contract has no name"))?; - let mut visitor = ContractVisitor::new(source_id, &source.content, &name); + let mut visitor = ContractVisitor::new(source_id.clone(), &source.content, &name); visitor.visit_contract(node)?; let mut items = visitor.items; @@ -590,7 +591,31 @@ impl<'a> SourceAnalyzer<'a> { #[derive(Debug, Default)] pub struct SourceFiles<'a> { /// The versioned sources. - pub sources: HashMap>, + /// Keyed by build_id and source_id. + pub sources: HashMap>, +} + +/// Serves as a unique identifier for sources across multiple compiler runs. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct SourceIdentifier { + /// Source ID is unique for each source file per compilation job but may not be across + /// different jobs. + pub source_id: usize, + /// Artifact build id is same for all sources in a single compilation job. But always unique + /// across different jobs. + pub build_id: String, +} + +impl fmt::Display for SourceIdentifier { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "source_id={} build_id={}", self.source_id, self.build_id,) + } +} + +impl SourceIdentifier { + pub fn new(source_id: usize, build_id: String) -> Self { + Self { source_id, build_id } + } } /// The source code and AST of a file. @@ -599,5 +624,5 @@ pub struct SourceFile<'a> { /// The source code. pub source: Source, /// The AST of the source code. - pub ast: &'a Ast, + pub ast: Cow<'a, Ast>, } diff --git a/crates/evm/coverage/src/anchors.rs b/crates/evm/coverage/src/anchors.rs index ee723d95cc5c..521916a57f9b 100644 --- a/crates/evm/coverage/src/anchors.rs +++ b/crates/evm/coverage/src/anchors.rs @@ -1,3 +1,5 @@ +use crate::analysis::SourceIdentifier; + use super::{CoverageItem, CoverageItemKind, ItemAnchor, SourceLocation}; use alloy_primitives::map::{DefaultHashBuilder, HashMap, HashSet}; use eyre::ensure; @@ -11,12 +13,16 @@ pub fn find_anchors( source_map: &SourceMap, ic_pc_map: &IcPcMap, items: &[CoverageItem], - items_by_source_id: &HashMap>, + items_by_source_id: &HashMap>, + source_id: &SourceIdentifier, ) -> Vec { let mut seen = HashSet::with_hasher(DefaultHashBuilder::default()); source_map .iter() - .filter_map(|element| items_by_source_id.get(&(element.index()? as usize))) + .filter_map(|element| { + items_by_source_id + .get(&SourceIdentifier::new(element.index()? as usize, source_id.build_id.clone())) + }) .flatten() .filter_map(|&item_id| { if !seen.insert(item_id) { @@ -171,7 +177,8 @@ pub fn find_anchor_branch( /// Calculates whether `element` is within the range of the target `location`. fn is_in_source_range(element: &SourceElement, location: &SourceLocation) -> bool { // Source IDs must match. - let source_ids_match = element.index().is_some_and(|a| a as usize == location.source_id); + let source_ids_match = + element.index().is_some_and(|a| a as usize == location.source_id.source_id); if !source_ids_match { return false; } diff --git a/crates/evm/coverage/src/lib.rs b/crates/evm/coverage/src/lib.rs index 52ec329add14..e0a931aafc2f 100644 --- a/crates/evm/coverage/src/lib.rs +++ b/crates/evm/coverage/src/lib.rs @@ -12,6 +12,7 @@ use alloy_primitives::{ map::{B256HashMap, HashMap}, Bytes, }; +use analysis::SourceIdentifier; use eyre::Result; use foundry_compilers::artifacts::sourcemap::SourceMap; use semver::Version; @@ -37,29 +38,31 @@ pub use inspector::CoverageCollector; #[derive(Clone, Debug, Default)] pub struct CoverageReport { /// A map of source IDs to the source path. - pub source_paths: HashMap<(Version, usize), PathBuf>, + pub source_paths: HashMap, /// A map of source paths to source IDs. - pub source_paths_to_ids: HashMap<(Version, PathBuf), usize>, + pub source_paths_to_ids: HashMap<(Version, PathBuf), SourceIdentifier>, /// All coverage items for the codebase, keyed by the compiler version. - pub items: HashMap>, + pub items: HashMap>, /// All item anchors for the codebase, keyed by their contract ID. pub anchors: HashMap, Vec)>, /// All the bytecode hits for the codebase. pub bytecode_hits: HashMap, /// The bytecode -> source mappings. pub source_maps: HashMap, + /// Anchor Item ID by source ID + pub item_ids_by_source_id: HashMap>, } impl CoverageReport { /// Add a source file path. - pub fn add_source(&mut self, version: Version, source_id: usize, path: PathBuf) { - self.source_paths.insert((version.clone(), source_id), path.clone()); + pub fn add_source(&mut self, version: Version, source_id: SourceIdentifier, path: PathBuf) { + self.source_paths.insert(source_id.clone(), path.clone()); self.source_paths_to_ids.insert((version, path), source_id); } /// Get the source ID for a specific source file path. - pub fn get_source_id(&self, version: Version, path: PathBuf) -> Option { - self.source_paths_to_ids.get(&(version, path)).copied() + pub fn get_source_id(&self, version: Version, path: PathBuf) -> Option { + self.source_paths_to_ids.get(&(version, path)).cloned() } /// Add the source maps. @@ -71,8 +74,10 @@ impl CoverageReport { } /// Add coverage items to this report. - pub fn add_items(&mut self, version: Version, items: impl IntoIterator) { - self.items.entry(version).or_default().extend(items); + pub fn add_items(&mut self, items: impl IntoIterator) { + for item in items.into_iter() { + self.items.entry(item.loc.source_id.clone()).or_default().push(item); + } } /// Add anchors to this report. @@ -98,10 +103,9 @@ impl CoverageReport { mut f: impl FnMut(&mut T, &'a CoverageItem), ) -> impl Iterator { let mut by_file: BTreeMap<&Path, T> = BTreeMap::new(); - for (version, items) in &self.items { + for (key, items) in &self.items { for item in items { - let key = (version.clone(), item.loc.source_id); - let Some(path) = self.source_paths.get(&key) else { continue }; + let Some(path) = self.source_paths.get(key) else { continue }; f(by_file.entry(path).or_default(), item); } } @@ -131,8 +135,12 @@ impl CoverageReport { for anchor in anchors { if let Some(hits) = hit_map.get(anchor.instruction) { self.items - .get_mut(&contract_id.version) - .and_then(|items| items.get_mut(anchor.item_id)) + .get_mut(&contract_id.source_id) + .and_then(|items| { + let scaled_item_id = anchor.item_id % items.len(); + + items.get_mut(scaled_item_id) + }) .expect("Anchor refers to non-existent coverage item") .hits += hits.get(); } @@ -146,12 +154,9 @@ impl CoverageReport { /// This function should only be called after all the sources were used, otherwise, the output /// will be missing the ones that are dependent on them. pub fn filter_out_ignored_sources(&mut self, filter: impl Fn(&Path) -> bool) { - self.items.retain(|version, items| { - items.retain(|item| { - self.source_paths - .get(&(version.clone(), item.loc.source_id)) - .map(|path| filter(path)) - .unwrap_or(false) + self.items.retain(|source_id, items| { + items.retain(|_item| { + self.source_paths.get(source_id).map(|path| filter(path)).unwrap_or(false) }); !items.is_empty() }); @@ -276,18 +281,13 @@ impl HitMap { /// A unique identifier for a contract #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct ContractId { - pub version: Version, - pub source_id: usize, + pub source_id: SourceIdentifier, pub contract_name: Arc, } impl Display for ContractId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "Contract \"{}\" (solc {}, source ID {})", - self.contract_name, self.version, self.source_id - ) + write!(f, "Contract \"{}\" source ID {}", self.contract_name, self.source_id) } } @@ -367,7 +367,7 @@ impl Display for CoverageItem { #[derive(Clone, Debug)] pub struct SourceLocation { /// The source ID. - pub source_id: usize, + pub source_id: SourceIdentifier, /// The contract this source range is in. pub contract_name: Arc, /// Byte range. diff --git a/crates/forge/bin/cmd/coverage.rs b/crates/forge/bin/cmd/coverage.rs index 48bc8349c633..7ba3ddeabe5a 100644 --- a/crates/forge/bin/cmd/coverage.rs +++ b/crates/forge/bin/cmd/coverage.rs @@ -4,7 +4,7 @@ use clap::{Parser, ValueEnum, ValueHint}; use eyre::{Context, Result}; use forge::{ coverage::{ - analysis::{SourceAnalysis, SourceAnalyzer, SourceFile, SourceFiles}, + analysis::{SourceAnalysis, SourceAnalyzer, SourceFile, SourceFiles, SourceIdentifier}, anchors::find_anchors, BytecodeReporter, ContractId, CoverageReport, CoverageReporter, CoverageSummaryReporter, DebugReporter, ItemAnchor, LcovReporter, @@ -26,6 +26,7 @@ use foundry_config::{Config, SolcReq}; use rayon::prelude::*; use semver::{Version, VersionReq}; use std::{ + borrow::Cow, io, path::{Path, PathBuf}, sync::Arc, @@ -107,7 +108,28 @@ impl CoverageArgs { /// Builds the project. fn build(&self, config: &Config) -> Result<(Project, ProjectCompileOutput)> { // Set up the project - let mut project = config.create_project(false, false)?; + let mut project = config.create_project(config.cache, false)?; + + // Set a different artifacts path for coverage. `out/coverage`. + // This is done to avoid overwriting the artifacts of the main build that maybe built with + // different optimizer settings or --via-ir. Optimizer settings are disabled for + // coverage builds. + let coverage_artifacts_path = project.artifacts_path().join("coverage"); + project.paths.artifacts = coverage_artifacts_path.clone(); + project.paths.build_infos = coverage_artifacts_path.join("build-info"); + + // Set a different compiler cache path for coverage. `cache/coverage`. + let cache_file = project + .paths + .cache + .components() + .last() + .ok_or_else(|| eyre::eyre!("Cache path is empty"))?; + + let cache_dir = + project.paths.cache.parent().ok_or_else(|| eyre::eyre!("Cache path is empty"))?; + project.paths.cache = cache_dir.join("coverage").join(cache_file); + if self.ir_minimum { // print warning message sh_warn!("{}", concat!( @@ -139,6 +161,8 @@ impl CoverageArgs { project.settings.solc.via_ir = None; } + sh_warn!("optimizer settings have been disabled for accurate coverage reports")?; + let output = ProjectCompiler::default() .compile(&project)? .with_stripped_file_prefixes(project.root()); @@ -150,32 +174,39 @@ impl CoverageArgs { #[instrument(name = "prepare", skip_all)] fn prepare(&self, project: &Project, output: &ProjectCompileOutput) -> Result { let mut report = CoverageReport::default(); - // Collect source files. let project_paths = &project.paths; - let mut versioned_sources = HashMap::>::default(); - for (path, source_file, version) in output.output().sources.sources_with_version() { - report.add_source(version.clone(), source_file.id as usize, path.clone()); + let mut versioned_sources = HashMap::>::default(); + // Account cached and freshly compiled sources + for (id, artifact) in output.artifact_ids() { // Filter out dependencies - if !self.include_libs && project_paths.has_library_ancestor(path) { + if !self.include_libs && project_paths.has_library_ancestor(&id.source) { continue; } - if let Some(ast) = &source_file.ast { - let file = project_paths.root.join(path); + let build_id = id.build_id; + let source_file = if let Some(source_file) = artifact.source_file() { + source_file + } else { + sh_warn!("ast source file not found for {}", id.source.display())?; + continue; + }; + + let identifier = SourceIdentifier::new(source_file.id as usize, build_id.clone()); + report.add_source(id.version.clone(), identifier.clone(), id.source.clone()); + + if let Some(ast) = source_file.ast { + let file = project_paths.root.join(id.source); trace!(root=?project_paths.root, ?file, "reading source file"); let source = SourceFile { - ast, + ast: Cow::Owned(ast), source: Source::read(&file) .wrap_err("Could not read source code for analysis")?, }; - versioned_sources - .entry(version.clone()) - .or_default() - .sources - .insert(source_file.id as usize, source); + + versioned_sources.entry(id.version).or_default().sources.insert(identifier, source); } } @@ -189,9 +220,9 @@ impl CoverageArgs { }) .collect(); - // Add coverage items - for (version, sources) in &versioned_sources { - let source_analysis = SourceAnalyzer::new(sources).analyze()?; + for (_version, sources) in versioned_sources { + // Add coverage items + let source_analysis = SourceAnalyzer::new(&sources).analyze()?; // Build helper mapping used by `find_anchors` let mut items_by_source_id = HashMap::<_, Vec<_>>::with_capacity_and_hasher( @@ -200,23 +231,29 @@ impl CoverageArgs { ); for (item_id, item) in source_analysis.items.iter().enumerate() { - items_by_source_id.entry(item.loc.source_id).or_default().push(item_id); + items_by_source_id.entry(item.loc.source_id.clone()).or_default().push(item_id); } let anchors = artifacts .par_iter() - .filter(|artifact| artifact.contract_id.version == *version) + .filter(|artifact| sources.sources.contains_key(&artifact.contract_id.source_id)) .map(|artifact| { - let creation_code_anchors = - artifact.creation.find_anchors(&source_analysis, &items_by_source_id); - let deployed_code_anchors = - artifact.deployed.find_anchors(&source_analysis, &items_by_source_id); + let creation_code_anchors = artifact.creation.find_anchors( + &source_analysis, + &items_by_source_id, + &artifact.contract_id.source_id, + ); + let deployed_code_anchors = artifact.deployed.find_anchors( + &source_analysis, + &items_by_source_id, + &artifact.contract_id.source_id, + ); (artifact.contract_id.clone(), (creation_code_anchors, deployed_code_anchors)) }) .collect::>(); report.add_anchors(anchors); - report.add_items(version.clone(), source_analysis.items); + report.add_items(source_analysis.items); } report.add_source_maps(artifacts.into_iter().map(|artifact| { @@ -280,8 +317,10 @@ impl CoverageArgs { { report.add_hit_map( &ContractId { - version: artifact_id.version.clone(), - source_id, + source_id: SourceIdentifier::new( + source_id.source_id, + artifact_id.build_id.clone(), + ), contract_name: artifact_id.name.as_str().into(), }, map, @@ -360,13 +399,13 @@ pub struct ArtifactData { } impl ArtifactData { - pub fn new(id: &ArtifactId, source_id: usize, artifact: &impl Artifact) -> Option { + pub fn new( + id: &ArtifactId, + source_id: SourceIdentifier, + artifact: &impl Artifact, + ) -> Option { Some(Self { - contract_id: ContractId { - version: id.version.clone(), - source_id, - contract_name: id.name.as_str().into(), - }, + contract_id: ContractId { source_id, contract_name: id.name.as_str().into() }, creation: BytecodeData::new( artifact.get_source_map()?.ok()?, artifact @@ -405,7 +444,8 @@ impl BytecodeData { pub fn find_anchors( &self, source_analysis: &SourceAnalysis, - items_by_source_id: &HashMap>, + items_by_source_id: &HashMap>, + source_id: &SourceIdentifier, ) -> Vec { find_anchors( &self.bytecode, @@ -413,6 +453,7 @@ impl BytecodeData { &self.ic_pc_map, &source_analysis.items, items_by_source_id, + source_id, ) } } diff --git a/crates/forge/src/coverage.rs b/crates/forge/src/coverage.rs index ff3cac46eb88..3bbfa8b40155 100644 --- a/crates/forge/src/coverage.rs +++ b/crates/forge/src/coverage.rs @@ -1,6 +1,7 @@ //! Coverage reports. use alloy_primitives::map::HashMap; +use analysis::SourceIdentifier; use comfy_table::{modifiers::UTF8_ROUND_CORNERS, Attribute, Cell, Color, Row, Table}; use evm_disassembler::disassemble_bytes; use foundry_common::fs; @@ -202,7 +203,7 @@ impl CoverageReporter for DebugReporter { " - Refers to item: {}", report .items - .get(&contract_id.version) + .get(&contract_id.source_id) .and_then(|items| items.get(anchor.item_id)) .map_or("None".to_owned(), |item| item.to_string()) ); @@ -246,7 +247,10 @@ impl CoverageReporter for BytecodeReporter { .unwrap_or(" ".to_owned()); let source_id = source_element.index(); let source_path = source_id.and_then(|i| { - report.source_paths.get(&(contract_id.version.clone(), i as usize)) + report.source_paths.get(&SourceIdentifier::new( + i as usize, + contract_id.source_id.build_id.clone(), + )) }); let code = format!("{code:?}"); diff --git a/crates/forge/tests/cli/coverage.rs b/crates/forge/tests/cli/coverage.rs index c840a80362a0..6bcafcce0b37 100644 --- a/crates/forge/tests/cli/coverage.rs +++ b/crates/forge/tests/cli/coverage.rs @@ -1687,6 +1687,300 @@ contract AContract { "#]]); }); +forgetest!(test_coverage_caching, |prj, cmd| { + prj.insert_ds_test(); + prj.add_source( + "AContract.sol", + r#" +contract AContract { + int public i; + + function init() public { + i = 0; + } + + function foo() public { + i = 1; + } +} + "#, + ) + .unwrap(); + + prj.add_source( + "AContractTest.sol", + r#" +import "./test.sol"; +import {AContract} from "./AContract.sol"; + +contract AContractTest is DSTest { + AContract a; + + function setUp() public { + a = new AContract(); + a.init(); + } + + function testFoo() public { + a.foo(); + } +} + "#, + ) + .unwrap(); + + // forge build + cmd.arg("build").assert_success(); + + // forge coverage + // Assert 100% coverage (init function coverage called in setUp is accounted). + cmd.forge_fuse().arg("coverage").args(["--summary".to_string()]).assert_success().stdout_eq( + str![[r#" +... +| File | % Lines | % Statements | % Branches | % Funcs | +|-------------------|---------------|---------------|---------------|---------------| +| src/AContract.sol | 100.00% (2/2) | 100.00% (2/2) | 100.00% (0/0) | 100.00% (2/2) | +| Total | 100.00% (2/2) | 100.00% (2/2) | 100.00% (0/0) | 100.00% (2/2) | + +"#]], + ); + + // forge build - Should not compile the contracts again. + cmd.forge_fuse().arg("build").assert_success().stdout_eq( + r#"No files changed, compilation skipped +"#, + ); + + // forge coverage - Should not compile the contracts again. + cmd.forge_fuse().arg("coverage").args(["--summary".to_string()]).assert_success().stdout_eq( + str![[r#"No files changed, compilation skipped +... +| File | % Lines | % Statements | % Branches | % Funcs | +|-------------------|---------------|---------------|---------------|---------------| +| src/AContract.sol | 100.00% (2/2) | 100.00% (2/2) | 100.00% (0/0) | 100.00% (2/2) | +| Total | 100.00% (2/2) | 100.00% (2/2) | 100.00% (0/0) | 100.00% (2/2) | + +"#]], + ); +}); + +forgetest!(test_coverage_multi_solc_versions, |prj, cmd| { + prj.insert_ds_test(); + + let counter = r#" + pragma solidity 0.8.13; + contract Counter { + uint256 public number; + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } + } + "#; + let counter2 = r#" + pragma solidity 0.8.27; + contract Counter2 { + uint256 public number; + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } + } + "#; + + let counter_test = r#" + pragma solidity ^0.8.13; + import "./test.sol"; + import {Counter} from "./Counter.sol"; + + contract CounterTest is DSTest { + Counter public counter; + + function setUp() public { + counter = new Counter(); + counter.setNumber(0); + } + + function test_Increment() public { + counter.increment(); + assertEq(counter.number(), 1); + } + + function testFuzz_SetNumber(uint256 x) public { + counter.setNumber(x); + assertEq(counter.number(), x); + } + } + "#; + + let counter2_test = r#" + pragma solidity ^0.8.13; + import "./test.sol"; + import {Counter2} from "./Counter2.sol"; + + contract Counter2Test is DSTest { + Counter2 public counter; + + function setUp() public { + counter = new Counter2(); + counter.setNumber(0); + } + + function test_Increment() public { + counter.increment(); + assertEq(counter.number(), 1); + } + + function testFuzz_SetNumber(uint256 x) public { + counter.setNumber(x); + assertEq(counter.number(), x); + } + } + "#; + + prj.add_source("Counter.sol", counter).unwrap(); + prj.add_source("Counter2.sol", counter2).unwrap(); + prj.add_source("CounterTest.sol", counter_test).unwrap(); + prj.add_source("Counter2Test.sol", counter2_test).unwrap(); + + // no-cache + cmd.arg("coverage").args(["--summary".to_string()]).assert_success().stdout_eq(str![[ + r#"[COMPILING_FILES] with [SOLC_VERSION] +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +[SOLC_VERSION] [ELAPSED] +... +| File | % Lines | % Statements | % Branches | % Funcs | +|------------------|---------------|---------------|---------------|---------------| +| src/Counter.sol | 100.00% (2/2) | 100.00% (2/2) | 100.00% (0/0) | 100.00% (2/2) | +| src/Counter2.sol | 100.00% (2/2) | 100.00% (2/2) | 100.00% (0/0) | 100.00% (2/2) | +| Total | 100.00% (4/4) | 100.00% (4/4) | 100.00% (0/0) | 100.00% (4/4) | + +"# + ]]); + + // cache + cmd.forge_fuse().arg("coverage").args(["--summary".to_string()]).assert_success().stdout_eq( + str![[r#"No files changed, compilation skipped +... +| File | % Lines | % Statements | % Branches | % Funcs | +|------------------|---------------|---------------|---------------|---------------| +| src/Counter.sol | 100.00% (2/2) | 100.00% (2/2) | 100.00% (0/0) | 100.00% (2/2) | +| src/Counter2.sol | 100.00% (2/2) | 100.00% (2/2) | 100.00% (0/0) | 100.00% (2/2) | +| Total | 100.00% (4/4) | 100.00% (4/4) | 100.00% (0/0) | 100.00% (4/4) | + +"#]], + ); + + // Replace solc version in Counter2.sol + let counter2 = counter2.replace("0.8.27", "0.8.25"); + + prj.add_source("Counter2.sol", &counter2).unwrap(); + + // Should recompile Counter2.sol + cmd.forge_fuse().arg("coverage").args(["--summary".to_string()]).assert_success().stdout_eq( + str![[r#"[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +... +| File | % Lines | % Statements | % Branches | % Funcs | +|------------------|---------------|---------------|---------------|---------------| +| src/Counter.sol | 100.00% (2/2) | 100.00% (2/2) | 100.00% (0/0) | 100.00% (2/2) | +| src/Counter2.sol | 100.00% (2/2) | 100.00% (2/2) | 100.00% (0/0) | 100.00% (2/2) | +| Total | 100.00% (4/4) | 100.00% (4/4) | 100.00% (0/0) | 100.00% (4/4) | + +"#]], + ); +}); + +// checks that `clean` also works with the "out" value set in Config +// this test verifies that the coverage is preserved across compiler runs that may result in files +// with different source_id's +forgetest!(coverage_cache_across_compiler_runs, |prj, cmd| { + prj.add_source( + "A", + r#" +contract A { + function f() public pure returns (uint) { + return 1; + } +}"#, + ) + .unwrap(); + + prj.add_source( + "B", + r#" +contract B { + function f() public pure returns (uint) { + return 1; + } +}"#, + ) + .unwrap(); + + let a_test = prj + .add_test( + "A.t.sol", + r#" +import {A} from "../src/A.sol"; + +contract ATest { + function test() public { + A a = new A(); + a.f(); + } +} + "#, + ) + .unwrap(); + + prj.add_test( + "B.t.sol", + r#" + import {B} from "../src/B.sol"; + + contract BTest { + function test() public { + B a = new B(); + a.f(); + } + } + "#, + ) + .unwrap(); + + cmd.forge_fuse().arg("coverage").assert_success().stdout_eq(str![[r#" +... +Ran 2 test suites [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) +| File | % Lines | % Statements | % Branches | % Funcs | +|-----------|---------------|---------------|---------------|---------------| +| src/A.sol | 100.00% (1/1) | 100.00% (1/1) | 100.00% (0/0) | 100.00% (1/1) | +| src/B.sol | 100.00% (1/1) | 100.00% (1/1) | 100.00% (0/0) | 100.00% (1/1) | +| Total | 100.00% (2/2) | 100.00% (2/2) | 100.00% (0/0) | 100.00% (2/2) | + +"#]]); + + prj.add_test("A.t.sol", &format!("{} ", std::fs::read_to_string(a_test).unwrap())).unwrap(); + + cmd.forge_fuse().arg("coverage").assert_success().stdout_eq(str![[r#" +... +Ran 2 test suites [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) +| File | % Lines | % Statements | % Branches | % Funcs | +|-----------|---------------|---------------|---------------|---------------| +| src/A.sol | 100.00% (1/1) | 100.00% (1/1) | 100.00% (0/0) | 100.00% (1/1) | +| src/B.sol | 100.00% (1/1) | 100.00% (1/1) | 100.00% (0/0) | 100.00% (1/1) | +| Total | 100.00% (2/2) | 100.00% (2/2) | 100.00% (0/0) | 100.00% (2/2) | + +"#]]); +}); #[track_caller] fn assert_lcov(cmd: &mut TestCommand, data: impl IntoData) { cmd.args(["--report=lcov", "--report-file"]).assert_file(data.into_data());