diff --git a/crates/miden-testing/src/expected_error.rs b/crates/miden-testing/src/expected_error.rs new file mode 100644 index 0000000000..a633bc0098 --- /dev/null +++ b/crates/miden-testing/src/expected_error.rs @@ -0,0 +1,40 @@ +//! Matcher trait used by the `$expected` arm of `assert_execution_error!` +//! and `assert_transaction_executor_error!`. The built-in impl for +//! [`MasmError`] preserves the legacy behavior (compare against +//! `OperationError::FailedAssertion`). + +use core::fmt::Display; + +use miden_processor::ExecutionError; +use miden_processor::operation::OperationError; +use miden_protocol::errors::MasmError; + +/// Matcher for an expected [`ExecutionError`] shape. +/// `Display` is required so the macro can format a panic message on mismatch. +pub trait ExpectedExecutionError: Display { + fn matches(&self, actual: &ExecutionError) -> bool; +} + +/// Matches `FailedAssertion` with the same `err_code`. If the actual error +/// carries an `err_msg`, it must equal the constant's message; an absent +/// `err_msg` is accepted. +impl ExpectedExecutionError for MasmError { + fn matches(&self, actual: &ExecutionError) -> bool { + let ExecutionError::OperationError { + err: OperationError::FailedAssertion { err_code, err_msg }, + .. + } = actual + else { + return false; + }; + + if *err_code != self.code() { + return false; + } + + match err_msg { + Some(msg) => msg.as_ref() == self.message(), + None => true, + } + } +} diff --git a/crates/miden-testing/src/lib.rs b/crates/miden-testing/src/lib.rs index c48e162f1e..a1083a271e 100644 --- a/crates/miden-testing/src/lib.rs +++ b/crates/miden-testing/src/lib.rs @@ -23,6 +23,8 @@ pub mod asserts; pub mod executor; +pub mod expected_error; + mod mock_host; pub mod utils; diff --git a/crates/miden-testing/src/utils.rs b/crates/miden-testing/src/utils.rs index 07c29d8f8b..3d4ef3de7e 100644 --- a/crates/miden-testing/src/utils.rs +++ b/crates/miden-testing/src/utils.rs @@ -18,52 +18,90 @@ use rand::rngs::SmallRng; // HELPER MACROS // ================================================================================================ +/// Asserts that a `Result<_, ExecError>` failed as expected. +/// +/// Three forms: +/// - `..., matches [if ]` — matches the inner `ExecutionError` against ``. Use +/// this for variants other than `FailedAssertion`, or to assert on a specific `err_code`. +/// - `..., any` — succeeds on any `Err`. +/// - `..., $expected` — delegates to `ExpectedExecutionError::matches` (e.g. a `MasmError` error +/// code). #[macro_export] macro_rules! assert_execution_error { - ($execution_result:expr, $expected_err:expr) => { + ($execution_result:expr, matches $pat:pat $(if $guard:expr)? $(,)?) => { match $execution_result { - Err($crate::ExecError(miden_processor::ExecutionError::OperationError { label: _, source_file: _, err: miden_processor::operation::OperationError::FailedAssertion { err_code, err_msg } })) => { - if let Some(ref msg) = err_msg { - assert_eq!(msg.as_ref(), $expected_err.message(), "error messages did not match"); - } + Err($crate::ExecError($pat)) $(if $guard)? => {}, + Ok(_) => ::core::panic!("Execution was unexpectedly successful"), + Err(err) => ::core::panic!( + "Execution error did not match `{}`: {}", + ::core::stringify!($pat), + err, + ), + } + }; + + ($execution_result:expr, any $(,)?) => { + match $execution_result { + Err(_) => {}, + Ok(_) => ::core::panic!("Execution was unexpectedly successful"), + } + }; - assert_eq!( - err_code, $expected_err.code(), - "Execution failed on assertion with an unexpected error (Actual code: {}, msg: {}, Expected code: {}).", - err_code, err_msg.as_ref().map(|string| string.as_ref()).unwrap_or(""), $expected_err, - ); + ($execution_result:expr, $expected:expr $(,)?) => { + match $execution_result { + Err($crate::ExecError(actual)) => { + if !$crate::expected_error::ExpectedExecutionError::matches(&$expected, &actual) { + ::core::panic!( + "Execution error did not match expected `{}`: {}", + $expected, + actual, + ); + } }, - Ok(_) => panic!("Execution was unexpectedly successful"), - Err(err) => panic!("Execution error was not as expected: {err}"), + Ok(_) => ::core::panic!("Execution was unexpectedly successful"), } }; } +/// Same as [`assert_execution_error!`], but for `TransactionExecutorError`. +/// The `matches` and `$expected` arms unwrap +/// `TransactionProgramExecutionFailed(_)` and match against the inner +/// `ExecutionError`. #[macro_export] macro_rules! assert_transaction_executor_error { - ($execution_result:expr, $expected_err:expr) => { + ($execution_result:expr, matches $pat:pat $(if $guard:expr)? $(,)?) => { match $execution_result { - Err(miden_tx::TransactionExecutorError::TransactionProgramExecutionFailed( - miden_processor::ExecutionError::OperationError { - label: _, - source_file: _, - err: miden_processor::operation::OperationError::FailedAssertion { - err_code, - err_msg, - }, - }, - )) => { - if let Some(ref msg) = err_msg { - assert_eq!(msg.as_ref(), $expected_err.message(), "error messages did not match"); - } + Err(miden_tx::TransactionExecutorError::TransactionProgramExecutionFailed($pat)) + $(if $guard)? => {}, + Ok(_) => ::core::panic!("Execution was unexpectedly successful"), + Err(err) => ::core::panic!( + "Execution error did not match `{}`: {}", + ::core::stringify!($pat), + err, + ), + } + }; - assert_eq!( - err_code, $expected_err.code(), - "Execution failed on assertion with an unexpected error (Actual code: {}, msg: {}, Expected: {}).", - err_code, err_msg.as_ref().map(|string| string.as_ref()).unwrap_or(""), $expected_err); + ($execution_result:expr, any $(,)?) => { + match $execution_result { + Err(_) => {}, + Ok(_) => ::core::panic!("Execution was unexpectedly successful"), + } + }; + + ($execution_result:expr, $expected:expr $(,)?) => { + match $execution_result { + Err(miden_tx::TransactionExecutorError::TransactionProgramExecutionFailed(actual)) => { + if !$crate::expected_error::ExpectedExecutionError::matches(&$expected, &actual) { + ::core::panic!( + "Execution error did not match expected `{}`: {}", + $expected, + actual, + ); + } }, - Ok(_) => panic!("Execution was unexpectedly successful"), - Err(err) => panic!("Execution error was not as expected: {err}"), + Ok(_) => ::core::panic!("Execution was unexpectedly successful"), + Err(err) => ::core::panic!("Execution error was not as expected: {err}"), } }; } diff --git a/crates/miden-testing/tests/assertion_macros.rs b/crates/miden-testing/tests/assertion_macros.rs new file mode 100644 index 0000000000..f0d1f81b7a --- /dev/null +++ b/crates/miden-testing/tests/assertion_macros.rs @@ -0,0 +1,389 @@ +//! Tests for `assert_execution_error!` and `assert_transaction_executor_error!`. +//! +//! - Sync tests build errors directly to exercise the macro grammar (each arm + `#[should_panic]` +//! for failure paths). +//! - Async tests run small MASM programs on `FastProcessor` to cover real `ExecutionError` variants +//! end-to-end. + +extern crate alloc; + +use alloc::format; +use alloc::sync::Arc; + +use miden_assembly::{Assembler, DefaultSourceManager, SourceSpan}; +use miden_core_lib::CoreLibrary; +use miden_processor::advice::AdviceError; +use miden_processor::operation::OperationError; +use miden_processor::{ + DefaultHost, + ExecutionError, + ExecutionOptions, + ExecutionOutput, + FastProcessor, + Felt, + MemoryError, + StackInputs, +}; +use miden_testing::{ExecError, assert_execution_error, assert_transaction_executor_error}; +use miden_tx::TransactionExecutorError; + +// HELPERS +// ================================================================================================ + +fn op_error(err: OperationError) -> ExecutionError { + ExecutionError::OperationError { + label: SourceSpan::default(), + source_file: None, + err, + } +} + +fn failed_assertion(err_code: u64) -> ExecutionError { + op_error(OperationError::FailedAssertion { + err_code: Felt::new(err_code), + err_msg: None, + }) +} + +fn exec_err(err: ExecutionError) -> Result<(), ExecError> { + Err(ExecError::new(err)) +} + +fn tx_err(err: ExecutionError) -> Result<(), TransactionExecutorError> { + Err(TransactionExecutorError::TransactionProgramExecutionFailed(err)) +} + +/// Assembles `src` and runs it on a default `FastProcessor` with no advice +/// inputs and an empty stack. Returned errors are wrapped in [`ExecError`]. +async fn run_masm(src: &str) -> Result { + run_masm_with_options(src, ExecutionOptions::default()).await +} + +/// Same as [`run_masm`] but allows overriding [`ExecutionOptions`]. +async fn run_masm_with_options( + src: &str, + options: ExecutionOptions, +) -> Result { + let core_lib = CoreLibrary::default(); + let assembler = Assembler::new(Arc::new(DefaultSourceManager::default())) + .with_dynamic_library(&core_lib) + .expect("CoreLibrary should load"); + let program = assembler.assemble_program(src).expect("MASM should assemble"); + + let mut host = DefaultHost::default(); + host.load_library(core_lib.mast_forest()) + .expect("CoreLibrary mast forest should load"); + + let stack = StackInputs::new(&[]).expect("empty stack inputs are valid"); + FastProcessor::new(stack) + .with_options(options) + .execute(&program, &mut host) + .await + .map_err(ExecError::new) +} + +// EXECUTION-ERROR ASSERTION TESTS — direct construction +// ================================================================================================ + +// `matches` arm — outer ExecutionError variants +#[test] +fn assert_execution_error_matches_outer_variant() { + let r = exec_err(ExecutionError::CycleLimitExceeded(42)); + assert_execution_error!(r, matches ExecutionError::CycleLimitExceeded(_)); + + let r = exec_err(ExecutionError::OutputStackOverflow(7)); + assert_execution_error!(r, matches ExecutionError::OutputStackOverflow(_)); +} + +// `matches` arm — inner OperationError variants +#[test] +fn assert_execution_error_matches_inner_operation_variant() { + let r = exec_err(op_error(OperationError::DivideByZero)); + assert_execution_error!( + r, + matches ExecutionError::OperationError { err: OperationError::DivideByZero, .. } + ); + + let r = exec_err(op_error(OperationError::LogArgumentZero)); + assert_execution_error!( + r, + matches ExecutionError::OperationError { err: OperationError::LogArgumentZero, .. } + ); +} + +// `matches` arm — pattern guard on FailedAssertion err_code +#[test] +fn assert_execution_error_matches_with_guard() { + let r = exec_err(failed_assertion(0x1234)); + assert_execution_error!( + r, + matches ExecutionError::OperationError { + err: OperationError::FailedAssertion { err_code, .. }, + .. + } if err_code == Felt::new(0x1234) + ); +} + +// `any` arm — succeeds on any Err +#[test] +fn assert_execution_error_any_accepts_any_error() { + let r = exec_err(ExecutionError::OutputStackOverflow(7)); + assert_execution_error!(r, any); + + let r = exec_err(op_error(OperationError::DivideByZero)); + assert_execution_error!(r, any); +} + +#[test] +#[should_panic(expected = "Execution was unexpectedly successful")] +fn assert_execution_error_matches_panics_on_ok() { + let r: Result<(), ExecError> = Ok(()); + assert_execution_error!(r, matches ExecutionError::CycleLimitExceeded(_)); +} + +#[test] +#[should_panic(expected = "did not match")] +fn assert_execution_error_matches_panics_on_pattern_mismatch() { + let r = exec_err(ExecutionError::CycleLimitExceeded(1)); + assert_execution_error!(r, matches ExecutionError::OutputStackOverflow(_)); +} + +#[test] +#[should_panic(expected = "did not match")] +fn assert_execution_error_matches_panics_on_guard_mismatch() { + let r = exec_err(failed_assertion(0x1234)); + assert_execution_error!( + r, + matches ExecutionError::OperationError { + err: OperationError::FailedAssertion { err_code, .. }, + .. + } if err_code == Felt::new(0xdead) + ); +} + +#[test] +#[should_panic(expected = "Execution was unexpectedly successful")] +fn assert_execution_error_any_panics_on_ok() { + let r: Result<(), ExecError> = Ok(()); + assert_execution_error!(r, any); +} + +// TRANSACTION-EXECUTOR-ERROR ASSERTION TESTS — direct construction +// ================================================================================================ + +#[test] +fn assert_transaction_executor_error_matches_outer_variant() { + let r = tx_err(ExecutionError::CycleLimitExceeded(42)); + assert_transaction_executor_error!(r, matches ExecutionError::CycleLimitExceeded(_)); +} + +#[test] +fn assert_transaction_executor_error_matches_inner_operation_variant() { + let r = tx_err(op_error(OperationError::DivideByZero)); + assert_transaction_executor_error!( + r, + matches ExecutionError::OperationError { err: OperationError::DivideByZero, .. } + ); +} + +#[test] +fn assert_transaction_executor_error_matches_with_guard() { + let r = tx_err(failed_assertion(0xabcd)); + assert_transaction_executor_error!( + r, + matches ExecutionError::OperationError { + err: OperationError::FailedAssertion { err_code, .. }, + .. + } if err_code == Felt::new(0xabcd) + ); +} + +#[test] +fn assert_transaction_executor_error_any_accepts_any_error() { + let r = tx_err(ExecutionError::OutputStackOverflow(7)); + assert_transaction_executor_error!(r, any); +} + +#[test] +#[should_panic(expected = "did not match")] +fn assert_transaction_executor_error_matches_panics_on_pattern_mismatch() { + let r = tx_err(ExecutionError::CycleLimitExceeded(1)); + assert_transaction_executor_error!(r, matches ExecutionError::OutputStackOverflow(_)); +} + +#[test] +#[should_panic(expected = "Execution was unexpectedly successful")] +fn assert_transaction_executor_error_any_panics_on_ok() { + let r: Result<(), TransactionExecutorError> = Ok(()); + assert_transaction_executor_error!(r, any); +} + +// VM-DRIVEN VARIANT COVERAGE +// ================================================================================================ + +#[tokio::test] +async fn divide_by_zero() { + // 5 / 0 — top of stack is the divisor. + let r = run_masm("begin push.5 push.0 div end").await; + assert_execution_error!( + r, + matches ExecutionError::OperationError { + err: OperationError::DivideByZero, + .. + } + ); +} + +#[tokio::test] +async fn log_argument_zero() { + let r = run_masm("begin push.0 ilog2 end").await; + assert_execution_error!( + r, + matches ExecutionError::OperationError { + err: OperationError::LogArgumentZero, + .. + } + ); +} + +#[tokio::test] +async fn not_binary_value() { + let r = run_masm("begin push.2 push.1 and end").await; + assert_execution_error!( + r, + matches ExecutionError::OperationError { + err: OperationError::NotBinaryValue { .. }, + .. + } + ); +} + +#[tokio::test] +async fn not_binary_value_if() { + let r = run_masm("begin push.2 if.true push.0 drop else push.0 drop end end").await; + assert_execution_error!( + r, + matches ExecutionError::OperationError { + err: OperationError::NotBinaryValueIf { .. }, + .. + } + ); +} + +#[tokio::test] +async fn not_binary_value_loop() { + let r = run_masm("begin push.2 while.true push.0 end end").await; + assert_execution_error!( + r, + matches ExecutionError::OperationError { + err: OperationError::NotBinaryValueLoop { .. }, + .. + } + ); +} + +#[tokio::test] +async fn not_u32_values() { + let r = run_masm("begin push.4294967296 u32assert end").await; + assert_execution_error!( + r, + matches ExecutionError::OperationError { + err: OperationError::NotU32Values { .. }, + .. + } + ); +} + +#[tokio::test] +async fn vm_failed_assertion() { + let r = run_masm(r#"begin push.0 assert.err="custom failure" end"#).await; + assert_execution_error!( + r, + matches ExecutionError::OperationError { + err: OperationError::FailedAssertion { .. }, + .. + } + ); +} + +#[tokio::test] +async fn output_stack_overflow() { + // Default stack starts at depth 16; push 5 extras → 21 at end. + let r = run_masm("begin push.1 push.1 push.1 push.1 push.1 end").await; + assert_execution_error!(r, matches ExecutionError::OutputStackOverflow(_)); +} + +#[tokio::test] +async fn cycle_limit_exceeded() { + // Set max_cycles to MIN_TRACE_LEN (64); 100×push body trips it. + let body = "push.0 ".repeat(100); + let src = format!("begin {body} end"); + let options = ExecutionOptions::new(Some(64), 64, 4096, false, false) + .expect("max_cycles=64 satisfies MIN_TRACE_LEN"); + let r = run_masm_with_options(&src, options).await; + assert_execution_error!(r, matches ExecutionError::CycleLimitExceeded(_)); +} + +#[tokio::test] +async fn memory_unaligned_word_access() { + let r = run_masm("begin push.1 mem_loadw_be end").await; + assert_execution_error!( + r, + matches ExecutionError::MemoryError { + err: MemoryError::UnalignedWordAccess { .. }, + .. + } + ); +} + +#[tokio::test] +async fn advice_error_empty_stack() { + let r = run_masm("begin adv_push.1 end").await; + assert_execution_error!( + r, + matches ExecutionError::AdviceError { + err: AdviceError::StackReadFailed, + .. + } + ); +} + +#[tokio::test] +async fn invalid_stack_depth_on_return() { + let src = " + proc bad + push.1 push.2 push.3 + end + begin + call.bad + end + "; + let r = run_masm(src).await; + assert_execution_error!( + r, + matches ExecutionError::OperationError { + err: OperationError::InvalidStackDepthOnReturn { .. }, + .. + } + ); +} + +#[tokio::test] +async fn event_error_unregistered() { + let src = r#" + const MY_EVENT=event("miden::testing::asserts::unregistered") + begin + emit.MY_EVENT + end + "#; + let r = run_masm(src).await; + assert_execution_error!(r, matches ExecutionError::EventError { .. }); +} + +#[tokio::test] +async fn procedure_not_found_via_dynexec() { + // `dynexec` looks up the digest popped from the stack in the host's MAST + // forest; a digest of zeros is not a real procedure root. + let r = run_masm("begin push.0.0.0.0 dynexec end").await; + assert_execution_error!(r, matches ExecutionError::ProcedureNotFound { .. }); +}