From bac8627fbe28ae37e89046fdadd90a100795bb0b Mon Sep 17 00:00:00 2001 From: Lily Hopkins Date: Thu, 5 Dec 2024 13:26:30 +0000 Subject: [PATCH] allow finding engines in subdirectories resolves #210 --- Cargo.lock | 196 ++++ testangel-user-interaction/Cargo.toml | 2 +- testangel/src/bin/executor.rs | 228 ++--- testangel/src/ipc.rs | 392 ++++---- testangel/src/types/mod.rs | 992 +++++++++++---------- testangel/src/ui/actions/mod.rs | 10 +- testangel/src/ui/flows/execution_dialog.rs | 568 ++++++------ testangel/src/ui/flows/mod.rs | 10 +- 8 files changed, 1321 insertions(+), 1077 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ed40bb0..a9018eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -175,6 +175,8 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9c39d707614dbcc6bed00015539f488d8e3fe3e66ed60961efc0c90f4b380b3" dependencies = [ + "async-fs", + "async-net", "enumflags2", "futures-channel", "futures-util", @@ -201,6 +203,102 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-channel" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30ca9a001c1e8ba5149f91a74362376cc6bc5b919d92d988668657bd570bdcec" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebcd09b382f40fcd159c2d695175b2ae620ffa5f3bd6f664131efff4e8b9e04a" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a2b323ccce0a1d90b449fd71f2a06ca7faa7c54c2751f06c9bd851fc061059" +dependencies = [ + "async-lock", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "async-lock" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-process" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63255f1dc2381611000436537bbedfe83183faa303a5a0edaf191edef06526bb" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", + "tracing", +] + [[package]] name = "async-recursion" version = "1.1.1" @@ -212,6 +310,30 @@ dependencies = [ "syn 2.0.90", ] +[[package]] +name = "async-signal" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "637e00349800c0bdf8bfc21ebbc0b6524abea702b0da4168ac00d070d0c0b9f3" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.59.0", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.83" @@ -223,6 +345,12 @@ dependencies = [ "syn 2.0.90", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.4.0" @@ -310,6 +438,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "borrow-or-share" version = "0.2.2" @@ -1254,6 +1395,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-lite" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cef40d21ae2c515b51041df9ed313ed21e572df340ea58a922a0aefe7e8891a1" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.31" @@ -2900,6 +3054,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.31" @@ -2931,6 +3096,21 @@ dependencies = [ "miniz_oxide 0.8.0", ] +[[package]] +name = "polling" +version = "3.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a604568c3202727d1507653cb121dbd627a58684eb09a820fd746bee38b4442f" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "pollster" version = "0.3.0" @@ -3686,6 +3866,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.7" @@ -5036,8 +5225,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1162094dc63b1629fcc44150bcceeaa80798cd28bcbe7fa987b65a034c258608" dependencies = [ "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", "async-recursion", + "async-task", "async-trait", + "blocking", "enumflags2", "event-listener", "futures-core", diff --git a/testangel-user-interaction/Cargo.toml b/testangel-user-interaction/Cargo.toml index f575a34..f8a3e57 100644 --- a/testangel-user-interaction/Cargo.toml +++ b/testangel-user-interaction/Cargo.toml @@ -12,6 +12,6 @@ crate-type = ["cdylib"] [dependencies] lazy_static = "1.4.0" -rfd = { version = "0.15.1", default-features = false, features = [ "xdg-portal" ] } +rfd = "0.15.1" testangel-engine = { path = "../testangel-engine" } thiserror = "2.0.4" diff --git a/testangel/src/bin/executor.rs b/testangel/src/bin/executor.rs index 60bf85b..bcf1ddd 100644 --- a/testangel/src/bin/executor.rs +++ b/testangel/src/bin/executor.rs @@ -1,107 +1,121 @@ -use std::{collections::HashMap, fs, path::PathBuf, sync::Arc}; - -use base64::{prelude::BASE64_STANDARD, Engine}; -use clap::{arg, Parser}; -use evidenceangel::{Author, EvidencePackage}; -use testangel::{types::AutomationFlow, *}; -use testangel_ipc::prelude::*; - -#[derive(Parser)] - -struct Cli { - /// The output file for evidence. If this already exists then it will be appended. - #[arg(short, long, default_value = "evidence.evp")] - output: PathBuf, - - /// The flow file to execute. - #[arg(index = 1)] - flow: PathBuf, -} - -fn main() { - pretty_env_logger::init(); - - let cli = Cli::parse(); - - let flow: AutomationFlow = - ron::from_str(&fs::read_to_string(cli.flow).expect("Failed to read flow.")) - .expect("Failed to parse flow."); - let engine_map = Arc::new(ipc::get_engines()); - let action_map = Arc::new(action_loader::get_actions(engine_map.clone())); - - // Check flow for actions that aren't available. - for action_config in &flow.actions { - if action_map - .get_action_by_id(&action_config.action_id) - .is_none() - { - eprintln!("This flow cannot be executed because an action isn't available or wasn't loaded. Maybe an engine is missing?"); - std::process::exit(1); - } - } - - let mut outputs: Vec> = Vec::new(); - let mut evidence = Vec::new(); - - for engine in engine_map.inner() { - if engine.reset_state().is_err() { - evidence.push(Evidence { - label: String::from("WARNING: State Warning"), - content: EvidenceContent::Textual(String::from("For this test execution, the state couldn't be correctly reset. Some results may not be accurate.")) - }); - } - } - - for action_config in flow.actions { - match action_config.execute(action_map.clone(), engine_map.clone(), outputs.clone()) { - Ok((output, ev)) => { - outputs.push(output); - evidence = vec![evidence, ev].concat(); - } - Err(e) => { - panic!("Failed to execute: {e}"); - } - } - } - - match fs::exists(&cli.output) { - Ok(exists) => { - let evp = if exists { - // Open - EvidencePackage::open(cli.output) - } else { - // Create - EvidencePackage::new(cli.output, "TestAngel Evidence".to_string(), vec![Author::new("Anonymous Author")]) - }; - - if let Err(e) = &evp { - eprintln!("Failed to create/open output file: {e}"); - } - let evp = evp.unwrap(); - - // Append new TC - if let Err(e) = add_evidence(evp, evidence) { - eprintln!("Failed to write evidence: {e}"); - } - }, - Err(e) => eprintln!("Failed to check if output file exists: {e}"), - } -} - -fn add_evidence(mut evp: EvidencePackage, evidence: Vec) -> evidenceangel::Result<()> { - let tc = evp.create_test_case("TestAngel Test Case")?; - let tc_evidence = tc.evidence_mut(); - for ev in evidence { - let Evidence { label, content } = ev; - let mut ea_ev = match content { - EvidenceContent::Textual(text) => evidenceangel::Evidence::new(evidenceangel::EvidenceKind::Text, evidenceangel::EvidenceData::Text { content: text }), - EvidenceContent::ImageAsPngBase64(base64) => evidenceangel::Evidence::new(evidenceangel::EvidenceKind::Image, evidenceangel::EvidenceData::Base64 { data: BASE64_STANDARD.decode(base64).map_err(|e| evidenceangel::Error::OtherExportError(Box::new(e)))? }), - }; - if !label.is_empty() { - ea_ev.set_caption(Some(label)); - } - tc_evidence.push(ea_ev); - } - evp.save()?; - Ok(()) -} +use std::{collections::HashMap, fs, path::PathBuf, sync::Arc}; + +use base64::{prelude::BASE64_STANDARD, Engine}; +use clap::{arg, Parser}; +use evidenceangel::{Author, EvidencePackage}; +use testangel::{types::AutomationFlow, *}; +use testangel_ipc::prelude::*; + +#[derive(Parser)] + +struct Cli { + /// The output file for evidence. If this already exists then it will be appended. + #[arg(short, long, default_value = "evidence.evp")] + output: PathBuf, + + /// The flow file to execute. + #[arg(index = 1)] + flow: PathBuf, +} + +fn main() { + pretty_env_logger::init(); + + let cli = Cli::parse(); + + let flow: AutomationFlow = + ron::from_str(&fs::read_to_string(cli.flow).expect("Failed to read flow.")) + .expect("Failed to parse flow."); + let engine_map = Arc::new(ipc::get_engines()); + let action_map = Arc::new(action_loader::get_actions(engine_map.clone())); + + // Check flow for actions that aren't available. + for action_config in &flow.actions { + if action_map + .get_action_by_id(&action_config.action_id) + .is_none() + { + eprintln!("This flow cannot be executed because an action isn't available or wasn't loaded. Maybe an engine is missing?"); + std::process::exit(1); + } + } + + let mut outputs: Vec> = Vec::new(); + let mut evidence = Vec::new(); + + for engine in engine_map.inner() { + if engine.reset_state().is_err() { + evidence.push(Evidence { + label: String::from("WARNING: State Warning"), + content: EvidenceContent::Textual(String::from("For this test execution, the state couldn't be correctly reset. Some results may not be accurate.")) + }); + } + } + + for action_config in flow.actions { + match action_config.execute(action_map.clone(), engine_map.clone(), outputs.clone()) { + Ok((output, ev)) => { + outputs.push(output); + evidence = vec![evidence, ev].concat(); + } + Err(e) => { + panic!("Failed to execute: {e}"); + } + } + } + + match fs::exists(&cli.output) { + Ok(exists) => { + let evp = if exists { + // Open + EvidencePackage::open(cli.output) + } else { + // Create + EvidencePackage::new( + cli.output, + "TestAngel Evidence".to_string(), + vec![Author::new("Anonymous Author")], + ) + }; + + if let Err(e) = &evp { + eprintln!("Failed to create/open output file: {e}"); + } + let evp = evp.unwrap(); + + // Append new TC + if let Err(e) = add_evidence(evp, evidence) { + eprintln!("Failed to write evidence: {e}"); + } + } + Err(e) => eprintln!("Failed to check if output file exists: {e}"), + } +} + +fn add_evidence(mut evp: EvidencePackage, evidence: Vec) -> evidenceangel::Result<()> { + let tc = evp.create_test_case("TestAngel Test Case")?; + let tc_evidence = tc.evidence_mut(); + for ev in evidence { + let Evidence { label, content } = ev; + let mut ea_ev = match content { + EvidenceContent::Textual(text) => evidenceangel::Evidence::new( + evidenceangel::EvidenceKind::Text, + evidenceangel::EvidenceData::Text { content: text }, + ), + EvidenceContent::ImageAsPngBase64(base64) => evidenceangel::Evidence::new( + evidenceangel::EvidenceKind::Image, + evidenceangel::EvidenceData::Base64 { + data: BASE64_STANDARD + .decode(base64) + .map_err(|e| evidenceangel::Error::OtherExportError(Box::new(e)))?, + }, + ), + }; + if !label.is_empty() { + ea_ev.set_caption(Some(label)); + } + tc_evidence.push(ea_ev); + } + evp.save()?; + Ok(()) +} diff --git a/testangel/src/ipc.rs b/testangel/src/ipc.rs index 639586e..11ab56b 100644 --- a/testangel/src/ipc.rs +++ b/testangel/src/ipc.rs @@ -1,188 +1,204 @@ -use std::{ - env, - ffi::{c_char, CStr, CString}, - fmt, fs, io, - path::PathBuf, - sync::Arc, -}; - -use testangel_ipc::prelude::*; - -#[derive(Debug)] -pub enum IpcError { - IoError(io::Error), - EngineNotStarted, - EngineNotCompliant, - CantLockEngineIo, - InvalidResponseFromEngine, -} - -pub fn ipc_call(engine: &Engine, request: Request) -> Result { - log::debug!( - "Sending request {:?} to engine {} at {:?}.", - request, - engine, - engine.path - ); - - let request = request.to_json(); - let c_request = CString::new(request).unwrap(); - let response = unsafe { - let lib = engine.lib.clone().ok_or(IpcError::EngineNotStarted)?; - - let ta_call: libloading::Symbol< - unsafe extern "C" fn(input: *const c_char) -> *const c_char, - > = lib - .get(b"ta_call") - .map_err(|_| IpcError::EngineNotCompliant)?; - let res = ta_call(c_request.as_ptr()); - let res = CStr::from_ptr(res); - let string = String::from_utf8_lossy(res.to_bytes()).to_string(); - - // release string - let ta_release: libloading::Symbol = lib - .get(b"ta_release") - .map_err(|_| IpcError::EngineNotCompliant)?; - ta_release(res.as_ptr()); - - string - }; - - let res = Response::try_from(response).map_err(|e| { - log::error!("Failed to parse response ({}) from engine {}.", e, engine,); - IpcError::InvalidResponseFromEngine - })?; - - log::debug!("Got response {res:?}"); - Ok(res) -} - -#[derive(Clone, Debug, Default)] -pub struct Engine { - path: PathBuf, - pub name: String, - pub lua_name: String, - pub instructions: Vec, - lib: Option>, -} - -impl Engine { - /// Ask the engine to reset it's state for test repeatability. - pub fn reset_state(&self) -> Result<(), IpcError> { - ipc_call(self, Request::ResetState).map(|_| ()) - } -} - -impl fmt::Display for Engine { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.name) - } -} - -#[derive(Default, Debug)] -pub struct EngineList(Vec); - -impl EngineList { - /// Get an instruction from an instruction ID by iterating through available engines. - pub fn get_instruction_by_id(&self, instruction_id: &String) -> Option { - for engine in &self.0 { - for inst in &engine.instructions { - if *inst.id() == *instruction_id { - return Some(inst.clone()); - } - } - } - None - } - - /// Get an instruction and engine from an instruction ID by iterating through available engines. - pub fn get_engine_by_instruction_id(&self, instruction_id: &String) -> Option<&Engine> { - for engine in &self.0 { - for inst in &engine.instructions { - if *inst.id() == *instruction_id { - return Some(engine); - } - } - } - None - } - - /// Return the inner list of engines - pub fn inner(&self) -> &Vec { - &self.0 - } -} - -/// Get the list of available engines. -pub fn get_engines() -> EngineList { - let mut engines = Vec::new(); - let engine_dir = env::var("TA_ENGINE_DIR").unwrap_or("./engines".to_owned()); - fs::create_dir_all(engine_dir.clone()).unwrap(); - log::info!("Searching for engines in {engine_dir:?}"); - let mut lua_names = vec![]; - for path in fs::read_dir(engine_dir).unwrap() { - let path = path.unwrap(); - let basename = path.file_name(); - if let Ok(meta) = path.metadata() { - if meta.is_dir() { - continue; - } - } - - if let Ok(str) = basename.into_string() { - log::debug!("Found {str}"); - if str.ends_with(".so") || str.ends_with(".dll") || str.ends_with(".dylib") { - log::debug!("Detected possible engine {str}"); - match unsafe { libloading::Library::new(path.path()) } { - Ok(lib) => { - let mut engine = Engine { - name: String::from("newly discovered engine"), - path: path.path(), - lib: Some(Arc::new(lib)), - ..Default::default() - }; - match ipc_call(&engine, Request::Instructions) { - Ok(res) => match res { - Response::Instructions { - friendly_name, - engine_version, - engine_lua_name, - ipc_version, - instructions, - } => { - if ipc_version == 2 { - if lua_names.contains(&engine_lua_name) { - log::warn!( - "Engine {friendly_name} (v{engine_version}) at {:?} uses a lua name that is already used by another engine!", - path.path() - ); - continue; - } - log::info!( - "Discovered engine {friendly_name} (v{engine_version}) at {:?}", - path.path() - ); - engine.name = friendly_name.clone(); - engine.lua_name = engine_lua_name.clone(); - engine.instructions = instructions; - engines.push(engine); - lua_names.push(engine_lua_name); - } else { - log::warn!( - "Engine {friendly_name} (v{engine_version}) at {:?} doesn't speak the right IPC version!", - path.path() - ); - } - } - _ => log::error!("Invalid response from engine {str}"), - }, - Err(e) => log::warn!("IPC error: {e:?}"), - } - } - Err(e) => log::warn!("Failed to load engine {str}: {e}"), - } - } - } - } - EngineList(engines) -} +use std::{ + env, + ffi::{c_char, CStr, CString}, + fmt, fs, io, + path::PathBuf, + sync::Arc, +}; + +use testangel_ipc::prelude::*; + +#[derive(Debug)] +pub enum IpcError { + IoError(io::Error), + EngineNotStarted, + EngineNotCompliant, + CantLockEngineIo, + InvalidResponseFromEngine, +} + +pub fn ipc_call(engine: &Engine, request: Request) -> Result { + log::debug!( + "Sending request {:?} to engine {} at {:?}.", + request, + engine, + engine.path + ); + + let request = request.to_json(); + let c_request = CString::new(request).unwrap(); + let response = unsafe { + let lib = engine.lib.clone().ok_or(IpcError::EngineNotStarted)?; + + let ta_call: libloading::Symbol< + unsafe extern "C" fn(input: *const c_char) -> *const c_char, + > = lib + .get(b"ta_call") + .map_err(|_| IpcError::EngineNotCompliant)?; + let res = ta_call(c_request.as_ptr()); + let res = CStr::from_ptr(res); + let string = String::from_utf8_lossy(res.to_bytes()).to_string(); + + // release string + let ta_release: libloading::Symbol = lib + .get(b"ta_release") + .map_err(|_| IpcError::EngineNotCompliant)?; + ta_release(res.as_ptr()); + + string + }; + + let res = Response::try_from(response).map_err(|e| { + log::error!("Failed to parse response ({}) from engine {}.", e, engine,); + IpcError::InvalidResponseFromEngine + })?; + + log::debug!("Got response {res:?}"); + Ok(res) +} + +#[derive(Clone, Debug, Default)] +pub struct Engine { + path: PathBuf, + pub name: String, + pub lua_name: String, + pub instructions: Vec, + lib: Option>, +} + +impl Engine { + /// Ask the engine to reset it's state for test repeatability. + pub fn reset_state(&self) -> Result<(), IpcError> { + ipc_call(self, Request::ResetState).map(|_| ()) + } +} + +impl fmt::Display for Engine { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.name) + } +} + +#[derive(Default, Debug)] +pub struct EngineList(Vec); + +impl EngineList { + /// Get an instruction from an instruction ID by iterating through available engines. + pub fn get_instruction_by_id(&self, instruction_id: &String) -> Option { + for engine in &self.0 { + for inst in &engine.instructions { + if *inst.id() == *instruction_id { + return Some(inst.clone()); + } + } + } + None + } + + /// Get an instruction and engine from an instruction ID by iterating through available engines. + pub fn get_engine_by_instruction_id(&self, instruction_id: &String) -> Option<&Engine> { + for engine in &self.0 { + for inst in &engine.instructions { + if *inst.id() == *instruction_id { + return Some(engine); + } + } + } + None + } + + /// Return the inner list of engines + pub fn inner(&self) -> &Vec { + &self.0 + } +} + +/// Get the list of available engines. +pub fn get_engines() -> EngineList { + let mut engines = Vec::new(); + let engine_dir = env::var("TA_ENGINE_DIR").unwrap_or("./engines".to_owned()); + fs::create_dir_all(engine_dir.clone()).unwrap(); + log::info!("Searching for engines in {engine_dir:?}"); + let mut lua_names = vec![]; + search_engine_dir(engine_dir, &mut engines, &mut lua_names); + EngineList(engines) +} + +fn search_engine_dir(engine_dir: String, engines: &mut Vec, lua_names: &mut Vec) { + for path in fs::read_dir(engine_dir).unwrap() { + let path = path.unwrap(); + let basename = path.file_name(); + if let Ok(meta) = path.metadata() { + if meta.is_dir() { + // Search subdir + search_engine_dir( + path.path() + .canonicalize() + .unwrap() + .as_os_str() + .to_os_string() + .into_string() + .unwrap(), + engines, + lua_names, + ); + continue; + } + } + + if let Ok(str) = basename.into_string() { + log::debug!("Found {:?}", path.path()); + if str.ends_with(".so") || str.ends_with(".dll") || str.ends_with(".dylib") { + log::debug!("Detected possible engine {str}"); + match unsafe { libloading::Library::new(path.path()) } { + Ok(lib) => { + let mut engine = Engine { + name: String::from("newly discovered engine"), + path: path.path(), + lib: Some(Arc::new(lib)), + ..Default::default() + }; + match ipc_call(&engine, Request::Instructions) { + Ok(res) => match res { + Response::Instructions { + friendly_name, + engine_version, + engine_lua_name, + ipc_version, + instructions, + } => { + if ipc_version == 2 { + if lua_names.contains(&engine_lua_name) { + log::warn!( + "Engine {friendly_name} (v{engine_version}) at {:?} uses a lua name that is already used by another engine!", + path.path() + ); + continue; + } + log::info!( + "Discovered engine {friendly_name} (v{engine_version}) at {:?}", + path.path() + ); + engine.name = friendly_name.clone(); + engine.lua_name = engine_lua_name.clone(); + engine.instructions = instructions; + engines.push(engine); + lua_names.push(engine_lua_name); + } else { + log::warn!( + "Engine {friendly_name} (v{engine_version}) at {:?} doesn't speak the right IPC version!", + path.path() + ); + } + } + _ => log::error!("Invalid response from engine {str}"), + }, + Err(e) => log::warn!("IPC error: {e:?}"), + } + } + Err(e) => log::warn!("Failed to load engine {str}: {e}"), + } + } + } + } +} diff --git a/testangel/src/types/mod.rs b/testangel/src/types/mod.rs index 7a92726..56aaeec 100644 --- a/testangel/src/types/mod.rs +++ b/testangel/src/types/mod.rs @@ -1,495 +1,497 @@ -use std::{collections::HashMap, fmt, sync::Arc}; - -use mlua::{Lua, ObjectLike}; -use serde::{Deserialize, Serialize}; -use testangel_ipc::prelude::*; - -use crate::{ - action_loader::ActionMap, - action_syntax::{Descriptor, DescriptorKind}, - ipc::{self, EngineList, IpcError}, -}; - -pub mod action_v1; - -#[derive(Debug, Copy, Clone, Serialize, Deserialize)] -pub struct VersionedFile { - version: usize, -} - -impl VersionedFile { - /// Get the version of the file - pub fn version(&self) -> usize { - self.version - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Action { - /// The data version of this action. - version: usize, - /// The internal ID of this action. Must be unique. - pub id: String, - /// The friendly name of this action. - pub friendly_name: String, - /// A description of this action. - pub description: String, - /// A group this action belongs to. - pub group: String, - /// The author of this action. - pub author: String, - /// Whether this action should be visible in the flow editor. - pub visible: bool, - /// The Lua code driving this action. - pub script: String, - /// A vector of required instruction IDs for this action to work. - pub required_instructions: Vec, -} - -impl Default for Action { - fn default() -> Self { - Self { - version: 2, - id: uuid::Uuid::new_v4().to_string(), - friendly_name: String::new(), - description: String::new(), - author: String::new(), - visible: true, - group: String::new(), - script: "--: param Integer Example Parameter\n--: return Text Some value to return\nfunction run_action(x)\n \n return 'Hello, world!'\nend\n".to_string(), - required_instructions: Vec::new(), - } - } -} - -impl Action { - /// Get the version of this action. - pub fn version(&self) -> usize { - self.version - } - - /// Generate a new ID for this action. - pub fn new_id(&mut self) { - self.id = uuid::Uuid::new_v4().to_string(); - } - - /// Check that all the instructions this action uses are available. Returns - /// Ok if all instructions are available, otherwise returns a list of - /// missing instructions. - pub fn check_instructions_available( - &self, - engine_list: Arc, - ) -> Result<(), Vec> { - let mut missing = vec![]; - for instruction in &self.required_instructions { - if engine_list.get_instruction_by_id(instruction).is_none() - && !missing.contains(instruction) - { - missing.push(instruction.clone()); - } - } - if missing.is_empty() { - Ok(()) - } else { - Err(missing) - } - } - - /// Get a list of parameters that need to be provided to this action. - pub fn parameters(&self) -> Vec<(String, ParameterKind)> { - let descriptors = Descriptor::parse_all(&self.script); - let mut params = vec![]; - for d in descriptors { - if d.descriptor_kind == DescriptorKind::Parameter { - params.push((d.name.clone(), d.kind)); - } - } - params - } - - /// Get a list of outputs provided by this action. - pub fn outputs(&self) -> Vec<(String, ParameterKind)> { - let descriptors = Descriptor::parse_all(&self.script); - let mut outputs = vec![]; - for d in descriptors { - if d.descriptor_kind == DescriptorKind::Return { - outputs.push((d.name.clone(), d.kind)); - } - } - outputs - } -} - -#[derive(Debug)] -pub enum FlowError { - FromInstruction { - error_kind: ErrorKind, - reason: String, - }, - Lua(String), - IPCFailure(IpcError), - ActionDidntReturnCorrectArgumentCount, - ActionDidntReturnValidArguments, - InstructionCalledWithWrongNumberOfParams, - InstructionCalledWithInvalidParamType, -} - -impl std::error::Error for FlowError {} - -impl fmt::Display for FlowError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::IPCFailure(e) => write!(f, "An IPC call failed ({e:?})."), - Self::Lua(e) => write!(f, "An action script error occurred:\n{}", e), - Self::FromInstruction { error_kind, reason } => write!( - f, - "An instruction returned an error: {error_kind:?}: {reason}" - ), - Self::ActionDidntReturnCorrectArgumentCount => { - write!(f, "The action didn't return the correct amount of values.") - } - Self::ActionDidntReturnValidArguments => { - write!(f, "The action didn't return valid values.") - } - Self::InstructionCalledWithWrongNumberOfParams => write!( - f, - "An instruction was called with the wrong number of parameters." - ), - Self::InstructionCalledWithInvalidParamType => write!( - f, - "An instruction was called with the wrong parameter type." - ), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AutomationFlow { - /// The version of this automation flow file - version: usize, - /// The actions called by this flow - pub actions: Vec, -} - -impl Default for AutomationFlow { - fn default() -> Self { - Self { - version: 1, - actions: vec![], - } - } -} - -impl AutomationFlow { - /// Get the version of this flow. - pub fn version(&self) -> usize { - self.version - } -} - -#[derive(Default, Debug, Clone, Serialize, Deserialize)] -pub struct ActionConfiguration { - pub action_id: String, - pub parameter_sources: HashMap, - pub parameter_values: HashMap, -} -impl ActionConfiguration { - /// Execute this action - pub fn execute( - &self, - action_map: Arc, - engine_map: Arc, - previous_action_outputs: Vec>, - ) -> Result<(HashMap, Vec), FlowError> { - // Find action by ID - let action = action_map.get_action_by_id(&self.action_id).unwrap(); - // Build action parameters - let mut action_parameters = HashMap::new(); - for (id, src) in &self.parameter_sources { - let value = match src { - ActionParameterSource::Literal => self.parameter_values.get(id).unwrap().clone(), - ActionParameterSource::FromOutput(step, id) => previous_action_outputs - .get(*step) - .unwrap() - .get(id) - .unwrap() - .clone(), - }; - action_parameters.insert(*id, value); - } - let mut param_vec = vec![]; - for i in 0..action_parameters.len() { - param_vec.push(action_parameters[&i].clone()); - } - Self::execute_directly(engine_map, &action, param_vec) - } - - #[allow(clippy::type_complexity)] - /// Directly execute an action with a set of parameters. - pub fn execute_directly( - engine_map: Arc, - action: &Action, - action_parameters: Vec, - ) -> Result<(HashMap, Vec), FlowError> { - let mut output = HashMap::new(); - - // Prepare Lua environment - let lua_env = Lua::new(); - lua_env.set_app_data::>(vec![]); - - // unwrap rationale: this will only fail under memory issues - for engine in engine_map.inner().clone() { - let engine_lua_name = engine.lua_name.clone(); - let engine_tbl = lua_env.create_table().unwrap(); - for instruction in engine.instructions.clone() { - let instruction_lua_name = instruction.lua_name().clone(); - let engine = engine.clone(); - let instruction_fn = lua_env - .create_function(move |lua, args: mlua::MultiValue| { - // Check we have the correct number of parameters. - if args.len() != instruction.parameters().len() { - return Err(mlua::Error::external( - FlowError::InstructionCalledWithWrongNumberOfParams, - )); - } - - // Check we have the correct parameter types and convert to parameter map - let mut param_map = HashMap::new(); - for (idx, param_id) in instruction.parameter_order().iter().enumerate() { - if let Some((_name, kind)) = instruction.parameters().get(param_id) { - // Get argument and coerce - let arg = args[idx].clone(); - match kind { - ParameterKind::Boolean => { - if let mlua::Value::Boolean(b) = arg { - param_map.insert( - param_id.clone(), - ParameterValue::Boolean(b), - ); - } else { - return Err(mlua::Error::external( - FlowError::InstructionCalledWithInvalidParamType, - )); - } - } - ParameterKind::String => { - let maybe_str = lua.coerce_string(arg)?; - if let Some(s) = maybe_str { - param_map.insert( - param_id.clone(), - ParameterValue::String(s.to_str()?.to_string()), - ); - } else { - return Err(mlua::Error::external( - FlowError::InstructionCalledWithInvalidParamType, - )); - } - } - ParameterKind::Decimal => { - let maybe_dec = lua.coerce_number(arg)?; - if let Some(d) = maybe_dec { - param_map.insert( - param_id.clone(), - ParameterValue::Decimal(d as f32), - ); - } else { - return Err(mlua::Error::external( - FlowError::InstructionCalledWithInvalidParamType, - )); - } - } - ParameterKind::Integer => { - let maybe_int = lua.coerce_integer(arg)?; - if let Some(i) = maybe_int { - param_map.insert( - param_id.clone(), - ParameterValue::Integer(i), - ); - } else { - return Err(mlua::Error::external( - FlowError::InstructionCalledWithInvalidParamType, - )); - } - } - } - } - } - - // Trigger instruction behaviour - let response = ipc::ipc_call( - &engine, - Request::RunInstructions { - instructions: vec![InstructionWithParameters { - instruction: instruction.id().clone(), - parameters: param_map, - }], - }, - ) - .map_err(|e| mlua::Error::external(FlowError::IPCFailure(e)))?; - - match response { - Response::ExecutionOutput { output, evidence } => { - // Add evidence - let mut ev = lua.app_data_mut::>().unwrap(); - for item in &evidence[0] { - ev.push(item.clone()); - } - - // Convert output back to Lua values - let mut outputs = vec![]; - for output_id in instruction.output_order() { - let o = output[0][output_id].clone(); - match o { - ParameterValue::Boolean(b) => { - log::debug!("Boolean {b} returned to Lua"); - outputs.push(mlua::Value::Boolean(b)) - } - ParameterValue::String(s) => { - log::debug!("String {s:?} returned to Lua"); - outputs.push(mlua::Value::String(lua.create_string(s)?)) - } - ParameterValue::Integer(i) => { - log::debug!("Integer {i} returned to Lua"); - outputs.push(mlua::Value::Integer(i)) - } - ParameterValue::Decimal(n) => { - log::debug!("Decimal {n} returned to Lua"); - outputs.push(mlua::Value::Number(n as f64)) - } - } - } - - Ok(mlua::MultiValue::from_vec(outputs)) - } - Response::Error { kind, reason } => { - Err(mlua::Error::external(FlowError::FromInstruction { - error_kind: kind, - reason, - })) - } - _ => unreachable!(), - } - }) - .unwrap(); - engine_tbl - .set(instruction_lua_name.as_str(), instruction_fn) - .unwrap(); - } - lua_env - .globals() - .set(engine_lua_name.as_str(), engine_tbl) - .unwrap(); - } - - // Execute Lua script - // Add parameters and get results - let mut params = vec![]; - for param in action_parameters { - match param { - ParameterValue::Boolean(b) => params.push(mlua::Value::Boolean(b)), - ParameterValue::String(s) => params.push(mlua::Value::String( - lua_env.create_string(s).map_err(|e| FlowError::Lua(e.to_string()))?, - )), - ParameterValue::Integer(i) => params.push(mlua::Value::Integer(i)), - ParameterValue::Decimal(n) => params.push(mlua::Value::Number(n as f64)), - } - } - - lua_env - .load(&action.script) - .set_name(action.friendly_name.clone()) - .exec() - .map_err(|e| FlowError::Lua(e.to_string()))?; - - let res: mlua::MultiValue = lua_env - .globals() - .call_function("run_action", mlua::MultiValue::from_vec(params)) - .map_err(|e| FlowError::Lua(e.to_string()))?; - let res = res.into_vec(); - - // Process return values - let ao = action.outputs(); - if ao.len() != res.len() { - return Err(FlowError::ActionDidntReturnCorrectArgumentCount); - } - for i in 0..ao.len() { - let (_name, kind) = ao[i].clone(); - let out = res[i].clone(); - let ta_out = match out { - mlua::Value::Boolean(b) => ParameterValue::Boolean(b), - mlua::Value::String(s) => ParameterValue::String(s.to_str().unwrap().to_owned()), - mlua::Value::Integer(i) => ParameterValue::Integer(i), - mlua::Value::Number(n) => ParameterValue::Decimal(n as f32), - _ => return Err(FlowError::ActionDidntReturnValidArguments), - }; - if ta_out.kind() != kind { - return Err(FlowError::ActionDidntReturnValidArguments); - } - output.insert(i, ta_out); - } - - let evidence = lua_env.app_data_ref::>().unwrap().clone(); - - Ok((output, evidence)) - } - - /// Update this action configuration to match the inputs and outputs of the provided action. - /// This will panic if the action's ID doesn't match the ID of this configuration already set. - /// Return true if this configuration has changed. - pub fn update(&mut self, action: Action) -> bool { - if self.action_id != action.id { - panic!("ActionConfiguration tried to be updated with a different action!"); - } - - // If number of parameters has changed - if self.parameter_sources.len() != action.parameters().len() { - *self = Self::from(action); - return true; - } - - for (n, value) in &self.parameter_values { - let (_, action_param_kind) = &action.parameters()[*n]; - if value.kind() != *action_param_kind { - // Reset parameters - *self = Self::from(action); - return true; - } - } - - false - } -} - -impl From for ActionConfiguration { - fn from(value: Action) -> Self { - let mut parameter_sources = HashMap::new(); - let mut parameter_values = HashMap::new(); - for (id, (_friendly_name, kind)) in value.parameters().iter().enumerate() { - parameter_sources.insert(id, ActionParameterSource::Literal); - parameter_values.insert(id, kind.default_value()); - } - Self { - action_id: value.id.clone(), - parameter_sources, - parameter_values, - } - } -} - -#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub enum ActionParameterSource { - #[default] - Literal, - FromOutput(usize, usize), -} - -impl fmt::Display for ActionParameterSource { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::FromOutput(step, id) => { - write!(f, "From Step {}: Output {}", step + 1, id + 1) - } - Self::Literal => write!(f, "Literal"), - } - } -} +use std::{collections::HashMap, fmt, sync::Arc}; + +use mlua::{Lua, ObjectLike}; +use serde::{Deserialize, Serialize}; +use testangel_ipc::prelude::*; + +use crate::{ + action_loader::ActionMap, + action_syntax::{Descriptor, DescriptorKind}, + ipc::{self, EngineList, IpcError}, +}; + +pub mod action_v1; + +#[derive(Debug, Copy, Clone, Serialize, Deserialize)] +pub struct VersionedFile { + version: usize, +} + +impl VersionedFile { + /// Get the version of the file + pub fn version(&self) -> usize { + self.version + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Action { + /// The data version of this action. + version: usize, + /// The internal ID of this action. Must be unique. + pub id: String, + /// The friendly name of this action. + pub friendly_name: String, + /// A description of this action. + pub description: String, + /// A group this action belongs to. + pub group: String, + /// The author of this action. + pub author: String, + /// Whether this action should be visible in the flow editor. + pub visible: bool, + /// The Lua code driving this action. + pub script: String, + /// A vector of required instruction IDs for this action to work. + pub required_instructions: Vec, +} + +impl Default for Action { + fn default() -> Self { + Self { + version: 2, + id: uuid::Uuid::new_v4().to_string(), + friendly_name: String::new(), + description: String::new(), + author: String::new(), + visible: true, + group: String::new(), + script: "--: param Integer Example Parameter\n--: return Text Some value to return\nfunction run_action(x)\n \n return 'Hello, world!'\nend\n".to_string(), + required_instructions: Vec::new(), + } + } +} + +impl Action { + /// Get the version of this action. + pub fn version(&self) -> usize { + self.version + } + + /// Generate a new ID for this action. + pub fn new_id(&mut self) { + self.id = uuid::Uuid::new_v4().to_string(); + } + + /// Check that all the instructions this action uses are available. Returns + /// Ok if all instructions are available, otherwise returns a list of + /// missing instructions. + pub fn check_instructions_available( + &self, + engine_list: Arc, + ) -> Result<(), Vec> { + let mut missing = vec![]; + for instruction in &self.required_instructions { + if engine_list.get_instruction_by_id(instruction).is_none() + && !missing.contains(instruction) + { + missing.push(instruction.clone()); + } + } + if missing.is_empty() { + Ok(()) + } else { + Err(missing) + } + } + + /// Get a list of parameters that need to be provided to this action. + pub fn parameters(&self) -> Vec<(String, ParameterKind)> { + let descriptors = Descriptor::parse_all(&self.script); + let mut params = vec![]; + for d in descriptors { + if d.descriptor_kind == DescriptorKind::Parameter { + params.push((d.name.clone(), d.kind)); + } + } + params + } + + /// Get a list of outputs provided by this action. + pub fn outputs(&self) -> Vec<(String, ParameterKind)> { + let descriptors = Descriptor::parse_all(&self.script); + let mut outputs = vec![]; + for d in descriptors { + if d.descriptor_kind == DescriptorKind::Return { + outputs.push((d.name.clone(), d.kind)); + } + } + outputs + } +} + +#[derive(Debug)] +pub enum FlowError { + FromInstruction { + error_kind: ErrorKind, + reason: String, + }, + Lua(String), + IPCFailure(IpcError), + ActionDidntReturnCorrectArgumentCount, + ActionDidntReturnValidArguments, + InstructionCalledWithWrongNumberOfParams, + InstructionCalledWithInvalidParamType, +} + +impl std::error::Error for FlowError {} + +impl fmt::Display for FlowError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::IPCFailure(e) => write!(f, "An IPC call failed ({e:?})."), + Self::Lua(e) => write!(f, "An action script error occurred:\n{}", e), + Self::FromInstruction { error_kind, reason } => write!( + f, + "An instruction returned an error: {error_kind:?}: {reason}" + ), + Self::ActionDidntReturnCorrectArgumentCount => { + write!(f, "The action didn't return the correct amount of values.") + } + Self::ActionDidntReturnValidArguments => { + write!(f, "The action didn't return valid values.") + } + Self::InstructionCalledWithWrongNumberOfParams => write!( + f, + "An instruction was called with the wrong number of parameters." + ), + Self::InstructionCalledWithInvalidParamType => write!( + f, + "An instruction was called with the wrong parameter type." + ), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AutomationFlow { + /// The version of this automation flow file + version: usize, + /// The actions called by this flow + pub actions: Vec, +} + +impl Default for AutomationFlow { + fn default() -> Self { + Self { + version: 1, + actions: vec![], + } + } +} + +impl AutomationFlow { + /// Get the version of this flow. + pub fn version(&self) -> usize { + self.version + } +} + +#[derive(Default, Debug, Clone, Serialize, Deserialize)] +pub struct ActionConfiguration { + pub action_id: String, + pub parameter_sources: HashMap, + pub parameter_values: HashMap, +} +impl ActionConfiguration { + /// Execute this action + pub fn execute( + &self, + action_map: Arc, + engine_map: Arc, + previous_action_outputs: Vec>, + ) -> Result<(HashMap, Vec), FlowError> { + // Find action by ID + let action = action_map.get_action_by_id(&self.action_id).unwrap(); + // Build action parameters + let mut action_parameters = HashMap::new(); + for (id, src) in &self.parameter_sources { + let value = match src { + ActionParameterSource::Literal => self.parameter_values.get(id).unwrap().clone(), + ActionParameterSource::FromOutput(step, id) => previous_action_outputs + .get(*step) + .unwrap() + .get(id) + .unwrap() + .clone(), + }; + action_parameters.insert(*id, value); + } + let mut param_vec = vec![]; + for i in 0..action_parameters.len() { + param_vec.push(action_parameters[&i].clone()); + } + Self::execute_directly(engine_map, &action, param_vec) + } + + #[allow(clippy::type_complexity)] + /// Directly execute an action with a set of parameters. + pub fn execute_directly( + engine_map: Arc, + action: &Action, + action_parameters: Vec, + ) -> Result<(HashMap, Vec), FlowError> { + let mut output = HashMap::new(); + + // Prepare Lua environment + let lua_env = Lua::new(); + lua_env.set_app_data::>(vec![]); + + // unwrap rationale: this will only fail under memory issues + for engine in engine_map.inner().clone() { + let engine_lua_name = engine.lua_name.clone(); + let engine_tbl = lua_env.create_table().unwrap(); + for instruction in engine.instructions.clone() { + let instruction_lua_name = instruction.lua_name().clone(); + let engine = engine.clone(); + let instruction_fn = lua_env + .create_function(move |lua, args: mlua::MultiValue| { + // Check we have the correct number of parameters. + if args.len() != instruction.parameters().len() { + return Err(mlua::Error::external( + FlowError::InstructionCalledWithWrongNumberOfParams, + )); + } + + // Check we have the correct parameter types and convert to parameter map + let mut param_map = HashMap::new(); + for (idx, param_id) in instruction.parameter_order().iter().enumerate() { + if let Some((_name, kind)) = instruction.parameters().get(param_id) { + // Get argument and coerce + let arg = args[idx].clone(); + match kind { + ParameterKind::Boolean => { + if let mlua::Value::Boolean(b) = arg { + param_map.insert( + param_id.clone(), + ParameterValue::Boolean(b), + ); + } else { + return Err(mlua::Error::external( + FlowError::InstructionCalledWithInvalidParamType, + )); + } + } + ParameterKind::String => { + let maybe_str = lua.coerce_string(arg)?; + if let Some(s) = maybe_str { + param_map.insert( + param_id.clone(), + ParameterValue::String(s.to_str()?.to_string()), + ); + } else { + return Err(mlua::Error::external( + FlowError::InstructionCalledWithInvalidParamType, + )); + } + } + ParameterKind::Decimal => { + let maybe_dec = lua.coerce_number(arg)?; + if let Some(d) = maybe_dec { + param_map.insert( + param_id.clone(), + ParameterValue::Decimal(d as f32), + ); + } else { + return Err(mlua::Error::external( + FlowError::InstructionCalledWithInvalidParamType, + )); + } + } + ParameterKind::Integer => { + let maybe_int = lua.coerce_integer(arg)?; + if let Some(i) = maybe_int { + param_map.insert( + param_id.clone(), + ParameterValue::Integer(i), + ); + } else { + return Err(mlua::Error::external( + FlowError::InstructionCalledWithInvalidParamType, + )); + } + } + } + } + } + + // Trigger instruction behaviour + let response = ipc::ipc_call( + &engine, + Request::RunInstructions { + instructions: vec![InstructionWithParameters { + instruction: instruction.id().clone(), + parameters: param_map, + }], + }, + ) + .map_err(|e| mlua::Error::external(FlowError::IPCFailure(e)))?; + + match response { + Response::ExecutionOutput { output, evidence } => { + // Add evidence + let mut ev = lua.app_data_mut::>().unwrap(); + for item in &evidence[0] { + ev.push(item.clone()); + } + + // Convert output back to Lua values + let mut outputs = vec![]; + for output_id in instruction.output_order() { + let o = output[0][output_id].clone(); + match o { + ParameterValue::Boolean(b) => { + log::debug!("Boolean {b} returned to Lua"); + outputs.push(mlua::Value::Boolean(b)) + } + ParameterValue::String(s) => { + log::debug!("String {s:?} returned to Lua"); + outputs.push(mlua::Value::String(lua.create_string(s)?)) + } + ParameterValue::Integer(i) => { + log::debug!("Integer {i} returned to Lua"); + outputs.push(mlua::Value::Integer(i)) + } + ParameterValue::Decimal(n) => { + log::debug!("Decimal {n} returned to Lua"); + outputs.push(mlua::Value::Number(n as f64)) + } + } + } + + Ok(mlua::MultiValue::from_vec(outputs)) + } + Response::Error { kind, reason } => { + Err(mlua::Error::external(FlowError::FromInstruction { + error_kind: kind, + reason, + })) + } + _ => unreachable!(), + } + }) + .unwrap(); + engine_tbl + .set(instruction_lua_name.as_str(), instruction_fn) + .unwrap(); + } + lua_env + .globals() + .set(engine_lua_name.as_str(), engine_tbl) + .unwrap(); + } + + // Execute Lua script + // Add parameters and get results + let mut params = vec![]; + for param in action_parameters { + match param { + ParameterValue::Boolean(b) => params.push(mlua::Value::Boolean(b)), + ParameterValue::String(s) => params.push(mlua::Value::String( + lua_env + .create_string(s) + .map_err(|e| FlowError::Lua(e.to_string()))?, + )), + ParameterValue::Integer(i) => params.push(mlua::Value::Integer(i)), + ParameterValue::Decimal(n) => params.push(mlua::Value::Number(n as f64)), + } + } + + lua_env + .load(&action.script) + .set_name(action.friendly_name.clone()) + .exec() + .map_err(|e| FlowError::Lua(e.to_string()))?; + + let res: mlua::MultiValue = lua_env + .globals() + .call_function("run_action", mlua::MultiValue::from_vec(params)) + .map_err(|e| FlowError::Lua(e.to_string()))?; + let res = res.into_vec(); + + // Process return values + let ao = action.outputs(); + if ao.len() != res.len() { + return Err(FlowError::ActionDidntReturnCorrectArgumentCount); + } + for i in 0..ao.len() { + let (_name, kind) = ao[i].clone(); + let out = res[i].clone(); + let ta_out = match out { + mlua::Value::Boolean(b) => ParameterValue::Boolean(b), + mlua::Value::String(s) => ParameterValue::String(s.to_str().unwrap().to_owned()), + mlua::Value::Integer(i) => ParameterValue::Integer(i), + mlua::Value::Number(n) => ParameterValue::Decimal(n as f32), + _ => return Err(FlowError::ActionDidntReturnValidArguments), + }; + if ta_out.kind() != kind { + return Err(FlowError::ActionDidntReturnValidArguments); + } + output.insert(i, ta_out); + } + + let evidence = lua_env.app_data_ref::>().unwrap().clone(); + + Ok((output, evidence)) + } + + /// Update this action configuration to match the inputs and outputs of the provided action. + /// This will panic if the action's ID doesn't match the ID of this configuration already set. + /// Return true if this configuration has changed. + pub fn update(&mut self, action: Action) -> bool { + if self.action_id != action.id { + panic!("ActionConfiguration tried to be updated with a different action!"); + } + + // If number of parameters has changed + if self.parameter_sources.len() != action.parameters().len() { + *self = Self::from(action); + return true; + } + + for (n, value) in &self.parameter_values { + let (_, action_param_kind) = &action.parameters()[*n]; + if value.kind() != *action_param_kind { + // Reset parameters + *self = Self::from(action); + return true; + } + } + + false + } +} + +impl From for ActionConfiguration { + fn from(value: Action) -> Self { + let mut parameter_sources = HashMap::new(); + let mut parameter_values = HashMap::new(); + for (id, (_friendly_name, kind)) in value.parameters().iter().enumerate() { + parameter_sources.insert(id, ActionParameterSource::Literal); + parameter_values.insert(id, kind.default_value()); + } + Self { + action_id: value.id.clone(), + parameter_sources, + parameter_values, + } + } +} + +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum ActionParameterSource { + #[default] + Literal, + FromOutput(usize, usize), +} + +impl fmt::Display for ActionParameterSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::FromOutput(step, id) => { + write!(f, "From Step {}: Output {}", step + 1, id + 1) + } + Self::Literal => write!(f, "Literal"), + } + } +} diff --git a/testangel/src/ui/actions/mod.rs b/testangel/src/ui/actions/mod.rs index bc74be8..a594f0b 100644 --- a/testangel/src/ui/actions/mod.rs +++ b/testangel/src/ui/actions/mod.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, fs, path::PathBuf, rc::Rc, sync::Arc}; +use std::{collections::HashMap, fmt, fs, path::PathBuf, rc::Rc, sync::Arc}; use adw::prelude::*; use relm4::{ @@ -27,9 +27,9 @@ pub enum SaveOrOpenActionError { MissingInstruction(String), } -impl ToString for SaveOrOpenActionError { - fn to_string(&self) -> String { - match self { +impl fmt::Display for SaveOrOpenActionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", match self { Self::IoError(e) => lang::lookup_with_args("action-save-open-error-io-error", { let mut map = HashMap::new(); map.insert("error", e.to_string().into()); @@ -59,7 +59,7 @@ impl ToString for SaveOrOpenActionError { map }) } - } + }) } } diff --git a/testangel/src/ui/flows/execution_dialog.rs b/testangel/src/ui/flows/execution_dialog.rs index c02e278..d59aa1f 100644 --- a/testangel/src/ui/flows/execution_dialog.rs +++ b/testangel/src/ui/flows/execution_dialog.rs @@ -1,276 +1,292 @@ -use std::{collections::HashMap, fs, sync::Arc}; - -use adw::prelude::*; -use arboard::Clipboard; -use base64::{prelude::BASE64_STANDARD, Engine}; -use evidenceangel::{Author, EvidencePackage}; -use relm4::{adw, gtk, Component, ComponentParts, RelmWidgetExt}; -use testangel::{ - action_loader::ActionMap, - ipc::EngineList, - types::{AutomationFlow, FlowError}, -}; -use testangel_ipc::prelude::{Evidence, EvidenceContent, ParameterValue}; - -use crate::ui::{file_filters, lang}; - -#[derive(Debug)] -pub enum ExecutionDialogCommandOutput { - /// Execution completed with the resulting evidence - Complete(Vec), - - /// Execution failed at the given step and for the given reason - Failed(usize, FlowError, Vec), -} - -#[derive(Debug)] -pub struct ExecutionDialogInit { - pub flow: AutomationFlow, - pub engine_list: Arc, - pub action_map: Arc, -} - -#[derive(Debug)] -pub enum ExecutionDialogInput { - Close, - FailedToGenerateEvidence(evidenceangel::Error), - SaveEvidence(Vec), -} - -#[derive(Debug)] -pub struct ExecutionDialog; - -impl ExecutionDialog { - /// Create the absolute barebones of a message dialog, allowing for custom button and response mapping. - fn create_message_dialog(&self, title: S, message: S) -> adw::MessageDialog - where - S: AsRef, - { - adw::MessageDialog::builder() - .title(title.as_ref()) - .heading(title.as_ref()) - .body(message.as_ref()) - .modal(true) - .build() - } -} - -fn add_evidence(mut evp: EvidencePackage, evidence: Vec) -> evidenceangel::Result<()> { - let tc = evp.create_test_case("TestAngel Test Case")?; - let tc_evidence = tc.evidence_mut(); - for ev in evidence { - let Evidence { label, content } = ev; - let mut ea_ev = match content { - EvidenceContent::Textual(text) => evidenceangel::Evidence::new(evidenceangel::EvidenceKind::Text, evidenceangel::EvidenceData::Text { content: text }), - EvidenceContent::ImageAsPngBase64(base64) => evidenceangel::Evidence::new(evidenceangel::EvidenceKind::Image, evidenceangel::EvidenceData::Base64 { data: BASE64_STANDARD.decode(base64).map_err(|e| evidenceangel::Error::OtherExportError(Box::new(e)))? }), - }; - if !label.is_empty() { - ea_ev.set_caption(Some(label)); - } - tc_evidence.push(ea_ev); - } - evp.save()?; - Ok(()) -} - -#[relm4::component(pub)] -impl Component for ExecutionDialog { - type Init = ExecutionDialogInit; - type Input = ExecutionDialogInput; - type Output = (); - type CommandOutput = ExecutionDialogCommandOutput; - - view! { - #[root] - adw::Window { - set_modal: true, - set_resizable: false, - - gtk::Box { - set_orientation: gtk::Orientation::Vertical, - set_spacing: 5, - set_margin_all: 50, - - gtk::Spinner { - set_spinning: true, - }, - gtk::Label { - set_label: &lang::lookup("flow-execution-running"), - }, - }, - }, - } - - fn init( - init: Self::Init, - root: Self::Root, - sender: relm4::ComponentSender, - ) -> relm4::ComponentParts { - let model = ExecutionDialog; - let widgets = view_output!(); - let flow = init.flow; - let engine_list = init.engine_list.clone(); - let action_map = init.action_map.clone(); - - sender.spawn_oneshot_command(move || { - let mut outputs: Vec> = Vec::new(); - let mut evidence = Vec::new(); - - for engine in engine_list.inner() { - if engine.reset_state().is_err() { - evidence.push(Evidence { - label: String::from("WARNING: State Warning"), - content: EvidenceContent::Textual(String::from("For this test execution, the state couldn't be correctly reset. Some results may not be accurate.")) - }); - } - } - - for (step, action_config) in flow.actions.iter().enumerate() { - log::debug!("Output state: {outputs:?}"); - log::debug!("Evidence state: {evidence:?}"); - log::debug!("Executing: {action_config:?}"); - match action_config.execute( - action_map.clone(), - engine_list.clone(), - outputs.clone(), - ) { - Ok((output, ev)) => { - outputs.push(output); - evidence = [evidence, ev].concat(); - } - Err(e) => { - return ExecutionDialogCommandOutput::Failed(step + 1, e, evidence); - } - } - } - - ExecutionDialogCommandOutput::Complete(evidence) - }); - - ComponentParts { model, widgets } - } - - fn update( - &mut self, - message: Self::Input, - sender: relm4::ComponentSender, - root: &Self::Root, - ) { - match message { - ExecutionDialogInput::Close => root.destroy(), - ExecutionDialogInput::FailedToGenerateEvidence(reason) => { - let dialog = self.create_message_dialog( - lang::lookup("evidence-failed"), - lang::lookup_with_args("evidence-failed-message", { - let mut map = HashMap::new(); - map.insert("reason", reason.to_string().into()); - map - }), - ); - dialog.set_transient_for(Some(root)); - dialog.add_response("ok", &lang::lookup("ok")); - dialog.set_default_response(Some("ok")); - let sender_c = sender.clone(); - dialog.connect_response(None, move |dlg, _response| { - sender_c.input(ExecutionDialogInput::Close); - dlg.close(); - }); - dialog.set_visible(true); - } - ExecutionDialogInput::SaveEvidence(evidence) => { - // Present save dialog - let dialog = gtk::FileDialog::builder() - .modal(true) - .title(lang::lookup("evidence-save-title")) - .initial_name(lang::lookup("evidence-default-name")) - .filters(&file_filters::filter_list(vec![ - file_filters::evps(), - file_filters::all(), - ])) - .build(); - - let sender_c = sender.clone(); - dialog.save( - Some(root), - Some(&relm4::gtk::gio::Cancellable::new()), - move |res| { - if let Ok(file) = res { - let path = file.path().unwrap().with_extension("evp"); - match fs::exists(&path) { - Ok(exists) => { - let evp = if exists { - // Open - EvidencePackage::open(path) - } else { - // Create - EvidencePackage::new(path, "TestAngel Evidence".to_string(), vec![Author::new("Anonymous Author")]) - }; - - if let Err(e) = &evp { - log::warn!("Failed to create/open output file: {e}"); - } - let evp = evp.unwrap(); - - // Append new TC - if let Err(e) = add_evidence(evp, evidence.clone()) { - sender_c.input(ExecutionDialogInput::FailedToGenerateEvidence(e)); - } - }, - Err(e) => log::warn!("Failed to check if output file exists: {e}"), - } - } - sender_c.input(ExecutionDialogInput::Close); - }, - ); - } - } - } - - fn update_cmd( - &mut self, - message: Self::CommandOutput, - sender: relm4::ComponentSender, - root: &Self::Root, - ) { - match message { - ExecutionDialogCommandOutput::Complete(evidence) => { - log::info!("Execution complete."); - sender.input(ExecutionDialogInput::SaveEvidence(evidence)); - } - - ExecutionDialogCommandOutput::Failed(step, reason, evidence) => { - log::warn!("Execution failed. Evidence: {evidence:?}"); - let dialog = self.create_message_dialog( - lang::lookup("flow-execution-failed"), - lang::lookup_with_args("flow-execution-failed-message", { - let mut map = HashMap::new(); - map.insert("step", step.into()); - map.insert("reason", reason.to_string().into()); - map - }), - ); - dialog.set_transient_for(Some(root)); - if !evidence.is_empty() { - dialog - .add_response("save", &lang::lookup("flow-execution-save-evidence-anyway")); - } - dialog.add_response("copy", &lang::lookup("copy-ok")); - dialog.add_response("ok", &lang::lookup("ok")); - dialog.set_default_response(Some("ok")); - let sender_c = sender.clone(); - dialog.connect_response(None, move |dlg, response| { - if response == "copy" { - if let Ok(mut cb) = Clipboard::new() { - let _ = cb.set_text(reason.to_string()); - } - } else if response == "save" { - sender_c.input(ExecutionDialogInput::SaveEvidence(evidence.clone())); - } - sender_c.input(ExecutionDialogInput::Close); - dlg.close(); - }); - dialog.set_visible(true); - } - } - } -} +use std::{collections::HashMap, fs, sync::Arc}; + +use adw::prelude::*; +use arboard::Clipboard; +use base64::{prelude::BASE64_STANDARD, Engine}; +use evidenceangel::{Author, EvidencePackage}; +use relm4::{adw, gtk, Component, ComponentParts, RelmWidgetExt}; +use testangel::{ + action_loader::ActionMap, + ipc::EngineList, + types::{AutomationFlow, FlowError}, +}; +use testangel_ipc::prelude::{Evidence, EvidenceContent, ParameterValue}; + +use crate::ui::{file_filters, lang}; + +#[derive(Debug)] +pub enum ExecutionDialogCommandOutput { + /// Execution completed with the resulting evidence + Complete(Vec), + + /// Execution failed at the given step and for the given reason + Failed(usize, FlowError, Vec), +} + +#[derive(Debug)] +pub struct ExecutionDialogInit { + pub flow: AutomationFlow, + pub engine_list: Arc, + pub action_map: Arc, +} + +#[derive(Debug)] +pub enum ExecutionDialogInput { + Close, + FailedToGenerateEvidence(evidenceangel::Error), + SaveEvidence(Vec), +} + +#[derive(Debug)] +pub struct ExecutionDialog; + +impl ExecutionDialog { + /// Create the absolute barebones of a message dialog, allowing for custom button and response mapping. + fn create_message_dialog(&self, title: S, message: S) -> adw::MessageDialog + where + S: AsRef, + { + adw::MessageDialog::builder() + .title(title.as_ref()) + .heading(title.as_ref()) + .body(message.as_ref()) + .modal(true) + .build() + } +} + +fn add_evidence(mut evp: EvidencePackage, evidence: Vec) -> evidenceangel::Result<()> { + let tc = evp.create_test_case("TestAngel Test Case")?; + let tc_evidence = tc.evidence_mut(); + for ev in evidence { + let Evidence { label, content } = ev; + let mut ea_ev = match content { + EvidenceContent::Textual(text) => evidenceangel::Evidence::new( + evidenceangel::EvidenceKind::Text, + evidenceangel::EvidenceData::Text { content: text }, + ), + EvidenceContent::ImageAsPngBase64(base64) => evidenceangel::Evidence::new( + evidenceangel::EvidenceKind::Image, + evidenceangel::EvidenceData::Base64 { + data: BASE64_STANDARD + .decode(base64) + .map_err(|e| evidenceangel::Error::OtherExportError(Box::new(e)))?, + }, + ), + }; + if !label.is_empty() { + ea_ev.set_caption(Some(label)); + } + tc_evidence.push(ea_ev); + } + evp.save()?; + Ok(()) +} + +#[relm4::component(pub)] +impl Component for ExecutionDialog { + type Init = ExecutionDialogInit; + type Input = ExecutionDialogInput; + type Output = (); + type CommandOutput = ExecutionDialogCommandOutput; + + view! { + #[root] + adw::Window { + set_modal: true, + set_resizable: false, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 5, + set_margin_all: 50, + + gtk::Spinner { + set_spinning: true, + }, + gtk::Label { + set_label: &lang::lookup("flow-execution-running"), + }, + }, + }, + } + + fn init( + init: Self::Init, + root: Self::Root, + sender: relm4::ComponentSender, + ) -> relm4::ComponentParts { + let model = ExecutionDialog; + let widgets = view_output!(); + let flow = init.flow; + let engine_list = init.engine_list.clone(); + let action_map = init.action_map.clone(); + + sender.spawn_oneshot_command(move || { + let mut outputs: Vec> = Vec::new(); + let mut evidence = Vec::new(); + + for engine in engine_list.inner() { + if engine.reset_state().is_err() { + evidence.push(Evidence { + label: String::from("WARNING: State Warning"), + content: EvidenceContent::Textual(String::from("For this test execution, the state couldn't be correctly reset. Some results may not be accurate.")) + }); + } + } + + for (step, action_config) in flow.actions.iter().enumerate() { + log::debug!("Output state: {outputs:?}"); + log::debug!("Evidence state: {evidence:?}"); + log::debug!("Executing: {action_config:?}"); + match action_config.execute( + action_map.clone(), + engine_list.clone(), + outputs.clone(), + ) { + Ok((output, ev)) => { + outputs.push(output); + evidence = [evidence, ev].concat(); + } + Err(e) => { + return ExecutionDialogCommandOutput::Failed(step + 1, e, evidence); + } + } + } + + ExecutionDialogCommandOutput::Complete(evidence) + }); + + ComponentParts { model, widgets } + } + + fn update( + &mut self, + message: Self::Input, + sender: relm4::ComponentSender, + root: &Self::Root, + ) { + match message { + ExecutionDialogInput::Close => root.destroy(), + ExecutionDialogInput::FailedToGenerateEvidence(reason) => { + let dialog = self.create_message_dialog( + lang::lookup("evidence-failed"), + lang::lookup_with_args("evidence-failed-message", { + let mut map = HashMap::new(); + map.insert("reason", reason.to_string().into()); + map + }), + ); + dialog.set_transient_for(Some(root)); + dialog.add_response("ok", &lang::lookup("ok")); + dialog.set_default_response(Some("ok")); + let sender_c = sender.clone(); + dialog.connect_response(None, move |dlg, _response| { + sender_c.input(ExecutionDialogInput::Close); + dlg.close(); + }); + dialog.set_visible(true); + } + ExecutionDialogInput::SaveEvidence(evidence) => { + // Present save dialog + let dialog = gtk::FileDialog::builder() + .modal(true) + .title(lang::lookup("evidence-save-title")) + .initial_name(lang::lookup("evidence-default-name")) + .filters(&file_filters::filter_list(vec![ + file_filters::evps(), + file_filters::all(), + ])) + .build(); + + let sender_c = sender.clone(); + dialog.save( + Some(root), + Some(&relm4::gtk::gio::Cancellable::new()), + move |res| { + if let Ok(file) = res { + let path = file.path().unwrap().with_extension("evp"); + match fs::exists(&path) { + Ok(exists) => { + let evp = if exists { + // Open + EvidencePackage::open(path) + } else { + // Create + EvidencePackage::new( + path, + "TestAngel Evidence".to_string(), + vec![Author::new("Anonymous Author")], + ) + }; + + if let Err(e) = &evp { + log::warn!("Failed to create/open output file: {e}"); + } + let evp = evp.unwrap(); + + // Append new TC + if let Err(e) = add_evidence(evp, evidence.clone()) { + sender_c.input( + ExecutionDialogInput::FailedToGenerateEvidence(e), + ); + } + } + Err(e) => log::warn!("Failed to check if output file exists: {e}"), + } + } + sender_c.input(ExecutionDialogInput::Close); + }, + ); + } + } + } + + fn update_cmd( + &mut self, + message: Self::CommandOutput, + sender: relm4::ComponentSender, + root: &Self::Root, + ) { + match message { + ExecutionDialogCommandOutput::Complete(evidence) => { + log::info!("Execution complete."); + sender.input(ExecutionDialogInput::SaveEvidence(evidence)); + } + + ExecutionDialogCommandOutput::Failed(step, reason, evidence) => { + log::warn!("Execution failed. Evidence: {evidence:?}"); + let dialog = self.create_message_dialog( + lang::lookup("flow-execution-failed"), + lang::lookup_with_args("flow-execution-failed-message", { + let mut map = HashMap::new(); + map.insert("step", step.into()); + map.insert("reason", reason.to_string().into()); + map + }), + ); + dialog.set_transient_for(Some(root)); + if !evidence.is_empty() { + dialog + .add_response("save", &lang::lookup("flow-execution-save-evidence-anyway")); + } + dialog.add_response("copy", &lang::lookup("copy-ok")); + dialog.add_response("ok", &lang::lookup("ok")); + dialog.set_default_response(Some("ok")); + let sender_c = sender.clone(); + dialog.connect_response(None, move |dlg, response| { + if response == "copy" { + if let Ok(mut cb) = Clipboard::new() { + let _ = cb.set_text(reason.to_string()); + } + } else if response == "save" { + sender_c.input(ExecutionDialogInput::SaveEvidence(evidence.clone())); + } + sender_c.input(ExecutionDialogInput::Close); + dlg.close(); + }); + dialog.set_visible(true); + } + } + } +} diff --git a/testangel/src/ui/flows/mod.rs b/testangel/src/ui/flows/mod.rs index 02d9ce5..32eb05e 100644 --- a/testangel/src/ui/flows/mod.rs +++ b/testangel/src/ui/flows/mod.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, fs, path::PathBuf, rc::Rc, sync::Arc}; +use std::{collections::HashMap, fmt, fs, path::PathBuf, rc::Rc, sync::Arc}; use adw::prelude::*; use relm4::{ @@ -27,9 +27,9 @@ pub enum SaveOrOpenFlowError { MissingAction(usize, String), } -impl ToString for SaveOrOpenFlowError { - fn to_string(&self) -> String { - match self { +impl fmt::Display for SaveOrOpenFlowError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", match self { Self::IoError(e) => lang::lookup_with_args("flow-save-open-error-io-error", { let mut map = HashMap::new(); map.insert("error", e.to_string().into()); @@ -60,7 +60,7 @@ impl ToString for SaveOrOpenFlowError { map }) } - } + }) } }