From ccd905f7cf4ace45fb78dbab910072585fdb6d42 Mon Sep 17 00:00:00 2001 From: Grant Wuerker Date: Fri, 18 Aug 2023 18:46:48 +0200 Subject: [PATCH] Test logging. --- Cargo.lock | 2 + crates/abi/src/types.rs | 4 +- crates/codegen/src/db.rs | 8 +- crates/codegen/src/db/queries/abi.rs | 12 ++- crates/driver/src/lib.rs | 62 ++++++++++++++- crates/fe/src/task/test.rs | 54 +++++++------ crates/test-runner/Cargo.toml | 2 + crates/test-runner/src/lib.rs | 109 ++++++++++++++++++++++++--- crates/tests/src/lib.rs | 4 +- newsfragments/933.feature.md | 38 ++++++++++ 10 files changed, 248 insertions(+), 47 deletions(-) create mode 100644 newsfragments/933.feature.md diff --git a/Cargo.lock b/Cargo.lock index c73cb83f0d..4e8dd11c37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -889,8 +889,10 @@ version = "0.24.0" dependencies = [ "bytes", "colored", + "ethabi", "getrandom", "hex", + "indexmap 1.9.2", "revm", ] diff --git a/crates/abi/src/types.rs b/crates/abi/src/types.rs index d24da48302..94bc60444c 100644 --- a/crates/abi/src/types.rs +++ b/crates/abi/src/types.rs @@ -143,9 +143,9 @@ impl Serialize for AbiType { #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct AbiTupleField { - name: String, + pub name: String, #[serde(flatten)] - ty: AbiType, + pub ty: AbiType, } impl AbiTupleField { diff --git a/crates/codegen/src/db.rs b/crates/codegen/src/db.rs index ce036795a3..3e87118585 100644 --- a/crates/codegen/src/db.rs +++ b/crates/codegen/src/db.rs @@ -2,7 +2,11 @@ use std::rc::Rc; use fe_abi::{contract::AbiContract, event::AbiEvent, function::AbiFunction, types::AbiType}; -use fe_analyzer::{db::AnalyzerDbStorage, namespace::items::ContractId, AnalyzerDb}; +use fe_analyzer::{ + db::AnalyzerDbStorage, + namespace::items::{ContractId, ModuleId}, + AnalyzerDb, +}; use fe_common::db::{SourceDb, SourceDbStorage, Upcast, UpcastMut}; use fe_mir::{ db::{MirDb, MirDbStorage}, @@ -31,6 +35,8 @@ pub trait CodegenDb: MirDb + Upcast + UpcastMut { fn codegen_abi_event(&self, ty: TypeId) -> AbiEvent; #[salsa::invoke(queries::abi::abi_contract)] fn codegen_abi_contract(&self, contract: ContractId) -> AbiContract; + #[salsa::invoke(queries::abi::abi_module_events)] + fn codegen_abi_module_events(&self, module: ModuleId) -> Vec; #[salsa::invoke(queries::abi::abi_type_maximum_size)] fn codegen_abi_type_maximum_size(&self, ty: TypeId) -> usize; #[salsa::invoke(queries::abi::abi_type_minimum_size)] diff --git a/crates/codegen/src/db/queries/abi.rs b/crates/codegen/src/db/queries/abi.rs index e492166e86..11c94be1fb 100644 --- a/crates/codegen/src/db/queries/abi.rs +++ b/crates/codegen/src/db/queries/abi.rs @@ -7,7 +7,7 @@ use fe_abi::{ use fe_analyzer::{ constants::INDEXED, namespace::{ - items::ContractId, + items::{ContractId, ModuleId}, types::{CtxDecl, SelfDecl}, }, }; @@ -32,8 +32,14 @@ pub fn abi_contract(db: &dyn CodegenDb, contract: ContractId) -> AbiContract { } } + let events = abi_module_events(db, contract.module(db.upcast())); + + AbiContract::new(funcs, events) +} + +pub fn abi_module_events(db: &dyn CodegenDb, module: ModuleId) -> Vec { let mut events = vec![]; - for &s in db.module_structs(contract.module(db.upcast())).as_ref() { + for &s in db.module_structs(module).as_ref() { let struct_ty = s.as_type(db.upcast()); // TODO: This is a hack to avoid generating an ABI for non-`emittable` structs. if struct_ty.is_emittable(db.upcast()) { @@ -43,7 +49,7 @@ pub fn abi_contract(db: &dyn CodegenDb, contract: ContractId) -> AbiContract { } } - AbiContract::new(funcs, events) + events } pub fn abi_function(db: &dyn CodegenDb, function: FunctionId) -> AbiFunction { diff --git a/crates/driver/src/lib.rs b/crates/driver/src/lib.rs index a0da278f0a..a31b1f3307 100644 --- a/crates/driver/src/lib.rs +++ b/crates/driver/src/lib.rs @@ -1,5 +1,7 @@ #![allow(unused_imports, dead_code)] +use fe_abi::event::AbiEvent; +use fe_abi::types::{AbiTupleField, AbiType}; pub use fe_codegen::db::{CodegenDb, Db}; use fe_analyzer::namespace::items::{ContractId, FunctionId, IngotId, IngotMode, ModuleId}; @@ -7,6 +9,7 @@ use fe_common::diagnostics::Diagnostic; use fe_common::files::FileKind; use fe_common::{db::Upcast, utils::files::BuildFiles}; use fe_parser::ast::SmolStr; +use fe_test_runner::ethabi::{Event, EventParam, ParamType}; use fe_test_runner::TestSink; use indexmap::{indexmap, IndexMap}; use serde_json::Value; @@ -31,20 +34,70 @@ pub struct CompiledContract { #[derive(Debug, Clone, PartialEq, Eq)] pub struct CompiledTest { pub name: SmolStr, + events: Vec, bytecode: String, } #[cfg(feature = "solc-backend")] impl CompiledTest { - pub fn new(name: SmolStr, bytecode: String) -> Self { - Self { name, bytecode } + pub fn new(name: SmolStr, events: Vec, bytecode: String) -> Self { + Self { + name, + events, + bytecode, + } } pub fn execute(&self, sink: &mut TestSink) -> bool { - fe_test_runner::execute(&self.name, &self.bytecode, sink) + let events = map_abi_events(&self.events); + fe_test_runner::execute(&self.name, &events, &self.bytecode, sink) + } +} + +fn map_abi_events(events: &[AbiEvent]) -> Vec { + events.iter().map(map_abi_event).collect() +} + +fn map_abi_event(event: &AbiEvent) -> Event { + let inputs = event + .inputs + .iter() + .map(|input| { + let kind = map_abi_type(&input.ty); + EventParam { + name: input.name.to_owned(), + kind, + indexed: input.indexed, + } + }) + .collect(); + Event { + name: event.name.to_owned(), + inputs, + anonymous: event.anonymous, } } +fn map_abi_type(typ: &AbiType) -> ParamType { + match typ { + AbiType::UInt(value) => ParamType::Uint(*value), + AbiType::Int(value) => ParamType::Int(*value), + AbiType::Address => ParamType::Address, + AbiType::Bool => ParamType::Bool, + AbiType::Function => panic!("function cannot be mapped to an actual ABI value type"), + AbiType::Array { elem_ty, len } => { + ParamType::FixedArray(Box::new(map_abi_type(elem_ty)), *len) + } + AbiType::Tuple(params) => ParamType::Tuple(map_abi_types(params)), + AbiType::Bytes => ParamType::Bytes, + AbiType::String => ParamType::String, + } +} + +fn map_abi_types(fields: &[AbiTupleField]) -> Vec { + fields.iter().map(|field| map_abi_type(&field.ty)).collect() +} + #[derive(Debug)] pub struct CompileError(pub Vec); @@ -168,7 +221,8 @@ fn compile_test(db: &mut Db, test: FunctionId, optimize: bool) -> CompiledTest { .to_string() .replace('"', "\\\""); let bytecode = compile_to_evm("test", &yul_test, optimize); - CompiledTest::new(test.name(db), bytecode) + let events = db.codegen_abi_module_events(test.module(db)); + CompiledTest::new(test.name(db), events, bytecode) } #[cfg(feature = "solc-backend")] diff --git a/crates/fe/src/task/test.rs b/crates/fe/src/task/test.rs index 1751495cae..c3ca5b9ba2 100644 --- a/crates/fe/src/task/test.rs +++ b/crates/fe/src/task/test.rs @@ -17,6 +17,8 @@ pub struct TestArgs { filter: Option, #[clap(long, takes_value(true))] optimize: Option, + #[clap(long)] + logs: bool, } pub fn test(args: TestArgs) { @@ -29,13 +31,36 @@ pub fn test(args: TestArgs) { }; println!("{test_sink}"); + if test_sink.failure_count() != 0 { std::process::exit(1) } } +pub fn execute_tests(module_name: &str, tests: &[CompiledTest], sink: &mut TestSink) { + if tests.len() == 1 { + println!("executing 1 test in {module_name}:"); + } else { + println!("executing {} tests in {}:", tests.len(), module_name); + } + + for test in tests { + print!(" {} ...", test.name); + let test_passed = test.execute(sink); + + if test_passed { + println!(" {}", "passed".green()) + } else { + println!(" {}", "failed".red()) + } + } + println!(); +} + fn test_single_file(args: &TestArgs) -> TestSink { let input_path = &args.input_path; + let optimize = args.optimize.unwrap_or(true); + let logs = args.logs; let mut db = fe_driver::Db::default(); let content = match std::fs::read_to_string(input_path) { @@ -46,11 +71,9 @@ fn test_single_file(args: &TestArgs) -> TestSink { Ok(content) => content, }; - match fe_driver::compile_single_file_tests(&mut db, input_path, &content, true) { + match fe_driver::compile_single_file_tests(&mut db, input_path, &content, optimize) { Ok((name, tests)) => { - let tests = filter_tests(&tests, &args.filter); - - let mut sink = TestSink::default(); + let mut sink = TestSink::new(logs); execute_tests(&name, &tests, &mut sink); sink } @@ -62,29 +85,10 @@ fn test_single_file(args: &TestArgs) -> TestSink { } } -pub fn execute_tests(module_name: &str, tests: &[CompiledTest], sink: &mut TestSink) { - if tests.len() == 1 { - println!("executing 1 test in {module_name}:"); - } else { - println!("executing {} tests in {}:", tests.len(), module_name); - } - - for test in tests { - print!(" {} ...", test.name); - let test_passed = test.execute(sink); - - if test_passed { - println!(" {}", "passed".green()) - } else { - println!(" {}", "failed".red()) - } - } - println!(); -} - fn test_ingot(args: &TestArgs) -> TestSink { let input_path = &args.input_path; let optimize = args.optimize.unwrap_or(true); + let logs = args.logs; if !Path::new(input_path).exists() { eprintln!("Input directory does not exist: `{input_path}`."); @@ -103,7 +107,7 @@ fn test_ingot(args: &TestArgs) -> TestSink { match fe_driver::compile_ingot_tests(&mut db, &build_files, optimize) { Ok(test_batches) => { - let mut sink = TestSink::default(); + let mut sink = TestSink::new(logs); for (module_name, tests) in test_batches { let tests = filter_tests(&tests, &args.filter); execute_tests(&module_name, &tests, &mut sink); diff --git a/crates/test-runner/Cargo.toml b/crates/test-runner/Cargo.toml index 11bac11311..c57ce7116c 100644 --- a/crates/test-runner/Cargo.toml +++ b/crates/test-runner/Cargo.toml @@ -10,6 +10,8 @@ repository = "https://github.com/ethereum/fe" hex="0.4" bytes = "1.3" colored = "2.0" +ethabi = { default-features = false, features = ["full-serde"], version = "18.0" } +indexmap = "1.6.2" # used by revm; we need to force the js feature for wasm support getrandom = { version = "0.2.8", features = ["js"] } diff --git a/crates/test-runner/src/lib.rs b/crates/test-runner/src/lib.rs index 6b90ebb918..325dfc6f02 100644 --- a/crates/test-runner/src/lib.rs +++ b/crates/test-runner/src/lib.rs @@ -1,15 +1,30 @@ use bytes::Bytes; use colored::Colorize; +use ethabi::{Event, Hash, RawLog}; +use indexmap::IndexMap; use revm::primitives::{AccountInfo, Bytecode, Env, ExecutionResult, TransactTo, B160, U256}; use std::fmt::Display; -#[derive(Debug, Default)] +pub use ethabi; + +#[derive(Debug)] pub struct TestSink { success_count: usize, failure_details: Vec, + logs_details: Vec, + collect_logs: bool, } impl TestSink { + pub fn new(collect_logs: bool) -> Self { + Self { + success_count: 0, + failure_details: vec![], + logs_details: vec![], + collect_logs, + } + } + pub fn test_count(&self) -> usize { self.failure_count() + self.success_count() } @@ -18,13 +33,27 @@ impl TestSink { self.failure_details.len() } + pub fn logs_count(&self) -> usize { + self.logs_details.len() + } + pub fn success_count(&self) -> usize { self.success_count } pub fn insert_failure(&mut self, name: &str, reason: &str) { self.failure_details - .push(format!("{} ({})", name, reason.red())) + .push(format!("{}\n{}", name, reason.red())) + } + + pub fn insert_logs(&mut self, name: &str, logs: &str) { + if self.collect_logs { + self.logs_details.push(format!( + "{} produced the following logs:\n{}\n", + name, + logs.bright_yellow() + )) + } } pub fn inc_success_count(&mut self) { @@ -34,13 +63,26 @@ impl TestSink { pub fn failure_details(&self) -> String { self.failure_details.join("\n") } + + pub fn logs_details(&self) -> String { + self.logs_details.join("\n") + } } impl Display for TestSink { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.logs_count() != 0 { + writeln!(f, "{}", self.logs_details())?; + writeln!(f)?; + } + if self.failure_count() != 0 { writeln!(f, "{}", self.failure_details())?; writeln!(f)?; + if self.collect_logs { + writeln!(f, "note: failed tests do not produce logs")?; + writeln!(f)?; + } } let test_description = |n: usize, status: &dyn Display| -> String { @@ -65,7 +107,11 @@ impl Display for TestSink { } } -pub fn execute(name: &str, bytecode: &str, sink: &mut TestSink) -> bool { +pub fn execute(name: &str, events: &[Event], bytecode: &str, sink: &mut TestSink) -> bool { + let events: IndexMap<_, _> = events + .iter() + .map(|event| (event.signature(), event)) + .collect(); let bytecode = Bytecode::new_raw(Bytes::copy_from_slice(&hex::decode(bytecode).unwrap())); let mut database = revm::InMemoryDB::default(); @@ -81,17 +127,60 @@ pub fn execute(name: &str, bytecode: &str, sink: &mut TestSink) -> bool { evm.database(&mut database); let result = evm.transact_commit().expect("evm failure"); - if result.is_success() { + if let ExecutionResult::Success { logs, .. } = result { + let logs: Vec<_> = logs + .iter() + .map(|log| { + if let Some(Some(event)) = log + .topics + .get(0) + .map(|sig| events.get(&Hash::from_slice(sig.as_bytes()))) + { + let topics = log + .topics + .iter() + .map(|topic| Hash::from_slice(topic.as_bytes())) + .collect(); + let data = log.data.clone().to_vec(); + let raw_log = RawLog { topics, data }; + if let Ok(parsed_event) = event.parse_log(raw_log) { + format!( + " {} emitted by {} with the following parameters [{}]", + event.name, + log.address, + parsed_event + .params + .iter() + .map(|param| format!("{}: {}", param.name, param.value)) + .collect::>() + .join(", "), + ) + } else { + format!(" {:?}", log) + } + } else { + format!(" {:?}", log) + } + }) + .collect(); + + if !logs.is_empty() { + sink.insert_logs(name, &logs.join("\n")) + } + sink.inc_success_count(); true - } else if let ExecutionResult::Revert { gas_used, output } = result { + } else if let ExecutionResult::Revert { output, .. } = result { sink.insert_failure( name, - &format!( - "Reverted gas used: {} output: {}", - gas_used, - hex::encode(output) - ), + &if output.is_empty() { + " reverted".to_string() + } else { + format!( + " reverted with the following output: {}", + hex::encode(output) + ) + }, ); false } else { diff --git a/crates/tests/src/lib.rs b/crates/tests/src/lib.rs index 96fe6f1caa..8b72ba33df 100644 --- a/crates/tests/src/lib.rs +++ b/crates/tests/src/lib.rs @@ -24,7 +24,7 @@ fn single_file_test_run(fixture: Fixture<&str>) { } }; - let mut test_sink = TestSink::default(); + let mut test_sink = TestSink::new(true); for test in tests { test.execute(&mut test_sink); @@ -50,7 +50,7 @@ fn ingot_test_run(fixture: Fixture<&str>) { let mut db = fe_driver::Db::default(); match fe_driver::compile_ingot_tests(&mut db, &build_files, optimize) { Ok(test_batches) => { - let mut sink = TestSink::default(); + let mut sink = TestSink::new(true); for (_, tests) in test_batches { for test in tests { test.execute(&mut sink); diff --git a/newsfragments/933.feature.md b/newsfragments/933.feature.md new file mode 100644 index 0000000000..ed6a608aed --- /dev/null +++ b/newsfragments/933.feature.md @@ -0,0 +1,38 @@ +Logs for successfully ran tests can be printed with the `--logs` parameter. + +example: + +``` +// test_log.fe + +use std::evm::log0 +use std::buf::MemoryBuffer + +struct MyEvent { + pub foo: u256 + pub baz: bool + pub bar: u256 +} + +#test +fn test_log(mut ctx: Context) { + ctx.emit(MyEvent(foo: 42, baz: false, bar: 26)) + unsafe { log0(buf: MemoryBuffer::new(len: 42)) } +} + +``` + +``` +$ fe test --logs test_log.fe +executing 1 test in test_log: + test_log ... passed + +test_log produced the following logs: + MyEvent emitted by 0x0000…002a with the following parameters [foo: 2a, baz: false, bar: 1a] + Log { address: 0x000000000000000000000000000000000000002a, topics: [], data: b"\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\x1a\0\0\0\0\0\0\0\0\0\0" } + + +1 test passed; 0 tests failed; 1 test executed +``` + +Note: Logs are not collected for failing tests.