use super::config::MonitorConfig;
use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;
use std::process::Stdio;
use std::time::{Duration, Instant};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
#[derive(Debug, Clone)]
pub struct AgentMetric {
pub agent: String,
pub registered: bool,
pub mos: Option<f64>,
pub jitter_ms: Option<f64>,
pub packet_loss_pct: Option<f64>,
pub rtt_ms: Option<f64>,
}
#[derive(Debug)]
pub struct ScenarioOutcome {
pub name: String,
pub passed: bool,
pub agents: Vec<AgentMetric>,
}
#[derive(Debug)]
pub struct RunOutcome {
pub passed: bool,
pub error: Option<String>,
pub duration: Duration,
pub timed_out: bool,
pub scenarios: Vec<ScenarioOutcome>,
}
#[derive(Deserialize)]
struct MetricEvent {
scenario: String,
agent: String,
#[serde(default)]
registered: bool,
mos: Option<f64>,
jitter_ms: Option<f64>,
packet_loss_pct: Option<f64>,
rtt_ms: Option<f64>,
}
fn build_args(m: &MonitorConfig) -> Vec<String> {
let mut args = vec![
"run".to_string(),
"--json".to_string(),
"--metrics".to_string(),
"--no-color".to_string(),
m.path.to_string_lossy().into_owned(),
];
for env in &m.env_file {
args.push("--env-file".to_string());
args.push(env.to_string_lossy().into_owned());
}
if let Some(name) = &m.scenario {
args.push("--scenario".to_string());
args.push(name.clone());
}
for tag in &m.tags {
args.push("--tag".to_string());
args.push(tag.clone());
}
args
}
#[derive(Default)]
struct Collected {
order: Vec<String>,
agents: HashMap<String, Vec<AgentMetric>>,
passed: HashMap<String, bool>,
}
impl Collected {
fn note(&mut self, scenario: &str) {
if !self.order.iter().any(|s| s == scenario) {
self.order.push(scenario.to_string());
}
}
fn fold_line(&mut self, line: &str) {
let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
return;
};
match value.get("event").and_then(|e| e.as_str()) {
Some("metric") => {
if let Ok(m) = serde_json::from_value::<MetricEvent>(value) {
self.note(&m.scenario);
self.agents
.entry(m.scenario)
.or_default()
.push(AgentMetric {
agent: m.agent,
registered: m.registered,
mos: m.mos,
jitter_ms: m.jitter_ms,
packet_loss_pct: m.packet_loss_pct,
rtt_ms: m.rtt_ms,
});
}
}
Some("scenario_finished") => {
let name = value.get("name").and_then(|n| n.as_str());
let passed = value.get("passed").and_then(|p| p.as_bool());
if let (Some(name), Some(passed)) = (name, passed) {
self.note(name);
self.passed.insert(name.to_string(), passed);
}
}
_ => {}
}
}
fn into_scenarios(mut self, overall: bool) -> Vec<ScenarioOutcome> {
self.order
.iter()
.map(|name| ScenarioOutcome {
name: name.clone(),
passed: self.passed.get(name).copied().unwrap_or(overall),
agents: self.agents.remove(name).unwrap_or_default(),
})
.collect()
}
}
pub async fn run(binary: &Path, m: &MonitorConfig, timeout: Duration) -> RunOutcome {
let started = Instant::now();
let mut child = match Command::new(binary)
.args(build_args(m))
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
{
Ok(c) => c,
Err(e) => {
return RunOutcome {
passed: false,
error: Some(format!("spawn {}: {e}", binary.display())),
duration: started.elapsed(),
timed_out: false,
scenarios: Vec::new(),
};
}
};
let stdout = child.stdout.take().expect("stdout piped");
let stderr = child.stderr.take().expect("stderr piped");
let collector = tokio::spawn(collect(stdout));
let errors = tokio::spawn(collect_lines(stderr));
let wait = tokio::time::timeout(timeout, child.wait()).await;
let collected = collector.await.unwrap_or_default();
let stderr_tail = errors.await.unwrap_or_default();
let duration = started.elapsed();
match wait {
Ok(Ok(status)) => RunOutcome {
passed: status.success(),
error: (!status.success())
.then(|| with_reason(&format!("scenario failed (exit {status})"), &stderr_tail)),
duration,
timed_out: false,
scenarios: collected.into_scenarios(status.success()),
},
Ok(Err(e)) => RunOutcome {
passed: false,
error: Some(format!("wait for child: {e}")),
duration,
timed_out: false,
scenarios: collected.into_scenarios(false),
},
Err(_) => {
let _ = child.kill().await;
RunOutcome {
passed: false,
error: Some(format!("timed out after {}s", timeout.as_secs())),
duration,
timed_out: true,
scenarios: collected.into_scenarios(false),
}
}
}
}
fn with_reason(msg: &str, stderr_tail: &str) -> String {
match stderr_tail.lines().rfind(|l| !l.trim().is_empty()) {
Some(reason) => format!("{msg}: {}", reason.trim()),
None => msg.to_string(),
}
}
async fn collect(stdout: tokio::process::ChildStdout) -> Collected {
let mut collected = Collected::default();
let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
collected.fold_line(&line);
}
collected
}
async fn collect_lines(stderr: tokio::process::ChildStderr) -> String {
use std::collections::VecDeque;
const MAX: usize = 20;
let mut tail: VecDeque<String> = VecDeque::with_capacity(MAX);
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
if tail.len() == MAX {
tail.pop_front();
}
tail.push_back(line);
}
tail.into_iter().collect::<Vec<_>>().join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn cfg() -> MonitorConfig {
MonitorConfig {
name: "smoke".into(),
path: PathBuf::from("scenarios/smoke.rhai"),
schedule: None,
timeout: None,
env_file: vec![PathBuf::from("ci.env")],
scenario: Some("answered".into()),
tags: vec!["smoke".into()],
}
}
#[test]
fn build_args_wires_flags_and_filters() {
let args = build_args(&cfg());
assert_eq!(&args[0..4], &["run", "--json", "--metrics", "--no-color"]);
assert!(args.contains(&"scenarios/smoke.rhai".to_string()));
assert!(args.windows(2).any(|w| w == ["--env-file", "ci.env"]));
assert!(args.windows(2).any(|w| w == ["--scenario", "answered"]));
assert!(args.windows(2).any(|w| w == ["--tag", "smoke"]));
}
#[test]
fn groups_agents_by_scenario_in_order() {
let mut c = Collected::default();
c.fold_line(r#"{"event":"metric","scenario":"rejects","agent":"Caller","registered":true,"mos":4.4}"#);
c.fold_line(
r#"{"event":"metric","scenario":"rejects","agent":"Callee","registered":true}"#,
);
c.fold_line(r#"{"event":"scenario_finished","name":"rejects","passed":true}"#);
c.fold_line(r#"{"event":"metric","scenario":"accepts","agent":"Caller","registered":true,"mos":4.3,"jitter_ms":8.0}"#);
c.fold_line(r#"{"event":"scenario_finished","name":"accepts","passed":false}"#);
c.fold_line("not json");
c.fold_line(r#"{"event":"wait","seconds":1.0}"#);
let scenarios = c.into_scenarios(true);
assert_eq!(scenarios.len(), 2);
assert_eq!(scenarios[0].name, "rejects");
assert_eq!(scenarios[0].agents.len(), 2);
assert!(scenarios[0].passed);
assert_eq!(scenarios[1].name, "accepts");
assert_eq!(scenarios[1].agents.len(), 1);
assert!(!scenarios[1].passed); assert_eq!(scenarios[1].agents[0].jitter_ms, Some(8.0));
}
#[test]
fn single_scenario_falls_back_to_overall_pass() {
let mut c = Collected::default();
c.fold_line(r#"{"event":"metric","scenario":"the call","agent":"A","registered":true}"#);
let scenarios = c.into_scenarios(false);
assert_eq!(scenarios.len(), 1);
assert_eq!(scenarios[0].name, "the call");
assert!(!scenarios[0].passed);
}
}