diff --git a/.github/workflows/_build_and_test.yml b/.github/workflows/_build_and_test.yml index 22020c21..f1088d41 100644 --- a/.github/workflows/_build_and_test.yml +++ b/.github/workflows/_build_and_test.yml @@ -15,6 +15,7 @@ jobs: working-directory: ${{ inputs.target }} steps: - uses: actions/checkout@v3 + - run: sudo apt install libudev-dev - name: Rustup run: rustup +nightly target add thumbv7em-none-eabihf - name: Build @@ -22,4 +23,8 @@ jobs: - name: Build examples run: cargo +nightly build --examples --release --verbose - name: Run tests - run: cargo +nightly test --release --verbose + run: | + cargo +nightly test --release --verbose 2>&1 | tee stderr.txt + - name: Check that tests failed for the expected reason + run: | + cat stderr.txt | grep -q "Error: unable to find Flipper Zero" diff --git a/crates/.cargo/config.toml b/crates/.cargo/config.toml index c44af23b..bf737704 100644 --- a/crates/.cargo/config.toml +++ b/crates/.cargo/config.toml @@ -1,4 +1,5 @@ [target.thumbv7em-none-eabihf] +runner = "python3 ../cargo-runner.py" rustflags = [ # CPU is Cortex-M4 (STM32WB55) "-C", "target-cpu=cortex-m4", diff --git a/crates/Cargo.toml b/crates/Cargo.toml index 8423c574..c350c136 100644 --- a/crates/Cargo.toml +++ b/crates/Cargo.toml @@ -4,6 +4,7 @@ members = [ "flipperzero", "sys", "rt", + "test", ] resolver = "2" @@ -20,6 +21,7 @@ license = "MIT" flipperzero-sys = { path = "sys", version = "0.8.0" } flipperzero-rt = { path = "rt", version = "0.8.0" } flipperzero-alloc = { path = "alloc", version = "0.8.0" } +flipperzero-test = { path = "test", version = "0.1.0" } ufmt = "0.2.0" [profile.dev] diff --git a/crates/cargo-runner.py b/crates/cargo-runner.py new file mode 100755 index 00000000..7df2d616 --- /dev/null +++ b/crates/cargo-runner.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +# Helper script for running binaries on a connected Flipper Zero. + +import argparse +import os +import sys +from pathlib import Path +from subprocess import run + +TOOLS_PATH = '../tools' + + +def parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument('binary', type=Path) + parser.add_argument('arguments', nargs=argparse.REMAINDER) + return parser.parse_args() + + +def main(): + args = parse_args() + + # Run the given FAP binary on a connected Flipper Zero. + result = run( + [ + 'cargo', + 'run', + '--quiet', + '--release', + '--bin', + 'run-fap', + '--', + os.fspath(args.binary), + ] + args.arguments, + cwd=os.path.join(os.path.dirname(__file__), TOOLS_PATH), + ) + if result.returncode: + sys.exit(result.returncode) + + +if __name__ == '__main__': + main() diff --git a/crates/flipperzero/Cargo.toml b/crates/flipperzero/Cargo.toml index b6435252..ef05100f 100644 --- a/crates/flipperzero/Cargo.toml +++ b/crates/flipperzero/Cargo.toml @@ -18,10 +18,11 @@ all-features = true [lib] bench = false -test = false +harness = false [dependencies] flipperzero-sys.workspace = true +flipperzero-test.workspace = true ufmt.workspace = true [dev-dependencies] @@ -32,6 +33,10 @@ flipperzero-rt.workspace = true # enables features requiring an allocator alloc = [] +[[test]] +name = "dolphin" +harness = false + [[example]] name = "dialog" required-features = ["alloc"] diff --git a/crates/flipperzero/src/furi/message_queue.rs b/crates/flipperzero/src/furi/message_queue.rs index 3134533e..13b4611d 100644 --- a/crates/flipperzero/src/furi/message_queue.rs +++ b/crates/flipperzero/src/furi/message_queue.rs @@ -82,3 +82,45 @@ impl Drop for MessageQueue { unsafe { sys::furi_message_queue_free(self.hnd) } } } + +#[flipperzero_test::tests] +mod tests { + use core::time::Duration; + + use flipperzero_sys::furi::Status; + + use super::MessageQueue; + + #[test] + fn capacity() { + let queue = MessageQueue::new(3); + assert_eq!(queue.len(), 0); + assert_eq!(queue.space(), 3); + assert_eq!(queue.capacity(), 3); + + // Adding a message to the queue should consume capacity. + queue.put(2, Duration::from_millis(1)).unwrap(); + assert_eq!(queue.len(), 1); + assert_eq!(queue.space(), 2); + assert_eq!(queue.capacity(), 3); + + // We should be able to fill the queue to capacity. + queue.put(4, Duration::from_millis(1)).unwrap(); + queue.put(6, Duration::from_millis(1)).unwrap(); + assert_eq!(queue.len(), 3); + assert_eq!(queue.space(), 0); + assert_eq!(queue.capacity(), 3); + + // Attempting to add another message should time out. + assert_eq!( + queue.put(7, Duration::from_millis(1)), + Err(Status::ERR_TIMEOUT), + ); + + // Removing a message from the queue frees up capacity. + assert_eq!(queue.get(Duration::from_millis(1)), Ok(2)); + assert_eq!(queue.len(), 2); + assert_eq!(queue.space(), 1); + assert_eq!(queue.capacity(), 3); + } +} diff --git a/crates/flipperzero/src/furi/sync.rs b/crates/flipperzero/src/furi/sync.rs index f26c2b39..069a856c 100644 --- a/crates/flipperzero/src/furi/sync.rs +++ b/crates/flipperzero/src/furi/sync.rs @@ -78,3 +78,24 @@ impl Drop for MutexGuard<'_, T> { // `UnsendUnsync` is actually a bit too strong. // As long as `T` implements `Sync`, it's fine to access it from another thread. unsafe impl Sync for MutexGuard<'_, T> {} + +#[flipperzero_test::tests] +mod tests { + use super::Mutex; + + #[test] + fn unshared_mutex_does_not_block() { + let mutex = Mutex::new(7u64); + + { + let mut value = mutex.lock().expect("should not fail"); + assert_eq!(*value, 7); + *value = 42; + } + + { + let value = mutex.lock().expect("should not fail"); + assert_eq!(*value, 42); + } + } +} diff --git a/crates/flipperzero/src/lib.rs b/crates/flipperzero/src/lib.rs index 959063bf..be15c092 100644 --- a/crates/flipperzero/src/lib.rs +++ b/crates/flipperzero/src/lib.rs @@ -1,6 +1,7 @@ //! High-level bindings for the Flipper Zero. #![no_std] +#![cfg_attr(test, no_main)] #[cfg(feature = "alloc")] extern crate alloc; @@ -18,3 +19,8 @@ pub mod __internal { // Re-export for use in macros pub use ufmt; } + +flipperzero_test::tests_runner!( + name = "flipperzero-rs Unit Tests", + [crate::furi::message_queue::tests, crate::furi::sync::tests] +); diff --git a/crates/flipperzero/tests/dolphin.rs b/crates/flipperzero/tests/dolphin.rs new file mode 100644 index 00000000..a4b9512d --- /dev/null +++ b/crates/flipperzero/tests/dolphin.rs @@ -0,0 +1,16 @@ +#![no_std] +#![no_main] + +#[flipperzero_test::tests] +mod tests { + use flipperzero::dolphin::Dolphin; + + #[test] + fn stats() { + let mut dolphin = Dolphin::open(); + let stats = dolphin.stats(); + assert!(stats.level >= 1); + } +} + +flipperzero_test::tests_runner!(name = "Dolphin Integration Test", [crate::tests]); diff --git a/crates/sys/src/lib.rs b/crates/sys/src/lib.rs index 7b73e4ec..7ffed3f9 100644 --- a/crates/sys/src/lib.rs +++ b/crates/sys/src/lib.rs @@ -2,6 +2,18 @@ #![no_std] +// Features that identify thumbv7em-none-eabihf. +// Until target_abi is stable, this also permits thumbv7em-none-eabi. +#[cfg(not(all( + target_arch = "arm", + target_feature = "thumb2", + target_feature = "v7", + target_feature = "dsp", + target_os = "none", + //target_abi = "eabihf", +)))] +core::compile_error!("This crate requires `--target thumbv7em-none-eabihf`"); + pub mod furi; mod inlines; diff --git a/crates/test/Cargo.toml b/crates/test/Cargo.toml new file mode 100644 index 00000000..52707dba --- /dev/null +++ b/crates/test/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "flipperzero-test" +version = "0.1.0" +repository.workspace = true +readme.workspace = true +license.workspace = true +edition.workspace = true +rust-version.workspace = true +autobins = false +autotests = false +autobenches = false + +[dependencies] +flipperzero-sys.workspace = true +flipperzero-test-macros = { version = "=0.1.0", path = "macros" } +ufmt.workspace = true + +[lib] +bench = false +test = false diff --git a/crates/test/macros/Cargo.toml b/crates/test/macros/Cargo.toml new file mode 100644 index 00000000..acc6e114 --- /dev/null +++ b/crates/test/macros/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "flipperzero-test-macros" +version = "0.1.0" +edition = "2021" + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1" +quote = "1" +syn = { version = "1", features = ["full"] } diff --git a/crates/test/macros/src/deassert.rs b/crates/test/macros/src/deassert.rs new file mode 100644 index 00000000..61d01e5c --- /dev/null +++ b/crates/test/macros/src/deassert.rs @@ -0,0 +1,101 @@ +use quote::quote; +use syn::{parse, Block, Expr, ExprMacro, ExprTuple, Stmt}; + +/// Find and replace macro assertions inside the given block with `return Err(..)`. +/// +/// The following assertion macros are replaced: +/// - [`assert`] +/// - [`assert_eq`] +/// - [`assert_ne`] +pub(crate) fn box_block(mut block: Box) -> parse::Result> { + block.stmts = block_stmts(block.stmts)?; + Ok(block) +} + +/// Searches recursively through block statements to find and replace macro assertions +/// with `return Err(..)`. +/// +/// The following assertion macros are replaced: +/// - [`assert`] +/// - [`assert_eq`] +/// - [`assert_ne`] +fn block_stmts(stmts: Vec) -> parse::Result> { + stmts + .into_iter() + .map(|stmt| match stmt { + Stmt::Expr(Expr::Block(mut e)) => { + e.block.stmts = block_stmts(e.block.stmts)?; + Ok(Stmt::Expr(Expr::Block(e))) + } + Stmt::Expr(Expr::Macro(m)) => expr_macro(m).map(Stmt::Expr), + Stmt::Semi(Expr::Macro(m), trailing) => expr_macro(m).map(|m| Stmt::Semi(m, trailing)), + _ => Ok(stmt), + }) + .collect::>() +} + +/// Replaces macro assertions with `return Err(..)`. +/// +/// The following assertion macros are replaced: +/// - [`assert`] +/// - [`assert_eq`] +/// - [`assert_ne`] +fn expr_macro(m: ExprMacro) -> parse::Result { + if m.mac.path.is_ident("assert") { + let tokens = m.mac.tokens; + let tokens_str = tokens.to_string(); + syn::parse( + quote!( + if !(#tokens) { + return ::core::result::Result::Err( + ::core::concat!("assertion failed: ", #tokens_str).into(), + ); + } + ) + .into(), + ) + } else if m.mac.path.is_ident("assert_eq") { + let (left, right) = binary_macro(m.mac.tokens)?; + let left_str = quote!(#left).to_string(); + let right_str = quote!(#right).to_string(); + syn::parse( + quote!( + if #left != #right { + return ::core::result::Result::Err( + ::flipperzero_test::TestFailure::AssertEq { + left: #left_str, + right: #right_str, + } + ); + } + ) + .into(), + ) + } else if m.mac.path.is_ident("assert_ne") { + let (left, right) = binary_macro(m.mac.tokens)?; + let left_str = quote!(#left).to_string(); + let right_str = quote!(#right).to_string(); + syn::parse( + quote!( + if #left == #right { + return ::core::result::Result::Err( + ::flipperzero_test::TestFailure::AssertNe { + left: #left_str, + right: #right_str, + } + ); + } + ) + .into(), + ) + } else { + Ok(Expr::Macro(m)) + } +} + +fn binary_macro(tokens: proc_macro2::TokenStream) -> parse::Result<(Expr, Expr)> { + let parts: ExprTuple = syn::parse(quote!((#tokens)).into())?; + assert_eq!(parts.elems.len(), 2); + let mut elems = parts.elems.into_iter(); + Ok((elems.next().unwrap(), elems.next().unwrap())) +} diff --git a/crates/test/macros/src/lib.rs b/crates/test/macros/src/lib.rs new file mode 100644 index 00000000..1bfd2c1c --- /dev/null +++ b/crates/test/macros/src/lib.rs @@ -0,0 +1,303 @@ +use proc_macro::TokenStream; +use proc_macro2::Span; +use quote::{quote, ToTokens}; +use syn::{ + parse::{self, Parse}, + punctuated::Punctuated, + spanned::Spanned, + token, Expr, ExprArray, Ident, Item, ItemMod, ReturnType, Stmt, Token, +}; + +mod deassert; + +struct TestRunner { + manifest_args: Punctuated, + test_suites: ExprArray, +} + +impl Parse for TestRunner { + fn parse(input: parse::ParseStream) -> syn::Result { + let mut manifest_args = Punctuated::new(); + if !input.peek(token::Bracket) { + loop { + if input.is_empty() || input.peek(token::Bracket) { + break; + } + let value = input.parse()?; + manifest_args.push_value(value); + if input.is_empty() || input.peek(token::Bracket) { + break; + } + let punct = input.parse()?; + manifest_args.push_punct(punct); + } + }; + + let test_suites = input.parse()?; + + Ok(TestRunner { + manifest_args, + test_suites, + }) + } +} + +struct TestRunnerArg { + ident: Ident, + eq_token: Token![=], + value: Box, +} + +impl Parse for TestRunnerArg { + fn parse(input: parse::ParseStream) -> syn::Result { + let ident = input.parse()?; + let eq_token = input.parse()?; + let value = input.parse()?; + Ok(TestRunnerArg { + ident, + eq_token, + value, + }) + } +} + +#[proc_macro] +pub fn tests_runner(args: TokenStream) -> TokenStream { + match tests_runner_impl(args) { + Ok(ts) => ts, + Err(e) => e.to_compile_error().into(), + } +} + +fn tests_runner_impl(args: TokenStream) -> parse::Result { + let TestRunner { + manifest_args, + test_suites, + } = syn::parse(args)?; + + let test_suites = test_suites + .elems + .into_iter() + .map(|attr| { + let mut module = String::new(); + for token in attr.to_token_stream() { + module.push_str(&token.to_string()); + } + let module = module.trim_start_matches("crate::"); + + ( + quote!(#attr::__test_list().len()), + quote!(#attr::__test_list().map(|(name, test_fn)| (#module, name, test_fn))), + ) + }) + .collect::>(); + + let test_counts = test_suites.iter().map(|(count, _)| count); + let test_lists = test_suites.iter().map(|(_, list)| list); + + let manifest_args = manifest_args.into_iter().map( + |TestRunnerArg { + ident, + eq_token, + value, + }| { quote!(#ident #eq_token #value) }, + ); + + Ok(quote!( + #[cfg(test)] + mod __test_runner { + // Required for panic handler + extern crate flipperzero_rt; + + // Required for allocator + #[cfg(feature = "alloc")] + extern crate flipperzero_alloc; + + use flipperzero_rt::{entry, manifest}; + + manifest!(#(#manifest_args),*); + entry!(main); + + const fn test_count() -> usize { + let ret = 0; + #( let ret = ret + #test_counts; )* + ret + } + + fn test_list() -> impl Iterator { + let ret = ::core::iter::empty(); + #( let ret = ret.chain(#test_lists); )* + ret + } + + // Test runner entry point + fn main(_args: *mut u8) -> i32 { + match ::flipperzero_test::__macro_support::run_tests(test_count(), test_list()) { + Ok(()) => 0, + Err(e) => e, + } + } + } + ) + .into()) +} + +#[proc_macro_attribute] +pub fn tests(args: TokenStream, input: TokenStream) -> TokenStream { + match tests_impl(args, input) { + Ok(ts) => ts, + Err(e) => e.to_compile_error().into(), + } +} + +fn tests_impl(args: TokenStream, input: TokenStream) -> parse::Result { + if !args.is_empty() { + return Err(parse::Error::new( + Span::call_site(), + "`#[tests]` attribute takes no arguments", + )); + } + + let module: ItemMod = syn::parse(input)?; + + let items = if let Some(content) = module.content { + content.1 + } else { + return Err(parse::Error::new( + module.span(), + "module must be inline (e.g. `mod foo {}`)", + )); + }; + + let mut tests = vec![]; + let mut untouched_tokens = vec![]; + for item in items { + match item { + Item::Fn(mut f) => { + let mut is_test = false; + + // Find and extract the `#[test]` attribute, if present. + f.attrs.retain(|attr| { + if attr.path.is_ident("test") { + is_test = true; + false + } else { + true + } + }); + + if is_test { + // Enforce expected function signature. + if !f.sig.inputs.is_empty() { + return Err(parse::Error::new( + f.sig.inputs.span(), + "`#[test]` function must have signature `fn()`", + )); + } + if !matches!(f.sig.output, ReturnType::Default) { + return Err(parse::Error::new( + f.sig.output.span(), + "`#[test]` function must have signature `fn()`", + )); + } + + // Add a `TestResult` return type. + f.sig.output = syn::parse(quote!(-> ::flipperzero_test::TestResult).into())?; + + // Replace `assert` macros in the test with `TestResult` functions. + f.block = deassert::box_block(f.block)?; + + // Enforce that the test doesn't return anything. This is somewhat + // redundant with the function signature check above, as the compiler + // will enforce that no value is returned by the unmodified function. + // However, in certain cases this results in better errors, due to us + // appending an `Ok(())` that can interfere with the previous expression. + check_ret_block(&mut f.block.stmts)?; + + // Append an `Ok(())` to the test. + f.block.stmts.push(Stmt::Expr(syn::parse( + quote!(::core::result::Result::Ok(())).into(), + )?)); + + tests.push(f); + } else { + untouched_tokens.push(Item::Fn(f)); + } + } + _ => { + untouched_tokens.push(item); + } + } + } + + let ident = module.ident; + let test_count = tests.len(); + let test_names = tests.iter().map(|test| { + let ident = &test.sig.ident; + let name = ident.to_string(); + quote!((#name, #ident)) + }); + + Ok(quote!( + #[cfg(test)] + pub(crate) mod #ident { + #(#untouched_tokens)* + + #(#tests)* + + pub(crate) const fn __test_list() -> [(&'static str, ::flipperzero_test::TestFn); #test_count] { + [#(#test_names), *] + } + } + ) + .into()) +} + +fn check_ret_block(stmts: &mut Vec) -> parse::Result<()> { + if let Some(stmt) = stmts.last_mut() { + if let Stmt::Expr(expr) = stmt { + if let Some(new_stmt) = check_ret_expr(expr)? { + *stmt = new_stmt; + } + } + } + Ok(()) +} + +fn check_ret_expr(expr: &mut Expr) -> parse::Result> { + match expr { + // If `expr` is a block that implicitly returns `()`, do nothing. + Expr::ForLoop(_) | Expr::While(_) => Ok(None), + // If `expr` is a block, recurse into it. + Expr::Async(e) => check_ret_block(&mut e.block.stmts).map(|()| None), + Expr::Block(e) => check_ret_block(&mut e.block.stmts).map(|()| None), + Expr::If(e) => { + // Checking the first branch is sufficient; the compiler will enforce that the + // other branches match. + check_ret_block(&mut e.then_branch.stmts).map(|()| None) + } + Expr::Loop(e) => check_ret_block(&mut e.body.stmts).map(|()| None), + Expr::Match(e) => { + if let Some(arm) = e.arms.first_mut() { + if let Some(stmt) = check_ret_expr(&mut arm.body)? { + *arm.body = Expr::Block(syn::parse(quote!({#stmt}).into())?); + } + } + Ok(None) + } + Expr::TryBlock(e) => check_ret_block(&mut e.block.stmts).map(|()| None), + Expr::Unsafe(e) => check_ret_block(&mut e.block.stmts).map(|()| None), + // If `expr` implicitly returns `()`, append a semicolon. + Expr::Assign(_) | Expr::AssignOp(_) => { + Ok(Some(Stmt::Semi(expr.clone(), Token!(;)(expr.span())))) + } + Expr::Break(brk) if brk.expr.is_none() => { + Ok(Some(Stmt::Semi(expr.clone(), Token!(;)(expr.span())))) + } + // For all other expressions, raise an error. + _ => Err(parse::Error::new( + expr.span(), + "`#[test]` function must not return anything", + )), + } +} diff --git a/crates/test/src/lib.rs b/crates/test/src/lib.rs new file mode 100644 index 00000000..b60113c8 --- /dev/null +++ b/crates/test/src/lib.rs @@ -0,0 +1,157 @@ +#![no_std] + +pub use flipperzero_test_macros::{tests, tests_runner}; + +/// The type of a Flipper Zero test function. +pub type TestFn = fn() -> TestResult; + +/// The result type of a Flipper Zero test. +pub type TestResult = Result<(), TestFailure>; + +/// A failure that occurred within a Flipper Zero test. +#[derive(Debug)] +pub enum TestFailure { + AssertEq { + left: &'static str, + right: &'static str, + }, + AssertNe { + left: &'static str, + right: &'static str, + }, + Str(&'static str), +} + +impl From<&'static str> for TestFailure { + fn from(value: &'static str) -> Self { + TestFailure::Str(value) + } +} + +impl ufmt::uDisplay for TestFailure { + fn fmt(&self, f: &mut ufmt::Formatter<'_, W>) -> Result<(), W::Error> + where + W: ufmt::uWrite + ?Sized, + { + match self { + TestFailure::AssertEq { left, right } => { + f.write_str("assertion failed: ")?; + f.write_str(left)?; + f.write_str(" == ")?; + f.write_str(right) + } + TestFailure::AssertNe { left, right } => { + f.write_str("assertion failed: ")?; + f.write_str(left)?; + f.write_str(" != ")?; + f.write_str(right) + } + TestFailure::Str(s) => f.write_str(s), + } + } +} + +pub mod __macro_support { + use core::ffi::c_char; + + use flipperzero_sys as sys; + use sys::furi::UnsafeRecord; + + use crate::TestFn; + + const RECORD_STORAGE: *const c_char = sys::c_string!("storage"); + + struct OutputFile(*mut sys::File); + + impl Drop for OutputFile { + fn drop(&mut self) { + unsafe { sys::storage_file_free(self.0) }; + } + } + + impl OutputFile { + fn new(storage: &UnsafeRecord) -> Self { + let output_file = unsafe { sys::storage_file_alloc(storage.as_ptr()) }; + unsafe { + sys::storage_file_open( + output_file, + sys::c_string!("/ext/flipperzero-rs-stdout"), + sys::FS_AccessMode_FSAM_WRITE, + sys::FS_OpenMode_FSOM_CREATE_ALWAYS, + ); + } + Self(output_file) + } + } + + impl ufmt::uWrite for OutputFile { + type Error = i32; + + fn write_str(&mut self, s: &str) -> Result<(), Self::Error> { + assert!(s.len() <= u16::MAX as usize); + let mut buf = s.as_bytes(); + while !buf.is_empty() { + let written = unsafe { + sys::storage_file_write(self.0, s.as_bytes().as_ptr().cast(), s.len() as u16) + }; + if written == 0 { + return Err(1); // TODO + } + buf = &buf[written as usize..]; + } + Ok(()) + } + } + + pub fn run_tests( + test_count: usize, + tests: impl Iterator, + ) -> Result<(), i32> { + let storage: UnsafeRecord = unsafe { UnsafeRecord::open(RECORD_STORAGE) }; + let mut output_file = OutputFile::new(&storage); + + ufmt::uwriteln!(output_file, "")?; + ufmt::uwriteln!(output_file, "running {} tests", test_count)?; + + let heap_before = unsafe { sys::memmgr_get_free_heap() }; + let cycle_counter = unsafe { sys::furi_get_tick() }; + let mut failed = 0; + for (module, name, test_fn) in tests { + ufmt::uwrite!(output_file, "test {}::{} ... ", module, name)?; + if let Err(e) = test_fn() { + failed += 1; + ufmt::uwriteln!(output_file, "FAILED")?; + ufmt::uwriteln!(output_file, "")?; + ufmt::uwriteln!(output_file, "---- {}::{} stdout ----", module, name)?; + ufmt::uwriteln!(output_file, "{}", e)?; + ufmt::uwriteln!(output_file, "")?; + } else { + ufmt::uwriteln!(output_file, "ok")?; + }; + } + let time_taken = unsafe { sys::furi_get_tick() } - cycle_counter; + + // Wait for tested services and apps to deallocate memory + unsafe { sys::furi_delay_us(10_000) }; + let heap_after = unsafe { sys::memmgr_get_free_heap() }; + + // Final Report + ufmt::uwriteln!(output_file, "")?; + ufmt::uwriteln!( + output_file, + "test result: {}. {} passed; {} failed; 0 ignored; 0 measured; 0 filtered out; finished in {}ms", + if failed == 0 { "ok" } else { "FAILED" }, + test_count - failed, + failed, + time_taken, + )?; + ufmt::uwriteln!(output_file, "leaked: {} bytes", heap_before - heap_after)?; + ufmt::uwriteln!(output_file, "")?; + + if failed == 0 { + Ok(()) + } else { + Err(1) + } + } +} diff --git a/tools/Cargo.lock b/tools/Cargo.lock index 43408931..95feff3e 100644 --- a/tools/Cargo.lock +++ b/tools/Cargo.lock @@ -241,6 +241,7 @@ dependencies = [ "csv", "doxygen-rs", "once_cell", + "rand", "regex", "serde", "serde_json", @@ -248,6 +249,17 @@ dependencies = [ "shlex", ] +[[package]] +name = "getrandom" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "glob" version = "0.3.1" @@ -519,6 +531,12 @@ version = "0.3.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ac9a59f73473f1b8d852421e59e64809f025994837ef743615c6d0c5b305160" +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + [[package]] name = "proc-macro-error" version = "1.0.4" @@ -567,6 +585,18 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", "rand_core", ] @@ -575,6 +605,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] [[package]] name = "redox_syscall" diff --git a/tools/Cargo.toml b/tools/Cargo.toml index a3b98417..b794506f 100644 --- a/tools/Cargo.toml +++ b/tools/Cargo.toml @@ -12,6 +12,7 @@ crossterm = "0.26.1" csv = "1.1.6" doxygen-rs = "0.3.1" once_cell = "1.17.1" +rand = "0.8" regex = "1.7.1" serde = { version = "1.0.152", features = ["derive"] } serde_json = "1.0.91" diff --git a/tools/src/bin/run-fap.rs b/tools/src/bin/run-fap.rs new file mode 100644 index 00000000..3e5bdcf1 --- /dev/null +++ b/tools/src/bin/run-fap.rs @@ -0,0 +1,138 @@ +use std::{ + fmt, + io::{self, Write}, + path::PathBuf, + thread, + time::Duration, +}; + +use clap::Parser; +use flipperzero_tools::{serial, storage}; +use rand::{thread_rng, Rng}; + +#[derive(Parser)] +#[command(author, version, about, long_about = None)] +struct Cli { + /// Serial port (e.g. `COM3` on Windows or `/dev/ttyUSB0` on Linux) + #[arg(short, long)] + port: Option, + + /// Path to the FAP binary to run. + fap: PathBuf, + + /// Arguments to provide to the FAP binary. + /// + /// Ignored until https://github.com/flipperdevices/flipperzero-firmware/issues/2505 is resolved. + args: Vec, +} + +enum Error { + FapIsNotAFile, + FlipperZeroNotFound, + FailedToOpenSerialPort(serialport::Error), + FailedToStartSerialInterface(io::Error), + FailedToUploadFap(io::Error), + MkdirFailed(storage::FlipperPath, io::Error), + RemoveFailed(storage::FlipperPath, io::Error), + Io(io::Error), +} + +impl fmt::Debug for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::FapIsNotAFile => write!(f, "provided FAP path is not to a file"), + Error::FlipperZeroNotFound => write!(f, "unable to find Flipper Zero"), + Error::FailedToOpenSerialPort(e) => write!(f, "unable to open serial port: {}", e), + Error::FailedToStartSerialInterface(e) => { + write!(f, "unable to start serial interface: {}", e) + } + Error::FailedToUploadFap(e) => write!(f, "unable to upload FAP: {}", e), + Error::MkdirFailed(path, e) => write!(f, "unable to make directory '{}': {}", path, e), + Error::RemoveFailed(path, e) => write!(f, "unable to remove '{}': {}", path, e), + Error::Io(e) => e.fmt(f), + } + } +} + +impl From for Error { + fn from(value: io::Error) -> Self { + Error::Io(value) + } +} + +fn wait_for_idle(serial_cli: &mut serial::SerialCli) -> io::Result<()> { + loop { + serial_cli.send_and_wait_eol("loader info")?; + if serial_cli + .consume_response()? + .contains("No application is running") + { + break Ok(()); + } + thread::sleep(Duration::from_millis(200)); + } +} + +fn main() -> Result<(), Error> { + let cli = Cli::parse(); + + if !cli.fap.is_file() { + return Err(Error::FapIsNotAFile); + } + let file_name = cli + .fap + .file_name() + .ok_or(Error::FapIsNotAFile)? + .to_str() + // If the FAP filename is not valid UTF-8, use a placeholder. + .unwrap_or("tmp-filename.fap"); + + let port_info = + serial::find_flipperzero(cli.port.as_deref()).ok_or(Error::FlipperZeroNotFound)?; + let port = serialport::new(&port_info.port_name, serial::BAUD_115200) + .timeout(Duration::from_secs(30)) + .open() + .map_err(Error::FailedToOpenSerialPort)?; + let mut store = storage::FlipperStorage::new(port); + store.start().map_err(Error::FailedToStartSerialInterface)?; + + // Upload the FAP to a temporary directory. + let dest_dir = + storage::FlipperPath::from(format!("/ext/tmp-{:08x}", thread_rng().gen::())); + let dest_file = dest_dir.clone() + file_name; + store + .mkdir(&dest_dir) + .map_err(|e| Error::MkdirFailed(dest_dir.clone(), e))?; + store + .send_file(&cli.fap, &dest_file) + .map_err(Error::FailedToUploadFap)?; + + let serial_cli = store.cli_mut(); + + // Wait for no application to be running. + wait_for_idle(serial_cli)?; + + // Run the FAP. + serial_cli.send_and_wait_eol(&format!("loader open Applications {}", dest_file))?; + + // Wait for the FAP to finish. + wait_for_idle(serial_cli)?; + + // Download and print the output file, if present. + let output_file = storage::FlipperPath::from("/ext/flipperzero-rs-stdout"); + if store.exist_file(&output_file)? { + let output = store.read_file(&output_file)?; + io::stdout().write_all(output.as_ref())?; + store.remove(&output_file)?; + } + + // Remove the FAP and temporary directory. + store + .remove(&dest_file) + .map_err(|e| Error::RemoveFailed(dest_file, e))?; + store + .remove(&dest_dir) + .map_err(|e| Error::RemoveFailed(dest_dir, e))?; + + Ok(()) +} diff --git a/tools/src/storage.rs b/tools/src/storage.rs index 295cc1fb..9290e7e8 100644 --- a/tools/src/storage.rs +++ b/tools/src/storage.rs @@ -43,6 +43,11 @@ impl FlipperStorage { self.cli.port_mut() } + /// Get mutable reference to underlying [`SerialCli`]. + pub fn cli_mut(&mut self) -> &mut SerialCli { + &mut self.cli + } + /// List files and directories on the device. pub fn list_tree(&mut self, path: &FlipperPath) -> io::Result<()> { // Note: The `storage list` command expects that paths do not end with a slash.