Skip to content

Commit 2cdbfac

Browse files
authored
feat: add support for test skip reasons (#8858)
1 parent c6d342d commit 2cdbfac

File tree

19 files changed

+308
-163
lines changed

19 files changed

+308
-163
lines changed

crates/cheatcodes/assets/cheatcodes.json

Lines changed: 22 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/cheatcodes/spec/src/vm.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -843,10 +843,14 @@ interface Vm {
843843
#[cheatcode(group = Testing, safety = Unsafe)]
844844
function expectSafeMemoryCall(uint64 min, uint64 max) external;
845845

846-
/// Marks a test as skipped. Must be called at the top of the test.
846+
/// Marks a test as skipped. Must be called at the top level of a test.
847847
#[cheatcode(group = Testing, safety = Unsafe)]
848848
function skip(bool skipTest) external;
849849

850+
/// Marks a test as skipped with a reason. Must be called at the top level of a test.
851+
#[cheatcode(group = Testing, safety = Unsafe)]
852+
function skip(bool skipTest, string calldata reason) external;
853+
850854
/// Asserts that the given condition is true.
851855
#[cheatcode(group = Testing, safety = Safe)]
852856
function assertTrue(bool condition) external pure;

crates/cheatcodes/src/test.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,14 +70,21 @@ impl Cheatcode for sleepCall {
7070
}
7171
}
7272

73-
impl Cheatcode for skipCall {
73+
impl Cheatcode for skip_0Call {
7474
fn apply_stateful<DB: DatabaseExt>(&self, ccx: &mut CheatsCtxt<DB>) -> Result {
7575
let Self { skipTest } = *self;
76-
if skipTest {
76+
skip_1Call { skipTest, reason: String::new() }.apply_stateful(ccx)
77+
}
78+
}
79+
80+
impl Cheatcode for skip_1Call {
81+
fn apply_stateful<DB: DatabaseExt>(&self, ccx: &mut CheatsCtxt<DB>) -> Result {
82+
let Self { skipTest, reason } = self;
83+
if *skipTest {
7784
// Skip should not work if called deeper than at test level.
7885
// Since we're not returning the magic skip bytes, this will cause a test failure.
7986
ensure!(ccx.ecx.journaled_state.depth() <= 1, "`skip` can only be used at test level");
80-
Err(MAGIC_SKIP.into())
87+
Err([MAGIC_SKIP, reason.as_bytes()].concat().into())
8188
} else {
8289
Ok(Default::default())
8390
}

crates/evm/core/src/constants.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ pub const TEST_CONTRACT_ADDRESS: Address = address!("b4c79daB8f259C7Aee6E5b2Aa72
3434
/// Magic return value returned by the `assume` cheatcode.
3535
pub const MAGIC_ASSUME: &[u8] = b"FOUNDRY::ASSUME";
3636

37-
/// Magic return value returned by the `skip` cheatcode.
37+
/// Magic return value returned by the `skip` cheatcode. Optionally appended with a reason.
3838
pub const MAGIC_SKIP: &[u8] = b"FOUNDRY::SKIP";
3939

4040
/// The address that deploys the default CREATE2 deployer contract.

crates/evm/core/src/decode.rs

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,39 @@ use foundry_common::SELECTOR_LEN;
99
use itertools::Itertools;
1010
use revm::interpreter::InstructionResult;
1111
use rustc_hash::FxHashMap;
12-
use std::sync::OnceLock;
12+
use std::{fmt, sync::OnceLock};
13+
14+
/// A skip reason.
15+
#[derive(Clone, Debug, PartialEq, Eq)]
16+
pub struct SkipReason(pub Option<String>);
17+
18+
impl SkipReason {
19+
/// Decodes a skip reason, if any.
20+
pub fn decode(raw_result: &[u8]) -> Option<Self> {
21+
raw_result.strip_prefix(crate::constants::MAGIC_SKIP).map(|reason| {
22+
let reason = String::from_utf8_lossy(reason).into_owned();
23+
Self((!reason.is_empty()).then_some(reason))
24+
})
25+
}
26+
27+
/// Decodes a skip reason from a string that was obtained by formatting `Self`.
28+
///
29+
/// This is a hack to support re-decoding a skip reason in proptest.
30+
pub fn decode_self(s: &str) -> Option<Self> {
31+
s.strip_prefix("skipped").map(|rest| Self(rest.strip_prefix(": ").map(ToString::to_string)))
32+
}
33+
}
34+
35+
impl fmt::Display for SkipReason {
36+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37+
f.write_str("skipped")?;
38+
if let Some(reason) = &self.0 {
39+
f.write_str(": ")?;
40+
f.write_str(reason)?;
41+
}
42+
Ok(())
43+
}
44+
}
1345

1446
/// Decode a set of logs, only returning logs from DSTest logging events and Hardhat's `console.log`
1547
pub fn decode_console_logs(logs: &[Log]) -> Vec<String> {
@@ -120,9 +152,8 @@ impl RevertDecoder {
120152
};
121153
}
122154

123-
if err == crate::constants::MAGIC_SKIP {
124-
// Also used in forge fuzz runner
125-
return Some("SKIPPED".to_string());
155+
if let Some(reason) = SkipReason::decode(err) {
156+
return Some(reason.to_string());
126157
}
127158

128159
// Solidity's `Error(string)` or `Panic(uint256)`
@@ -177,11 +208,17 @@ impl RevertDecoder {
177208
}
178209

179210
// Generic custom error.
180-
Some(format!(
181-
"custom error {}:{}",
182-
hex::encode(selector),
183-
std::str::from_utf8(data).map_or_else(|_| trimmed_hex(data), String::from)
184-
))
211+
Some({
212+
let mut s = format!("custom error {}", hex::encode_prefixed(selector));
213+
if !data.is_empty() {
214+
s.push_str(": ");
215+
match std::str::from_utf8(data) {
216+
Ok(data) => s.push_str(data),
217+
Err(_) => s.push_str(&trimmed_hex(data)),
218+
}
219+
}
220+
s
221+
})
185222
}
186223
}
187224

@@ -194,7 +231,7 @@ fn trimmed_hex(s: &[u8]) -> String {
194231
"{}…{} ({} bytes)",
195232
&hex::encode(&s[..n / 2]),
196233
&hex::encode(&s[s.len() - n / 2..]),
197-
s.len()
234+
s.len(),
198235
)
199236
}
200237
}

crates/evm/evm/src/executors/fuzz/mod.rs

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ use alloy_primitives::{Address, Bytes, Log, U256};
55
use eyre::Result;
66
use foundry_common::evm::Breakpoints;
77
use foundry_config::FuzzConfig;
8-
use foundry_evm_core::{constants::MAGIC_ASSUME, decode::RevertDecoder};
8+
use foundry_evm_core::{
9+
constants::MAGIC_ASSUME,
10+
decode::{RevertDecoder, SkipReason},
11+
};
912
use foundry_evm_coverage::HitMaps;
1013
use foundry_evm_fuzz::{
1114
strategies::{fuzz_calldata, fuzz_calldata_from_state, EvmFuzzState},
@@ -131,9 +134,8 @@ impl FuzzedExecutor {
131134
}) => {
132135
// We cannot use the calldata returned by the test runner in `TestError::Fail`,
133136
// since that input represents the last run case, which may not correspond with
134-
// our failure - when a fuzz case fails, proptest will try
135-
// to run at least one more case to find a minimal failure
136-
// case.
137+
// our failure - when a fuzz case fails, proptest will try to run at least one
138+
// more case to find a minimal failure case.
137139
let reason = rd.maybe_decode(&outcome.1.result, Some(status));
138140
execution_data.borrow_mut().logs.extend(outcome.1.logs.clone());
139141
execution_data.borrow_mut().counterexample = outcome;
@@ -157,6 +159,7 @@ impl FuzzedExecutor {
157159
first_case: fuzz_result.first_case.unwrap_or_default(),
158160
gas_by_case: fuzz_result.gas_by_case,
159161
success: run_result.is_ok(),
162+
skipped: false,
160163
reason: None,
161164
counterexample: None,
162165
logs: fuzz_result.logs,
@@ -168,20 +171,22 @@ impl FuzzedExecutor {
168171
};
169172

170173
match run_result {
171-
// Currently the only operation that can trigger proptest global rejects is the
172-
// `vm.assume` cheatcode, thus we surface this info to the user when the fuzz test
173-
// aborts due to too many global rejects, making the error message more actionable.
174-
Err(TestError::Abort(reason)) if reason.message() == "Too many global rejects" => {
175-
result.reason = Some(
176-
FuzzError::TooManyRejects(self.runner.config().max_global_rejects).to_string(),
177-
);
178-
}
174+
Ok(()) => {}
179175
Err(TestError::Abort(reason)) => {
180-
result.reason = Some(reason.to_string());
176+
let msg = reason.message();
177+
// Currently the only operation that can trigger proptest global rejects is the
178+
// `vm.assume` cheatcode, thus we surface this info to the user when the fuzz test
179+
// aborts due to too many global rejects, making the error message more actionable.
180+
result.reason = if msg == "Too many global rejects" {
181+
let error = FuzzError::TooManyRejects(self.runner.config().max_global_rejects);
182+
Some(error.to_string())
183+
} else {
184+
Some(msg.to_string())
185+
};
181186
}
182187
Err(TestError::Fail(reason, _)) => {
183188
let reason = reason.to_string();
184-
result.reason = if reason.is_empty() { None } else { Some(reason) };
189+
result.reason = (!reason.is_empty()).then_some(reason);
185190

186191
let args = if let Some(data) = calldata.get(4..) {
187192
func.abi_decode_input(data, false).unwrap_or_default()
@@ -193,7 +198,13 @@ impl FuzzedExecutor {
193198
BaseCounterExample::from_fuzz_call(calldata, args, call.traces),
194199
));
195200
}
196-
_ => {}
201+
}
202+
203+
if let Some(reason) = &result.reason {
204+
if let Some(reason) = SkipReason::decode_self(reason) {
205+
result.skipped = true;
206+
result.reason = reason.0;
207+
}
197208
}
198209

199210
state.log_stats();
@@ -212,9 +223,9 @@ impl FuzzedExecutor {
212223
let mut call = self
213224
.executor
214225
.call_raw(self.sender, address, calldata.clone(), U256::ZERO)
215-
.map_err(|_| TestCaseError::fail(FuzzError::FailedContractCall))?;
226+
.map_err(|e| TestCaseError::fail(e.to_string()))?;
216227

217-
// When the `assume` cheatcode is called it returns a special string
228+
// Handle `vm.assume`.
218229
if call.result.as_ref() == MAGIC_ASSUME {
219230
return Err(TestCaseError::reject(FuzzError::AssumeReject))
220231
}

crates/evm/evm/src/executors/invariant/error.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,9 @@ impl InvariantFuzzError {
4040
Self::BrokenInvariant(case_data) | Self::Revert(case_data) => {
4141
(!case_data.revert_reason.is_empty()).then(|| case_data.revert_reason.clone())
4242
}
43-
Self::MaxAssumeRejects(allowed) => Some(format!(
44-
"The `vm.assume` cheatcode rejected too many inputs ({allowed} allowed)"
45-
)),
43+
Self::MaxAssumeRejects(allowed) => {
44+
Some(format!("`vm.assume` rejected too many inputs ({allowed} allowed)"))
45+
}
4646
}
4747
}
4848
}

crates/evm/evm/src/executors/mod.rs

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ use foundry_evm_core::{
1919
CALLER, CHEATCODE_ADDRESS, CHEATCODE_CONTRACT_HASH, DEFAULT_CREATE2_DEPLOYER,
2020
DEFAULT_CREATE2_DEPLOYER_CODE, DEFAULT_CREATE2_DEPLOYER_DEPLOYER,
2121
},
22-
decode::RevertDecoder,
22+
decode::{RevertDecoder, SkipReason},
2323
utils::StateChangeset,
2424
};
2525
use foundry_evm_coverage::HitMaps;
@@ -649,15 +649,15 @@ impl std::ops::DerefMut for ExecutionErr {
649649

650650
#[derive(Debug, thiserror::Error)]
651651
pub enum EvmError {
652-
/// Error which occurred during execution of a transaction
652+
/// Error which occurred during execution of a transaction.
653653
#[error(transparent)]
654654
Execution(#[from] Box<ExecutionErr>),
655-
/// Error which occurred during ABI encoding/decoding
655+
/// Error which occurred during ABI encoding/decoding.
656656
#[error(transparent)]
657-
AbiError(#[from] alloy_dyn_abi::Error),
658-
/// Error caused which occurred due to calling the skip() cheatcode.
659-
#[error("Skipped")]
660-
SkipError,
657+
Abi(#[from] alloy_dyn_abi::Error),
658+
/// Error caused which occurred due to calling the `skip` cheatcode.
659+
#[error("{_0}")]
660+
Skip(SkipReason),
661661
/// Any other error.
662662
#[error(transparent)]
663663
Eyre(#[from] eyre::Error),
@@ -671,7 +671,7 @@ impl From<ExecutionErr> for EvmError {
671671

672672
impl From<alloy_sol_types::Error> for EvmError {
673673
fn from(err: alloy_sol_types::Error) -> Self {
674-
Self::AbiError(err.into())
674+
Self::Abi(err.into())
675675
}
676676
}
677677

@@ -769,8 +769,8 @@ impl Default for RawCallResult {
769769
impl RawCallResult {
770770
/// Converts the result of the call into an `EvmError`.
771771
pub fn into_evm_error(self, rd: Option<&RevertDecoder>) -> EvmError {
772-
if self.result[..] == crate::constants::MAGIC_SKIP[..] {
773-
return EvmError::SkipError;
772+
if let Some(reason) = SkipReason::decode(&self.result) {
773+
return EvmError::Skip(reason);
774774
}
775775
let reason = rd.unwrap_or_default().decode(&self.result, Some(self.exit_reason));
776776
EvmError::Execution(Box::new(self.into_execution_error(reason)))

crates/evm/fuzz/src/error.rs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,13 @@
1-
//! errors related to fuzz tests
1+
//! Errors related to fuzz tests.
2+
23
use proptest::test_runner::Reason;
34

45
/// Possible errors when running fuzz tests
56
#[derive(Debug, thiserror::Error)]
67
pub enum FuzzError {
7-
#[error("Couldn't call unknown contract")]
8-
UnknownContract,
9-
#[error("Failed contract call")]
10-
FailedContractCall,
118
#[error("`vm.assume` reject")]
129
AssumeReject,
13-
#[error("The `vm.assume` cheatcode rejected too many inputs ({0} allowed)")]
10+
#[error("`vm.assume` rejected too many inputs ({0} allowed)")]
1411
TooManyRejects(u32),
1512
}
1613

crates/evm/fuzz/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,8 @@ pub struct FuzzTestResult {
151151
/// properly, or that there was a revert and that the test was expected to fail
152152
/// (prefixed with `testFail`)
153153
pub success: bool,
154+
/// Whether the test case was skipped. `reason` will contain the skip reason, if any.
155+
pub skipped: bool,
154156

155157
/// If there was a revert, this field will be populated. Note that the test can
156158
/// still be successful (i.e self.success == true) when it's expected to fail.

0 commit comments

Comments
 (0)