use oxify_model::{ExecutionContext, Workflow, WorkflowId};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowTestCase {
pub name: String,
pub description: Option<String>,
pub inputs: HashMap<String, Value>,
pub expected_outputs: HashMap<String, ExpectedValue>,
pub expected_status: ExpectedStatus,
pub timeout_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ExpectedValue {
Exact { value: Value },
Pattern { pattern: String },
Range { min: f64, max: f64 },
JsonSchema { schema: Value },
Predicate { expression: String },
Exists,
Null,
}
impl ExpectedValue {
pub fn matches(&self, actual: &Value) -> bool {
match self {
ExpectedValue::Exact { value } => actual == value,
ExpectedValue::Pattern { pattern } => {
if let Some(actual_str) = actual.as_str() {
regex::Regex::new(pattern)
.ok()
.map(|re| re.is_match(actual_str))
.unwrap_or(false)
} else {
false
}
}
ExpectedValue::Range { min, max } => {
if let Some(num) = actual.as_f64() {
num >= *min && num <= *max
} else {
false
}
}
ExpectedValue::JsonSchema { .. } => {
true
}
ExpectedValue::Predicate { .. } => {
true
}
ExpectedValue::Exists => !actual.is_null(),
ExpectedValue::Null => actual.is_null(),
}
}
pub fn describe(&self) -> String {
match self {
ExpectedValue::Exact { value } => format!("equals {}", value),
ExpectedValue::Pattern { pattern } => format!("matches pattern /{}/", pattern),
ExpectedValue::Range { min, max } => format!("in range [{}, {}]", min, max),
ExpectedValue::JsonSchema { .. } => "matches JSON schema".to_string(),
ExpectedValue::Predicate { expression } => {
format!("satisfies predicate: {}", expression)
}
ExpectedValue::Exists => "is not null".to_string(),
ExpectedValue::Null => "is null".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ExpectedStatus {
Success,
Failure,
Any,
}
#[derive(Debug, Clone)]
pub struct AssertionResult {
pub variable: String,
pub passed: bool,
pub expected: ExpectedValue,
pub actual: Value,
pub message: Option<String>,
}
impl AssertionResult {
pub fn pass(variable: String, expected: ExpectedValue, actual: Value) -> Self {
Self {
variable,
passed: true,
expected,
actual,
message: None,
}
}
pub fn fail(variable: String, expected: ExpectedValue, actual: Value, message: String) -> Self {
Self {
variable,
passed: false,
expected,
actual,
message: Some(message),
}
}
}
#[derive(Debug, Clone)]
pub struct TestResult {
pub test_name: String,
pub passed: bool,
pub assertions: Vec<AssertionResult>,
pub execution_time_ms: u64,
pub error: Option<String>,
}
impl TestResult {
pub fn passed_assertions(&self) -> usize {
self.assertions.iter().filter(|a| a.passed).count()
}
pub fn failed_assertions(&self) -> usize {
self.assertions.iter().filter(|a| !a.passed).count()
}
pub fn summary(&self) -> String {
format!(
"{}: {} ({}/{} assertions passed)",
self.test_name,
if self.passed { "PASS" } else { "FAIL" },
self.passed_assertions(),
self.assertions.len()
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestSuite {
pub name: String,
pub tests: Vec<WorkflowTestCase>,
}
#[derive(Debug, Clone)]
pub struct TestReport {
pub suite_name: String,
pub results: Vec<TestResult>,
pub total_time_ms: u64,
}
impl TestReport {
pub fn passed_tests(&self) -> usize {
self.results.iter().filter(|r| r.passed).count()
}
pub fn failed_tests(&self) -> usize {
self.results.iter().filter(|r| !r.passed).count()
}
pub fn pass_rate(&self) -> f32 {
if self.results.is_empty() {
0.0
} else {
self.passed_tests() as f32 / self.results.len() as f32
}
}
pub fn summary(&self) -> String {
format!(
"Test Suite: {}\n{}/{} tests passed ({:.1}%)\nTotal time: {}ms",
self.suite_name,
self.passed_tests(),
self.results.len(),
self.pass_rate() * 100.0,
self.total_time_ms
)
}
}
pub struct WorkflowTestRunner {
pub default_timeout_ms: u64,
}
impl Default for WorkflowTestRunner {
fn default() -> Self {
Self::new()
}
}
impl WorkflowTestRunner {
pub fn new() -> Self {
Self {
default_timeout_ms: 30000, }
}
pub fn with_timeout(timeout_ms: u64) -> Self {
Self {
default_timeout_ms: timeout_ms,
}
}
pub async fn run_test(&self, workflow: &Workflow, test_case: &WorkflowTestCase) -> TestResult {
let start = std::time::Instant::now();
let mut context = ExecutionContext::new(WorkflowId::new_v4());
for (key, value) in &test_case.inputs {
context.variables.insert(key.clone(), value.clone());
}
let _execution_result = self.execute_workflow(workflow, context).await;
let assertions = self.check_outputs(test_case, &test_case.inputs);
let passed = assertions.iter().all(|a| a.passed);
TestResult {
test_name: test_case.name.clone(),
passed,
assertions,
execution_time_ms: start.elapsed().as_millis() as u64,
error: None,
}
}
pub async fn run_suite(&self, workflow: &Workflow, suite: &TestSuite) -> TestReport {
let start = std::time::Instant::now();
let mut results = Vec::new();
for test_case in &suite.tests {
let result = self.run_test(workflow, test_case).await;
results.push(result);
}
TestReport {
suite_name: suite.name.clone(),
results,
total_time_ms: start.elapsed().as_millis() as u64,
}
}
async fn execute_workflow(
&self,
_workflow: &Workflow,
_context: ExecutionContext,
) -> Result<ExecutionContext, String> {
Ok(ExecutionContext::new(WorkflowId::new_v4()))
}
fn check_outputs(
&self,
test_case: &WorkflowTestCase,
actual_outputs: &HashMap<String, Value>,
) -> Vec<AssertionResult> {
let mut assertions = Vec::new();
for (variable, expected) in &test_case.expected_outputs {
let actual = actual_outputs.get(variable).cloned().unwrap_or(Value::Null);
if expected.matches(&actual) {
assertions.push(AssertionResult::pass(
variable.clone(),
expected.clone(),
actual,
));
} else {
let message = format!(
"Expected {} but got {}",
expected.describe(),
serde_json::to_string(&actual).unwrap_or_else(|_| "?".to_string())
);
assertions.push(AssertionResult::fail(
variable.clone(),
expected.clone(),
actual,
message,
));
}
}
assertions
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_expected_value_exact() {
let expected = ExpectedValue::Exact {
value: Value::String("hello".to_string()),
};
assert!(expected.matches(&Value::String("hello".to_string())));
assert!(!expected.matches(&Value::String("world".to_string())));
}
#[test]
fn test_expected_value_pattern() {
let expected = ExpectedValue::Pattern {
pattern: "^test.*".to_string(),
};
assert!(expected.matches(&Value::String("test123".to_string())));
assert!(!expected.matches(&Value::String("hello".to_string())));
}
#[test]
fn test_expected_value_range() {
let expected = ExpectedValue::Range {
min: 10.0,
max: 20.0,
};
assert!(expected.matches(&Value::Number(15.into())));
assert!(!expected.matches(&Value::Number(5.into())));
assert!(!expected.matches(&Value::Number(25.into())));
}
#[test]
fn test_expected_value_exists() {
let expected = ExpectedValue::Exists;
assert!(expected.matches(&Value::String("hello".to_string())));
assert!(expected.matches(&Value::Number(42.into())));
assert!(!expected.matches(&Value::Null));
}
#[tokio::test]
async fn test_workflow_test_runner() {
let runner = WorkflowTestRunner::new();
let workflow = Workflow {
metadata: oxify_model::WorkflowMetadata::new("Test".to_string()),
nodes: vec![],
edges: vec![],
};
let mut expected_outputs = HashMap::new();
expected_outputs.insert(
"result".to_string(),
ExpectedValue::Exact {
value: Value::String("success".to_string()),
},
);
let test_case = WorkflowTestCase {
name: "test1".to_string(),
description: Some("Test workflow execution".to_string()),
inputs: [("input".to_string(), Value::String("test".to_string()))]
.iter()
.cloned()
.collect(),
expected_outputs,
expected_status: ExpectedStatus::Success,
timeout_ms: Some(5000),
};
let result = runner.run_test(&workflow, &test_case).await;
assert_eq!(result.test_name, "test1");
assert!(result.execution_time_ms < 5000);
}
#[tokio::test]
async fn test_test_suite_execution() {
let runner = WorkflowTestRunner::new();
let workflow = Workflow {
metadata: oxify_model::WorkflowMetadata::new("Test".to_string()),
nodes: vec![],
edges: vec![],
};
let suite = TestSuite {
name: "Integration Tests".to_string(),
tests: vec![
WorkflowTestCase {
name: "test1".to_string(),
description: None,
inputs: HashMap::new(),
expected_outputs: HashMap::new(),
expected_status: ExpectedStatus::Success,
timeout_ms: None,
},
WorkflowTestCase {
name: "test2".to_string(),
description: None,
inputs: HashMap::new(),
expected_outputs: HashMap::new(),
expected_status: ExpectedStatus::Success,
timeout_ms: None,
},
],
};
let report = runner.run_suite(&workflow, &suite).await;
assert_eq!(report.suite_name, "Integration Tests");
assert_eq!(report.results.len(), 2);
}
}