diff --git a/forc-test/src/execute.rs b/forc-test/src/execute.rs new file mode 100644 index 00000000000..417bfdbfdba --- /dev/null +++ b/forc-test/src/execute.rs @@ -0,0 +1,178 @@ +use crate::setup::TestSetup; +use crate::TestResult; +use crate::TEST_METADATA_SEED; +use forc_pkg::PkgTestEntry; +use fuel_tx::{self as tx, output::contract::Contract, Chargeable, Finalizable}; +use fuel_vm::error::InterpreterError; +use fuel_vm::{ + self as vm, + checked_transaction::builder::TransactionBuilderExt, + interpreter::{Interpreter, NotSupportedEcal}, + prelude::{Instruction, SecretKey}, + storage::MemoryStorage, +}; +use rand::{Rng, SeedableRng}; + +/// An interface for executing a test within a VM [Interpreter] instance. +#[derive(Debug)] +pub struct TestExecutor { + pub interpreter: Interpreter, + tx_builder: tx::TransactionBuilder, + test_entry: PkgTestEntry, + name: String, +} + +impl TestExecutor { + pub fn new( + bytecode: &[u8], + test_offset: u32, + test_setup: TestSetup, + test_entry: &PkgTestEntry, + name: String, + ) -> Self { + let storage = test_setup.storage().clone(); + + // Patch the bytecode to jump to the relevant test. + let bytecode = patch_test_bytecode(bytecode, test_offset).into_owned(); + + // Create a transaction to execute the test function. + let script_input_data = vec![]; + let rng = &mut rand::rngs::StdRng::seed_from_u64(TEST_METADATA_SEED); + + // Prepare the transaction metadata. + let secret_key = SecretKey::random(rng); + let utxo_id = rng.gen(); + let amount = 1; + let maturity = 1.into(); + let asset_id = rng.gen(); + let tx_pointer = rng.gen(); + + let mut tx_builder = tx::TransactionBuilder::script(bytecode, script_input_data) + .add_unsigned_coin_input( + secret_key, + utxo_id, + amount, + asset_id, + tx_pointer, + 0u32.into(), + ) + .maturity(maturity) + .clone(); + + let mut output_index = 1; + // Insert contract ids into tx input + for contract_id in test_setup.contract_ids() { + tx_builder + .add_input(tx::Input::contract( + tx::UtxoId::new(tx::Bytes32::zeroed(), 0), + tx::Bytes32::zeroed(), + tx::Bytes32::zeroed(), + tx::TxPointer::new(0u32.into(), 0), + contract_id, + )) + .add_output(tx::Output::Contract(Contract { + input_index: output_index, + balance_root: fuel_tx::Bytes32::zeroed(), + state_root: tx::Bytes32::zeroed(), + })); + output_index += 1; + } + let consensus_params = tx_builder.get_params().clone(); + + // Temporarily finalize to calculate `script_gas_limit` + let tmp_tx = tx_builder.clone().finalize(); + // Get `max_gas` used by everything except the script execution. Add `1` because of rounding. + let max_gas = + tmp_tx.max_gas(consensus_params.gas_costs(), consensus_params.fee_params()) + 1; + // Increase `script_gas_limit` to the maximum allowed value. + tx_builder.script_gas_limit(consensus_params.tx_params().max_gas_per_tx - max_gas); + + TestExecutor { + interpreter: Interpreter::with_storage(storage, consensus_params.into()), + tx_builder, + test_entry: test_entry.clone(), + name, + } + } + + pub fn execute(&mut self) -> anyhow::Result { + let block_height = (u32::MAX >> 1).into(); + let start = std::time::Instant::now(); + let transition = self + .interpreter + .transact(self.tx_builder.finalize_checked(block_height)) + .map_err(|err: InterpreterError<_>| anyhow::anyhow!(err))?; + let duration = start.elapsed(); + let state = *transition.state(); + let receipts = transition.receipts().to_vec(); + + let gas_used = *receipts + .iter() + .find_map(|receipt| match receipt { + tx::Receipt::ScriptResult { gas_used, .. } => Some(gas_used), + _ => None, + }) + .ok_or_else(|| anyhow::anyhow!("missing used gas information from test execution"))?; + + // Only retain `Log` and `LogData` receipts. + let logs = receipts + .into_iter() + .filter(|receipt| { + matches!(receipt, tx::Receipt::Log { .. }) + || matches!(receipt, tx::Receipt::LogData { .. }) + }) + .collect(); + + let span = self.test_entry.span.clone(); + let file_path = self.test_entry.file_path.clone(); + let condition = self.test_entry.pass_condition.clone(); + let name = self.name.clone(); + Ok(TestResult { + name, + file_path, + duration, + span, + state, + condition, + logs, + gas_used, + }) + } +} + +/// Given some bytecode and an instruction offset for some test's desired entry point, patch the +/// bytecode with a `JI` (jump) instruction to jump to the desired test. +/// +/// We want to splice in the `JI` only after the initial data section setup is complete, and only +/// if the entry point doesn't begin exactly after the data section setup. +/// +/// The following is how the beginning of the bytecode is laid out: +/// +/// ```ignore +/// [0] ji i4 ; Jumps to the data section setup. +/// [1] noop +/// [2] DATA_SECTION_OFFSET[0..32] +/// [3] DATA_SECTION_OFFSET[32..64] +/// [4] lw $ds $is 1 ; The data section setup, i.e. where the first ji lands. +/// [5] add $$ds $$ds $is +/// [6] ; This is where we want to jump from to our test code! +/// ``` +fn patch_test_bytecode(bytecode: &[u8], test_offset: u32) -> std::borrow::Cow<[u8]> { + // TODO: Standardize this or add metadata to bytecode. + const PROGRAM_START_INST_OFFSET: u32 = 6; + const PROGRAM_START_BYTE_OFFSET: usize = PROGRAM_START_INST_OFFSET as usize * Instruction::SIZE; + + // If our desired entry point is the program start, no need to jump. + if test_offset == PROGRAM_START_INST_OFFSET { + return std::borrow::Cow::Borrowed(bytecode); + } + + // Create the jump instruction and splice it into the bytecode. + let ji = vm::fuel_asm::op::ji(test_offset); + let ji_bytes = ji.to_bytes(); + let start = PROGRAM_START_BYTE_OFFSET; + let end = start + ji_bytes.len(); + let mut patched = bytecode.to_vec(); + patched.splice(start..end, ji_bytes); + std::borrow::Cow::Owned(patched) +} diff --git a/forc-test/src/lib.rs b/forc-test/src/lib.rs index efe8b51815a..0aa8efe0734 100644 --- a/forc-test/src/lib.rs +++ b/forc-test/src/lib.rs @@ -1,8 +1,15 @@ +pub mod execute; +pub mod setup; + +use crate::execute::TestExecutor; +use crate::setup::{ + ContractDeploymentSetup, ContractTestSetup, DeploymentSetup, ScriptTestSetup, TestSetup, +}; use forc_pkg as pkg; use fuel_abi_types::error_codes::ErrorSignal; use fuel_tx as tx; use fuel_vm::checked_transaction::builder::TransactionBuilderExt; -use fuel_vm::{self as vm, fuel_asm, prelude::Instruction}; +use fuel_vm::{self as vm}; use pkg::TestPassCondition; use pkg::{Built, BuiltPackage}; use rand::{Rng, SeedableRng}; @@ -10,8 +17,6 @@ use rayon::prelude::*; use std::{collections::HashMap, fs, path::PathBuf, sync::Arc}; use sway_core::BuildTarget; use sway_types::Span; -use tx::output::contract::Contract; -use tx::{Chargeable, Finalizable}; use vm::prelude::SecretKey; /// The result of a `forc test` invocation. @@ -117,31 +122,6 @@ pub enum PackageWithDeploymentToTest { Contract(ContractToTest), } -/// Required test setup for package types that requires a deployment. -#[derive(Debug)] -enum DeploymentSetup { - Script(ScriptTestSetup), - Contract(ContractTestSetup), -} - -impl DeploymentSetup { - /// Returns the storage for this test setup - fn storage(&self) -> &vm::storage::MemoryStorage { - match self { - DeploymentSetup::Script(script_setup) => &script_setup.storage, - DeploymentSetup::Contract(contract_setup) => &contract_setup.storage, - } - } - - /// Return the root contract id if this is a contract setup. - fn root_contract_id(&self) -> Option { - match self { - DeploymentSetup::Script(_) => None, - DeploymentSetup::Contract(contract_setup) => Some(contract_setup.root_contract_id), - } - } -} - /// The set of options provided to the `test` function. #[derive(Default, Clone)] pub struct Opts { @@ -176,69 +156,6 @@ pub struct TestPrintOpts { pub print_logs: bool, } -/// The storage and the contract id (if a contract is being tested) for a test. -#[derive(Debug)] -enum TestSetup { - WithDeployment(DeploymentSetup), - WithoutDeployment(vm::storage::MemoryStorage), -} - -impl TestSetup { - /// Returns the storage for this test setup - fn storage(&self) -> &vm::storage::MemoryStorage { - match self { - TestSetup::WithDeployment(deployment_setup) => deployment_setup.storage(), - TestSetup::WithoutDeployment(storage) => storage, - } - } - - /// Produces an iterator yielding contract ids of contract dependencies for this test setup. - fn contract_dependency_ids(&self) -> impl Iterator + '_ { - match self { - TestSetup::WithDeployment(deployment_setup) => match deployment_setup { - DeploymentSetup::Script(script_setup) => { - script_setup.contract_dependency_ids.iter() - } - DeploymentSetup::Contract(contract_setup) => { - contract_setup.contract_dependency_ids.iter() - } - }, - TestSetup::WithoutDeployment(_) => [].iter(), - } - } - - /// Return the root contract id if this is a contract setup. - fn root_contract_id(&self) -> Option { - match self { - TestSetup::WithDeployment(deployment_setup) => deployment_setup.root_contract_id(), - TestSetup::WithoutDeployment(_) => None, - } - } - - /// Produces an iterator yielding all contract ids required to be included in the transaction - /// for this test setup. - fn contract_ids(&self) -> impl Iterator + '_ { - self.contract_dependency_ids() - .cloned() - .chain(self.root_contract_id()) - } -} - -/// The data collected to test a contract. -#[derive(Debug)] -struct ContractTestSetup { - storage: vm::storage::MemoryStorage, - contract_dependency_ids: Vec, - root_contract_id: tx::ContractId, -} - -/// The data collected to test a script. -#[derive(Debug)] -struct ScriptTestSetup { - storage: vm::storage::MemoryStorage, - contract_dependency_ids: Vec, -} - impl TestedPackage { pub fn tests_passed(&self) -> bool { self.tests.iter().all(|test| test.passed()) @@ -412,64 +329,42 @@ impl<'a> PackageTests { .bytecode .entries .par_iter() - .filter_map(|entry| entry.kind.test().map(|test| (entry, test))) - .filter(|(entry, _)| { - // If a test filter is specified, only the tests containing the filter phrase in - // their name are going to be executed. - match &test_filter { - Some(filter) => filter.filter(&entry.finalized.fn_name), - None => true, + .filter_map(|entry| { + if let Some(test_entry) = entry.kind.test() { + // If a test filter is specified, only the tests containing the filter phrase in + // their name are going to be executed. + let name = entry.finalized.fn_name.clone(); + if let Some(filter) = test_filter { + if !filter.filter(&name) { + return None; + } + } + return Some((entry, test_entry)); } + None }) .map(|(entry, test_entry)| { + // Execute the test and return the result. let offset = u32::try_from(entry.finalized.imm) .expect("test instruction offset out of range"); let name = entry.finalized.fn_name.clone(); let test_setup = self.setup()?; - let (state, duration, receipts) = - exec_test(&pkg_with_tests.bytecode.bytes, offset, test_setup); - - let gas_used = *receipts - .iter() - .find_map(|receipt| match receipt { - tx::Receipt::ScriptResult { gas_used, .. } => Some(gas_used), - _ => None, - }) - .ok_or_else(|| { - anyhow::anyhow!("missing used gas information from test execution") - })?; - - // Only retain `Log` and `LogData` receipts. - let logs = receipts - .into_iter() - .filter(|receipt| { - matches!(receipt, fuel_tx::Receipt::Log { .. }) - || matches!(receipt, fuel_tx::Receipt::LogData { .. }) - }) - .collect(); - - let span = test_entry.span.clone(); - let file_path = test_entry.file_path.clone(); - let condition = test_entry.pass_condition.clone(); - Ok(TestResult { + TestExecutor::new( + &pkg_with_tests.bytecode.bytes, + offset, + test_setup, + test_entry, name, - file_path, - duration, - span, - state, - condition, - logs, - gas_used, - }) + ) + .execute() }) .collect::>() })?; - let tested_pkg = TestedPackage { + Ok(TestedPackage { built: Box::new(pkg_with_tests.clone()), tests, - }; - Ok(tested_pkg) + }) } /// Setup the storage for a test and returns a contract id for testing contracts. @@ -528,7 +423,7 @@ impl TestResult { } } - /// Return the revert code for this `TestResult` if the test is reverted. + /// Return the revert code for this [TestResult] if the test is reverted. pub fn revert_code(&self) -> Option { match self.state { vm::state::ProgramState::Revert(revert_code) => Some(revert_code), @@ -536,7 +431,7 @@ impl TestResult { } } - /// Return a `ErrorSignal` for this `TestResult` if the test is failed to pass. + /// Return an [ErrorSignal] for this [TestResult] if the test is failed to pass. pub fn error_signal(&self) -> anyhow::Result { let revert_code = self.revert_code().ok_or_else(|| { anyhow::anyhow!("there is no revert code to convert to `ErrorSignal`") @@ -544,7 +439,7 @@ impl TestResult { ErrorSignal::try_from_revert_code(revert_code).map_err(|e| anyhow::anyhow!(e)) } - /// Return `TestDetails` from the span of the function declaring this test. + /// Return [TestDetails] from the span of the function declaring this test. pub fn details(&self) -> anyhow::Result { let span_start = self.span.start(); let file_str = fs::read_to_string(&*self.file_path)?; @@ -658,9 +553,6 @@ pub fn build(opts: Opts) -> anyhow::Result { BuiltTests::from_built(built, &member_contract_dependencies) } -/// Result of preparing a deployment transaction setup for a contract. -type ContractDeploymentSetup = (tx::ContractId, vm::checked_transaction::Checked); - /// Deploys the provided contract and returns an interpreter instance ready to be used in test /// executions with deployed contract. fn deployment_transaction( @@ -722,123 +614,6 @@ fn run_tests( } } -/// Given some bytecode and an instruction offset for some test's desired entry point, patch the -/// bytecode with a `JI` (jump) instruction to jump to the desired test. -/// -/// We want to splice in the `JI` only after the initial data section setup is complete, and only -/// if the entry point doesn't begin exactly after the data section setup. -/// -/// The following is how the beginning of the bytecode is laid out: -/// -/// ```ignore -/// [0] ji i4 ; Jumps to the data section setup. -/// [1] noop -/// [2] DATA_SECTION_OFFSET[0..32] -/// [3] DATA_SECTION_OFFSET[32..64] -/// [4] lw $ds $is 1 ; The data section setup, i.e. where the first ji lands. -/// [5] add $$ds $$ds $is -/// [6] ; This is where we want to jump from to our test code! -/// ``` -fn patch_test_bytecode(bytecode: &[u8], test_offset: u32) -> std::borrow::Cow<[u8]> { - // TODO: Standardize this or add metadata to bytecode. - const PROGRAM_START_INST_OFFSET: u32 = 6; - const PROGRAM_START_BYTE_OFFSET: usize = PROGRAM_START_INST_OFFSET as usize * Instruction::SIZE; - - // If our desired entry point is the program start, no need to jump. - if test_offset == PROGRAM_START_INST_OFFSET { - return std::borrow::Cow::Borrowed(bytecode); - } - - // Create the jump instruction and splice it into the bytecode. - let ji = fuel_asm::op::ji(test_offset); - let ji_bytes = ji.to_bytes(); - let start = PROGRAM_START_BYTE_OFFSET; - let end = start + ji_bytes.len(); - let mut patched = bytecode.to_vec(); - patched.splice(start..end, ji_bytes); - std::borrow::Cow::Owned(patched) -} - -// Execute the test whose entry point is at the given instruction offset as if it were a script. -fn exec_test( - bytecode: &[u8], - test_offset: u32, - test_setup: TestSetup, -) -> ( - vm::state::ProgramState, - std::time::Duration, - Vec, -) { - let storage = test_setup.storage().clone(); - - // Patch the bytecode to jump to the relevant test. - let bytecode = patch_test_bytecode(bytecode, test_offset).into_owned(); - - // Create a transaction to execute the test function. - let script_input_data = vec![]; - let rng = &mut rand::rngs::StdRng::seed_from_u64(TEST_METADATA_SEED); - - // Prepare the transaction metadata. - let secret_key = SecretKey::random(rng); - let utxo_id = rng.gen(); - let amount = 1; - let maturity = 1.into(); - let asset_id = rng.gen(); - let tx_pointer = rng.gen(); - let block_height = (u32::MAX >> 1).into(); - - let mut tb = tx::TransactionBuilder::script(bytecode, script_input_data) - .add_unsigned_coin_input( - secret_key, - utxo_id, - amount, - asset_id, - tx_pointer, - 0u32.into(), - ) - .maturity(maturity) - .clone(); - let mut output_index = 1; - // Insert contract ids into tx input - for contract_id in test_setup.contract_ids() { - tb.add_input(tx::Input::contract( - tx::UtxoId::new(tx::Bytes32::zeroed(), 0), - tx::Bytes32::zeroed(), - tx::Bytes32::zeroed(), - tx::TxPointer::new(0u32.into(), 0), - contract_id, - )) - .add_output(tx::Output::Contract(Contract { - input_index: output_index, - balance_root: fuel_tx::Bytes32::zeroed(), - state_root: tx::Bytes32::zeroed(), - })); - output_index += 1; - } - let consensus_params = tb.get_params().clone(); - - // Temporarily finalize to calculate `script_gas_limit` - let tmp_tx = tb.clone().finalize(); - // Get `max_gas` used by everything except the script execution. Add `1` because of rounding. - let max_gas = tmp_tx.max_gas(consensus_params.gas_costs(), consensus_params.fee_params()) + 1; - // Increase `script_gas_limit` to the maximum allowed value. - tb.script_gas_limit(consensus_params.tx_params().max_gas_per_tx - max_gas); - - let tx = tb.finalize_checked(block_height); - - let mut interpreter: vm::prelude::Interpreter<_, _, vm::interpreter::NotSupportedEcal> = - vm::interpreter::Interpreter::with_storage(storage, consensus_params.into()); - - // Execute and return the result. - let start = std::time::Instant::now(); - let transition = interpreter.transact(tx).unwrap(); - let duration = start.elapsed(); - let state = *transition.state(); - let receipts = transition.receipts().to_vec(); - - (state, duration, receipts) -} - #[cfg(test)] mod tests { use std::path::PathBuf; diff --git a/forc-test/src/setup.rs b/forc-test/src/setup.rs new file mode 100644 index 00000000000..8d213a46ad2 --- /dev/null +++ b/forc-test/src/setup.rs @@ -0,0 +1,93 @@ +use fuel_tx as tx; +use fuel_vm::{self as vm}; + +/// Result of preparing a deployment transaction setup for a contract. +pub type ContractDeploymentSetup = (tx::ContractId, vm::checked_transaction::Checked); + +/// Required test setup for package types that requires a deployment. +#[derive(Debug)] +pub enum DeploymentSetup { + Script(ScriptTestSetup), + Contract(ContractTestSetup), +} + +impl DeploymentSetup { + /// Returns the storage for this test setup + fn storage(&self) -> &vm::storage::MemoryStorage { + match self { + DeploymentSetup::Script(script_setup) => &script_setup.storage, + DeploymentSetup::Contract(contract_setup) => &contract_setup.storage, + } + } + + /// Return the root contract id if this is a contract setup. + fn root_contract_id(&self) -> Option { + match self { + DeploymentSetup::Script(_) => None, + DeploymentSetup::Contract(contract_setup) => Some(contract_setup.root_contract_id), + } + } +} + +/// The storage and the contract id (if a contract is being tested) for a test. +#[derive(Debug)] +pub enum TestSetup { + WithDeployment(DeploymentSetup), + WithoutDeployment(vm::storage::MemoryStorage), +} + +impl TestSetup { + /// Returns the storage for this test setup + pub fn storage(&self) -> &vm::storage::MemoryStorage { + match self { + TestSetup::WithDeployment(deployment_setup) => deployment_setup.storage(), + TestSetup::WithoutDeployment(storage) => storage, + } + } + + /// Produces an iterator yielding contract ids of contract dependencies for this test setup. + pub fn contract_dependency_ids(&self) -> impl Iterator + '_ { + match self { + TestSetup::WithDeployment(deployment_setup) => match deployment_setup { + DeploymentSetup::Script(script_setup) => { + script_setup.contract_dependency_ids.iter() + } + DeploymentSetup::Contract(contract_setup) => { + contract_setup.contract_dependency_ids.iter() + } + }, + TestSetup::WithoutDeployment(_) => [].iter(), + } + } + + /// Return the root contract id if this is a contract setup. + pub fn root_contract_id(&self) -> Option { + match self { + TestSetup::WithDeployment(deployment_setup) => deployment_setup.root_contract_id(), + TestSetup::WithoutDeployment(_) => None, + } + } + + /// Produces an iterator yielding all contract ids required to be included in the transaction + /// for this test setup. + pub fn contract_ids(&self) -> impl Iterator + '_ { + self.contract_dependency_ids() + .cloned() + .chain(self.root_contract_id()) + } +} + +/// The data collected to test a contract. +#[derive(Debug)] +pub struct ContractTestSetup { + pub storage: vm::storage::MemoryStorage, + pub contract_dependency_ids: Vec, + pub root_contract_id: tx::ContractId, +} + +/// The data collected to test a script. +#[derive(Debug)] +pub struct ScriptTestSetup { + pub storage: vm::storage::MemoryStorage, + pub contract_dependency_ids: Vec, +}