Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ lazy_static = "1.4"
# Actor framework for ReAct agents
ractor = "0"

# TOML deserialization (also used transitively by config)
toml = "0.8"

# Configuration file support (multi-format)
config = { version = "0.14", features = [
"toml",
Expand Down
1 change: 1 addition & 0 deletions crates/mofa-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ mofa-kernel = { path = "../mofa-kernel", version = "0.1", features = [
] }
mofa-runtime = { path = "../mofa-runtime", version = "0.1" }
mofa-foundation = { path = "../mofa-foundation", version = "0.1" }
mofa-testing = { path = "../../tests", version = "0.1" }
config.workspace = true
tokio = { workspace = true }
thiserror = { workspace = true }
Expand Down
56 changes: 56 additions & 0 deletions crates/mofa-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,24 @@ pub enum Commands {
dora: bool,
},

/// Run a testing DSL case file
TestDsl {
/// TOML DSL file to execute
file: PathBuf,

/// Optional canonical artifact file path
#[arg(long)]
artifact_out: Option<PathBuf>,

/// Optional report file path
#[arg(long)]
report_out: Option<PathBuf>,

/// Report file format
#[arg(long, value_enum, default_value_t = TestDslReportFormat::Json)]
report_format: TestDslReportFormat,
},

/// Run a dora dataflow
#[cfg(feature = "dora")]
Dataflow {
Expand Down Expand Up @@ -219,6 +237,12 @@ pub enum DatabaseType {
Sqlite,
}

#[derive(Clone, Copy, Debug, ValueEnum, PartialEq, Eq)]
pub enum TestDslReportFormat {
Json,
Text,
}

impl std::fmt::Display for DatabaseType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Expand Down Expand Up @@ -725,6 +749,38 @@ mod tests {
assert!(parsed.is_ok(), "doctor ci strict json should parse");
}

#[test]
fn test_test_dsl_parses() {
let parsed = Cli::try_parse_from(["mofa", "test-dsl", "tests/examples/simple_agent.toml"]);
assert!(parsed.is_ok(), "test-dsl command should parse");
}

#[test]
fn test_test_dsl_report_flags_parse() {
let parsed = Cli::try_parse_from([
"mofa",
"test-dsl",
"tests/examples/simple_agent.toml",
"--report-out",
"/tmp/report.json",
"--report-format",
"json",
]);
assert!(parsed.is_ok(), "test-dsl report flags should parse");
}

#[test]
fn test_test_dsl_artifact_flag_parses() {
let parsed = Cli::try_parse_from([
"mofa",
"test-dsl",
"tests/examples/simple_agent.toml",
"--artifact-out",
"/tmp/artifact.json",
]);
assert!(parsed.is_ok(), "test-dsl artifact flag should parse");
}

#[test]
fn test_rag_index_parses() {
let parsed = Cli::try_parse_from([
Expand Down
1 change: 1 addition & 0 deletions crates/mofa-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ pub mod new;
pub mod plugin;
pub mod rag;
pub mod run;
pub mod test_dsl;
pub mod session;
pub mod tool;
151 changes: 151 additions & 0 deletions crates/mofa-cli/src/commands/test_dsl.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
//! `mofa test-dsl` command implementation

use crate::CliError;
use crate::cli::TestDslReportFormat;
use crate::output::OutputFormat;
use mofa_testing::{
AgentRunArtifact, DslError, JsonFormatter, ReportFormatter, TestCaseResult, TestReport,
TestStatus, TextFormatter, TestCaseDsl, assertion_error_from_outcomes,
collect_assertion_outcomes, execute_test_case,
};
use serde::Serialize;
use serde_json::json;
use std::path::Path;

#[derive(Debug, Serialize)]
struct TestDslSummary {
name: String,
success: bool,
output_text: Option<String>,
duration_ms: u128,
tool_calls: Vec<String>,
workspace_root: String,
}

/// Execute one TOML DSL test case through the testing runner.
pub async fn run(
path: &Path,
format: OutputFormat,
artifact_out: Option<&Path>,
report_out: Option<&Path>,
report_format: TestDslReportFormat,
) -> Result<(), CliError> {
let case = TestCaseDsl::from_toml_file(path).map_err(map_dsl_error)?;
let result = execute_test_case(&case).await.map_err(map_dsl_error)?;
let assertions = collect_assertion_outcomes(&case, &result);
let artifact = AgentRunArtifact::from_run_result(&case, &result, assertions.clone());
let report = build_report(&artifact);

if let Some(artifact_out) = artifact_out {
write_artifact(artifact_out, &artifact)?;
}

if let Some(report_out) = report_out {
write_report(report_out, report_format, &report)?;
}

let summary = TestDslSummary {
name: case.name,
success: result.is_success(),
output_text: result.output_text(),
duration_ms: result.duration.as_millis(),
tool_calls: result
.metadata
.tool_calls
.iter()
.map(|record| record.tool_name.clone())
.collect(),
workspace_root: result.metadata.workspace_root.display().to_string(),
};

match format {
OutputFormat::Json => {
let output = json!({
"success": true,
"case": summary,
});
println!("{}", serde_json::to_string_pretty(&output)?);
}
_ => {
println!("case: {}", summary.name);
println!("status: {}", if summary.success { "passed" } else { "failed" });
if let Some(output_text) = &summary.output_text {
println!("output: {}", output_text);
}
if !summary.tool_calls.is_empty() {
println!("tool_calls: {}", summary.tool_calls.join(", "));
}
println!("duration_ms: {}", summary.duration_ms);
}
}

if let Some(error) = assertion_error_from_outcomes(&assertions) {
return Err(map_dsl_error(error));
}

Ok(())
}

fn build_report(artifact: &AgentRunArtifact) -> TestReport {
let status = if artifact.status == "passed" {
TestStatus::Passed
} else {
TestStatus::Failed
};
let error = artifact
.runner_error
.clone()
.or_else(|| {
artifact
.assertions
.iter()
.find(|item| !item.passed)
.map(|item| format!("assertion failed: {}", item.kind))
});
let metadata = vec![
(
"execution_id".to_string(),
artifact.execution_id.clone(),
),
(
"workspace_root".to_string(),
artifact.workspace_root.clone(),
),
(
"tool_calls".to_string(),
artifact.tool_calls.len().to_string(),
),
];

TestReport {
suite_name: "dsl".to_string(),
results: vec![TestCaseResult {
name: artifact.case_name.clone(),
status,
duration: std::time::Duration::from_millis(artifact.duration_ms),
error,
metadata,
}],
total_duration: std::time::Duration::from_millis(artifact.duration_ms),
timestamp: artifact.started_at_ms,
}
}

fn write_artifact(path: &Path, artifact: &AgentRunArtifact) -> Result<(), CliError> {
let body = serde_json::to_string_pretty(artifact)?;
std::fs::write(path, body)?;
Ok(())
}

fn write_report(path: &Path, format: TestDslReportFormat, report: &TestReport) -> Result<(), CliError> {
let body = match format {
TestDslReportFormat::Json => JsonFormatter.format(report),
TestDslReportFormat::Text => TextFormatter.format(report),
};
std::fs::write(path, body)?;
Ok(())
}

fn map_dsl_error(error: DslError) -> CliError {
CliError::Other(format!("DSL test failed: {error}"))
}
19 changes: 19 additions & 0 deletions crates/mofa-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ fn main() {

async fn run_command(cli: Cli) -> CliResult<()> {
use cli::Commands;
let output_format = cli.output_format.unwrap_or_default();

// Initialize context for commands that need backend services
let needs_context = matches!(
Expand Down Expand Up @@ -121,6 +122,24 @@ async fn run_command(cli: Cli) -> CliResult<()> {
commands::run::run(&config, dora)?;
}

Some(Commands::TestDsl {
file,
artifact_out,
report_out,
report_format,
}) => {
commands::test_dsl::run(
&file,
output_format,
artifact_out.as_deref(),
report_out.as_deref(),
report_format,
)
.await
.into_report()
.attach_with(|| format!("running DSL test case from {}", file.display()))?;
}

#[cfg(feature = "dora")]
Some(Commands::Dataflow { file, uv }) => {
commands::run::run_dataflow(&file, uv)?;
Expand Down
Loading
Loading