use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::process::Command;
use serde_json::Value;
type TestError = Box<dyn std::error::Error>;
const FAN_OUT: usize = 4;
const STDERR_TAIL_LINES: usize = 20;
pub fn aion_json(args: &[&str]) -> Result<Value, String> {
let rendered = args.join(" ");
let output = Command::new(env!("CARGO_BIN_EXE_aion"))
.args(args)
.output()
.map_err(|error| format!("could not run `aion {rendered}`: {error}"))?;
if !output.status.success() {
return Err(format!(
"`aion {rendered}` exited {status}; stderr:\n{stderr}",
status = output.status,
stderr = last_lines(&String::from_utf8_lossy(&output.stderr)),
));
}
serde_json::from_slice(&output.stdout).map_err(|error| {
format!(
"`aion {rendered}` succeeded but its stdout was not JSON ({error}):\n{stdout}",
stdout = last_lines(&String::from_utf8_lossy(&output.stdout)),
)
})
}
fn last_lines(text: &str) -> String {
let lines: Vec<&str> = text.lines().collect();
if lines.is_empty() {
return "<empty>".to_owned();
}
lines[lines.len().saturating_sub(STDERR_TAIL_LINES)..].join("\n")
}
pub fn try_start(endpoint: &str) -> Result<String, String> {
let value = aion_json(&[
"start",
"aion_outbox_fixture",
"--input",
"{\"fixture\":\"input\"}",
"--endpoint",
endpoint,
])?;
value
.get("workflow_id")
.and_then(Value::as_str)
.map(str::to_owned)
.ok_or_else(|| format!("start reply carried no workflow_id: {value}"))
}
pub fn describe(endpoint: &str, workflow_id: &str) -> Option<Value> {
aion_json(&["describe", workflow_id, "--endpoint", endpoint]).ok()
}
pub fn status_of(description: &Value) -> Option<&str> {
description
.get("summary")
.and_then(|summary| summary.get("status"))
.and_then(Value::as_str)
}
#[derive(Default)]
pub struct RefusalTally {
counts: BTreeMap<String, usize>,
total: usize,
}
impl RefusalTally {
pub fn record(&mut self, refusal: String) {
*self.counts.entry(refusal).or_default() += 1;
self.total += 1;
}
pub fn report(&self) -> String {
let mut ranked: Vec<(&String, &usize)> = self.counts.iter().collect();
ranked.sort_by(|left, right| right.1.cmp(left.1));
let mut report = format!("{} attempts, {} distinct: ", self.total, ranked.len());
for (refusal, count) in ranked {
let _ = write!(report, "\n\n [x{count}] {refusal}");
}
report
}
}
pub fn check_exactly_once(description: Option<&Value>) -> Result<(), TestError> {
let description =
description.ok_or_else(|| TestError::from("missing completed describe payload"))?;
let completions = activity_completed_count(description);
if completions == FAN_OUT {
Ok(())
} else {
Err(format!(
"exactly-once violated: expected {FAN_OUT} ActivityCompleted terminals \
(one per ordinal), found {completions}"
)
.into())
}
}
fn activity_completed_count(description: &Value) -> usize {
description
.get("history")
.and_then(Value::as_array)
.map_or(0, |events| {
events
.iter()
.filter(|event| {
event.get("type").and_then(Value::as_str) == Some("ActivityCompleted")
})
.count()
})
}