use crate::compare::{CompareEngine, CompareResult};
use crate::convert::{fixture_to_session, session_to_fixture};
use crate::fixture::{Fixture, FixtureEvent};
use crate::report::ReportSummary;
use ma_harness_core::log::{EventLog, EventQuery};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;
use thiserror::Error;
use tracing::{debug, info, warn};
#[derive(Debug, Clone)]
pub struct ConformanceResult {
pub fixture_name: String,
pub compare: CompareResult,
pub duration_ms: u64,
pub error: Option<String>,
pub actual_events: Vec<FixtureEvent>,
pub expect_fail: bool,
}
impl ConformanceResult {
pub fn is_pass(&self) -> bool {
self.compare.passed && self.error.is_none()
}
pub fn is_pass_expected(&self) -> bool {
if self.expect_fail {
!self.is_pass() && self.error.is_none()
} else {
self.is_pass()
}
}
}
#[derive(Debug, Clone, Default)]
pub struct RunnerStats {
pub total: usize,
pub passed: usize,
pub failed: usize,
pub errored: usize,
pub total_duration_ms: u64,
}
impl RunnerStats {
pub fn from_results(results: &[ConformanceResult]) -> Self {
let mut stats = Self {
total: results.len(),
..Self::default()
};
for r in results {
if r.error.is_some() {
stats.errored += 1;
} else if r.is_pass_expected() {
stats.passed += 1;
} else {
stats.failed += 1;
}
stats.total_duration_ms += r.duration_ms;
}
stats
}
pub fn to_summary(&self) -> ReportSummary {
let pass_rate = if self.total == 0 {
1.0
} else {
self.passed as f64 / self.total as f64
};
ReportSummary {
total: self.total,
passed: self.passed,
failed: self.failed,
errored: self.errored,
pass_rate,
total_duration_ms: self.total_duration_ms,
}
}
}
pub struct ConformanceRunner {
plugin_dir: Option<PathBuf>,
verbose: bool,
}
impl Default for ConformanceRunner {
fn default() -> Self {
Self::new()
}
}
impl ConformanceRunner {
pub fn new() -> Self {
Self {
plugin_dir: None,
verbose: false,
}
}
pub fn with_plugin_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.plugin_dir = Some(dir.into());
self
}
pub fn verbose(mut self) -> Self {
self.verbose = true;
self
}
pub fn run_fixture(&self, fixture: &Fixture) -> ConformanceResult {
let start = Instant::now();
debug!(fixture = %fixture.name, expect_fail = fixture.expect_fail, "running fixture");
let _ctx = match self.build_ctx(fixture) {
Ok(ctx) => ctx,
Err(e) => {
return ConformanceResult {
fixture_name: fixture.name.clone(),
compare: CompareResult::ok(0, fixture.output.events.len()),
duration_ms: start.elapsed().as_millis() as u64,
error: Some(format!("ctx build failed: {e}")),
actual_events: Vec::new(),
expect_fail: fixture.expect_fail,
};
}
};
let actual_events = match self.replay_events_via_event_log(fixture) {
Ok(events) => events,
Err(e) => {
return ConformanceResult {
fixture_name: fixture.name.clone(),
compare: CompareResult::ok(0, fixture.output.events.len()),
duration_ms: start.elapsed().as_millis() as u64,
error: Some(format!("event log replay failed: {e}")),
actual_events: Vec::new(),
expect_fail: fixture.expect_fail,
};
}
};
let duration_ms = start.elapsed().as_millis() as u64;
if self.verbose {
info!(
fixture = %fixture.name,
actual_count = actual_events.len(),
expected_count = fixture.output.events.len(),
"replay done"
);
}
let compare = CompareEngine::compare(&actual_events, &fixture.output.events);
ConformanceResult {
fixture_name: fixture.name.clone(),
compare,
duration_ms,
error: None,
actual_events,
expect_fail: fixture.expect_fail,
}
}
pub fn run_all(&self, fixtures: &[Fixture]) -> Vec<ConformanceResult> {
let mut results = Vec::with_capacity(fixtures.len());
for fixture in fixtures {
let r = self.run_fixture(fixture);
if !r.is_pass() {
warn!(fixture = %fixture.name, "fixture failed");
}
results.push(r);
}
results
}
pub fn build_summary(&self, results: &[ConformanceResult]) -> ReportSummary {
RunnerStats::from_results(results).to_summary()
}
fn build_ctx(
&self,
_fixture: &Fixture,
) -> Result<Arc<ma_harness_cordis::Context>, RunnerError> {
Ok(Arc::new(ma_harness_cordis::Context::new()))
}
fn replay_events_via_event_log(
&self,
fixture: &Fixture,
) -> Result<Vec<FixtureEvent>, RunnerError> {
let log = EventLog::open_in_memory()
.map_err(|e| RunnerError::EventLog(format!("open_in_memory failed: {e}")))?;
for input_event in &fixture.input.events {
let session_event = fixture_to_session(&fixture.input.session_id, input_event)
.map_err(|e| RunnerError::Convert(e.to_string()))?;
let seq = log.append(session_event);
if self.verbose {
debug!(seq, "appended event");
}
}
let page = log
.query(&EventQuery {
session_id: fixture.input.session_id.clone(),
..Default::default()
})
.map_err(|e| RunnerError::EventLog(format!("query failed: {e}")))?;
let actual: Vec<FixtureEvent> = page
.events
.iter()
.map(|s| session_to_fixture(&s.event))
.collect();
Ok(actual)
}
}
#[derive(Debug, Error)]
pub enum RunnerError {
#[error("plugin load failed: {0}")]
PluginLoad(String),
#[error("ctx init failed: {0}")]
CtxInit(String),
#[error("event log error: {0}")]
EventLog(String),
#[error("event convert error: {0}")]
Convert(String),
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fixture::{FixtureCategory, FixtureInput, FixtureOutput};
use crate::fixture::ExpectedEvent;
use std::collections::BTreeMap;
fn sample_fixture() -> Fixture {
Fixture {
name: "sample".to_string(),
category: FixtureCategory::ToolCall,
description: Some("sample for test".to_string()),
expect_fail: false,
input: FixtureInput {
session_id: "s1".to_string(),
plugins: vec!["hello".to_string()],
events: vec![FixtureEvent {
event_type: "ToolCall".to_string(),
payload: serde_json::json!({"tool": "bash", "args": {"command": "echo hi"}}),
timestamp_ms: None,
}],
},
output: FixtureOutput {
events: vec![ExpectedEvent {
event_type: "ToolCall".to_string(),
payload_match: BTreeMap::new(),
timestamp_ms: None,
}],
final_state: BTreeMap::new(),
},
}
}
#[test]
fn runner_runs_passing_fixture() {
let runner = ConformanceRunner::new();
let f = sample_fixture();
let r = runner.run_fixture(&f);
assert!(
r.is_pass(),
"error={:?} diffs={:?}",
r.error,
r.compare.diffs
);
assert_eq!(r.actual_events.len(), 1);
assert_eq!(r.fixture_name, "sample");
}
#[test]
fn runner_returns_stats() {
let runner = ConformanceRunner::new();
let f = sample_fixture();
let results = runner.run_all(&[f.clone(), f.clone(), f]);
let stats = RunnerStats::from_results(&results);
assert_eq!(stats.total, 3);
assert_eq!(stats.passed, 3);
assert_eq!(stats.failed, 0);
assert_eq!(stats.errored, 0);
let summary = stats.to_summary();
assert_eq!(summary.total, 3);
assert!((summary.pass_rate - 1.0).abs() < 1e-9);
}
#[test]
fn runner_collects_all_results() {
let runner = ConformanceRunner::new();
let results = runner.run_all(&[sample_fixture()]);
assert_eq!(results.len(), 1);
assert_eq!(results[0].fixture_name, "sample");
}
fn multi_event_fixture() -> Fixture {
Fixture {
name: "multi_event".to_string(),
category: FixtureCategory::AgentRun,
description: Some("Run lifecycle with one tool call".to_string()),
expect_fail: false,
input: FixtureInput {
session_id: "session-multi".to_string(),
plugins: vec!["bash".to_string()],
events: vec![
FixtureEvent {
event_type: "RunStart".to_string(),
payload: serde_json::json!({"model": "stub"}),
timestamp_ms: None,
},
FixtureEvent {
event_type: "ToolCall".to_string(),
payload: serde_json::json!({"tool": "bash", "args": {"command": "echo hi"}}),
timestamp_ms: None,
},
FixtureEvent {
event_type: "ToolResult".to_string(),
payload: serde_json::json!({"tool": "bash", "result": "hi\n"}),
timestamp_ms: None,
},
FixtureEvent {
event_type: "RunEnd".to_string(),
payload: serde_json::json!({"status": "ok"}),
timestamp_ms: None,
},
],
},
output: FixtureOutput {
events: vec![
ExpectedEvent {
event_type: "RunStart".to_string(),
payload_match: BTreeMap::new(),
timestamp_ms: None,
},
ExpectedEvent {
event_type: "ToolCall".to_string(),
payload_match: BTreeMap::new(),
timestamp_ms: None,
},
ExpectedEvent {
event_type: "ToolResult".to_string(),
payload_match: BTreeMap::new(),
timestamp_ms: None,
},
ExpectedEvent {
event_type: "RunEnd".to_string(),
payload_match: BTreeMap::new(),
timestamp_ms: None,
},
],
final_state: BTreeMap::new(),
},
}
}
#[test]
fn runner_via_event_log_preserves_event_order() {
let runner = ConformanceRunner::new();
let f = multi_event_fixture();
let r = runner.run_fixture(&f);
assert!(
r.is_pass(),
"error={:?} diffs={:?}",
r.error,
r.compare.diffs
);
assert_eq!(r.actual_events.len(), 4);
assert_eq!(r.actual_events[0].event_type, "RunStart");
assert_eq!(r.actual_events[1].event_type, "ToolCall");
assert_eq!(r.actual_events[2].event_type, "ToolResult");
assert_eq!(r.actual_events[3].event_type, "RunEnd");
}
#[test]
fn runner_via_event_log_preserves_payload() {
let runner = ConformanceRunner::new();
let f = multi_event_fixture();
let r = runner.run_fixture(&f);
let tool_call = &r.actual_events[1];
assert_eq!(tool_call.payload["tool"], "bash");
assert_eq!(tool_call.payload["args"]["command"], "echo hi");
}
#[test]
fn runner_detects_extra_event() {
let runner = ConformanceRunner::new();
let f = multi_event_fixture();
let mut f = f;
f.output.events.pop();
let r = runner.run_fixture(&f);
assert!(!r.is_pass());
assert_eq!(r.compare.diffs.len(), 1);
assert!(r.compare.diffs[0].summary().contains("extra event"));
assert!(r.compare.diffs[0].summary().contains("RunEnd"));
}
fn expect_fail_fixture() -> Fixture {
let mut f = sample_fixture();
f.name = "by_design_fail".to_string();
f.description = Some("by-design fail fixture".to_string());
f.input = FixtureInput {
session_id: "by-design".to_string(),
plugins: vec!["bash".to_string()],
events: vec![
FixtureEvent {
event_type: "RunStart".to_string(),
payload: serde_json::json!({"prompt": "test"}),
timestamp_ms: None,
},
FixtureEvent {
event_type: "RunEnd".to_string(),
payload: serde_json::json!({"status": "ok"}),
timestamp_ms: None,
},
],
};
f.output = FixtureOutput {
events: vec![
ExpectedEvent {
event_type: "RunStart".to_string(),
payload_match: BTreeMap::new(),
timestamp_ms: None,
},
ExpectedEvent {
event_type: "RunEnd".to_string(),
payload_match: BTreeMap::new(),
timestamp_ms: None,
},
ExpectedEvent {
event_type: "ToolResult".to_string(),
payload_match: BTreeMap::new(),
timestamp_ms: None,
},
],
final_state: BTreeMap::new(),
};
f.expect_fail = true;
f
}
#[test]
fn runner_expect_fail_flip_to_pass_when_diff_present() {
let runner = ConformanceRunner::new();
let f = expect_fail_fixture();
let r = runner.run_fixture(&f);
assert!(!r.is_pass(), "comparer should report diff");
assert_eq!(r.compare.diffs.len(), 1);
assert!(r.is_pass_expected(), "expect_fail should flip to pass");
assert!(r.expect_fail);
}
#[test]
fn runner_expect_fail_flag_without_diff_is_user_fail() {
let runner = ConformanceRunner::new();
let mut f = sample_fixture();
f.expect_fail = true;
let r = runner.run_fixture(&f);
assert!(r.is_pass());
assert!(!r.is_pass_expected());
}
#[test]
fn runner_stats_flip_expect_fail_to_passed() {
let runner = ConformanceRunner::new();
let f1 = sample_fixture(); let f2 = expect_fail_fixture(); let results = runner.run_all(&[f1, f2]);
let stats = RunnerStats::from_results(&results);
assert_eq!(stats.total, 2);
assert_eq!(stats.passed, 2, "both should be user-pass");
assert_eq!(stats.failed, 0);
}
}