use std::collections::BTreeMap;
use std::io::Read;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
pub const E2E_RUNNER_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunMode {
Quick,
Ci,
Live,
}
impl RunMode {
pub fn as_str(self) -> &'static str {
match self {
RunMode::Quick => "quick",
RunMode::Ci => "ci",
RunMode::Live => "live",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum RunOutcome {
Success,
CommandFailure { exit_code: i32 },
Timeout,
InvalidJson,
AssertionFailure { failed: Vec<String> },
MissingFixture { path: String },
LogArtifactLoss { detail: String },
}
impl RunOutcome {
pub fn kind(&self) -> &'static str {
match self {
RunOutcome::Success => "success",
RunOutcome::CommandFailure { .. } => "command_failure",
RunOutcome::Timeout => "timeout",
RunOutcome::InvalidJson => "invalid_json",
RunOutcome::AssertionFailure { .. } => "assertion_failure",
RunOutcome::MissingFixture { .. } => "missing_fixture",
RunOutcome::LogArtifactLoss { .. } => "log_artifact_loss",
}
}
pub fn is_success(&self) -> bool {
matches!(self, RunOutcome::Success)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RawRun {
pub exit_code: Option<i32>,
pub timed_out: bool,
pub signal: Option<i32>,
pub stdout: String,
pub stderr: String,
pub elapsed_ms: u64,
}
pub type OutputAssertion<'a> = (String, Box<dyn Fn(&str, &str) -> bool + 'a>);
#[derive(Default)]
pub struct RunExpectation<'a> {
pub expect_json: bool,
pub assertions: Vec<OutputAssertion<'a>>,
}
pub fn classify_outcome(raw: &RawRun, expect: &RunExpectation<'_>) -> RunOutcome {
if raw.timed_out {
return RunOutcome::Timeout;
}
match raw.exit_code {
Some(0) => {}
Some(code) => return RunOutcome::CommandFailure { exit_code: code },
None => {
return RunOutcome::CommandFailure {
exit_code: raw.signal.map(|s| -s).unwrap_or(-1),
};
}
}
let parsed_json_ok = if expect.expect_json {
serde_json::from_str::<serde_json::Value>(raw.stdout.trim()).is_ok()
} else {
true
};
if expect.expect_json && !parsed_json_ok {
return RunOutcome::InvalidJson;
}
let failed: Vec<String> = expect
.assertions
.iter()
.filter(|(_, check)| !check(&raw.stdout, &raw.stderr))
.map(|(name, _)| name.clone())
.collect();
if !failed.is_empty() {
return RunOutcome::AssertionFailure { failed };
}
RunOutcome::Success
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunEvent {
pub schema_version: u32,
pub mode: RunMode,
pub command_line: Vec<String>,
pub binary_path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub binary_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub binary_hash: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env_overrides: BTreeMap<String, String>,
pub cwd: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub fixture_id: Option<String>,
pub phase: String,
pub start_ms: u64,
pub end_ms: u64,
pub elapsed_ms: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
pub timed_out: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub signal: Option<i32>,
pub parsed_json_ok: bool,
pub outcome: RunOutcome,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub assertion_failures: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub artifact_paths: Vec<String>,
pub stdout_len: usize,
pub stderr_len: usize,
}
impl RunEvent {
pub fn to_jsonl(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
}
pub fn human_summary(&self) -> String {
let cmd = self.command_line.join(" ");
format!(
"[{}] {} -> {} ({}ms, exit={})",
self.mode.as_str(),
cmd,
self.outcome.kind(),
self.elapsed_ms,
self.exit_code
.map(|c| c.to_string())
.unwrap_or_else(|| if self.timed_out {
"timeout".into()
} else {
"signal".into()
}),
)
}
}
pub struct RunSpec {
pub binary_path: String,
pub args: Vec<String>,
pub timeout: Duration,
pub env_overrides: BTreeMap<String, String>,
pub cwd: PathBuf,
pub fixture_id: Option<String>,
pub require_path: Option<PathBuf>,
pub phase: String,
pub mode: RunMode,
}
fn execute_bounded(spec: &RunSpec) -> std::io::Result<RawRun> {
let start = Instant::now();
let mut cmd = Command::new(&spec.binary_path);
cmd.args(&spec.args)
.current_dir(&spec.cwd)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (k, v) in &spec.env_overrides {
cmd.env(k, v);
}
crate::sources::configure_child_process_group(&mut cmd);
let mut child = cmd.spawn()?;
let pid = child.id();
let mut out_pipe = child.stdout.take();
let mut err_pipe = child.stderr.take();
let out_handle = std::thread::spawn(move || {
let mut buf = String::new();
if let Some(p) = out_pipe.as_mut() {
let _ = p.read_to_string(&mut buf);
}
buf
});
let err_handle = std::thread::spawn(move || {
let mut buf = String::new();
if let Some(p) = err_pipe.as_mut() {
let _ = p.read_to_string(&mut buf);
}
buf
});
let deadline = start + spec.timeout;
let mut timed_out = false;
let status = loop {
match child.try_wait()? {
Some(status) => break status,
None => {
if Instant::now() >= deadline {
kill_process_group(pid);
let _ = child.kill();
timed_out = true;
break child.wait()?;
}
std::thread::sleep(Duration::from_millis(10));
}
}
};
let elapsed_ms = start.elapsed().as_millis() as u64;
let stdout = out_handle.join().unwrap_or_default();
let stderr = err_handle.join().unwrap_or_default();
let (exit_code, signal) = decode_status(&status);
Ok(RawRun {
exit_code: if timed_out { None } else { exit_code },
timed_out,
signal,
stdout,
stderr,
elapsed_ms,
})
}
#[cfg(unix)]
fn kill_process_group(pid: u32) {
let group = format!("-{pid}");
let _ = Command::new("/bin/kill")
.args(["-KILL", &group])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
#[cfg(not(unix))]
fn kill_process_group(_pid: u32) {}
#[cfg(unix)]
fn decode_status(status: &std::process::ExitStatus) -> (Option<i32>, Option<i32>) {
use std::os::unix::process::ExitStatusExt;
(status.code(), status.signal())
}
#[cfg(not(unix))]
fn decode_status(status: &std::process::ExitStatus) -> (Option<i32>, Option<i32>) {
(status.code(), None)
}
pub fn run(spec: &RunSpec, expect: &RunExpectation<'_>, now_ms: u64) -> RunEvent {
let command_line = {
let mut v = vec![spec.binary_path.clone()];
v.extend(spec.args.iter().cloned());
v
};
let base =
|raw: &RawRun, outcome: RunOutcome, parsed_json_ok: bool, failures: Vec<String>| RunEvent {
schema_version: E2E_RUNNER_SCHEMA_VERSION,
mode: spec.mode,
command_line: command_line.clone(),
binary_path: spec.binary_path.clone(),
binary_version: None,
binary_hash: None,
env_overrides: spec.env_overrides.clone(),
cwd: spec.cwd.display().to_string(),
fixture_id: spec.fixture_id.clone(),
phase: spec.phase.clone(),
start_ms: now_ms,
end_ms: now_ms.saturating_add(raw.elapsed_ms),
elapsed_ms: raw.elapsed_ms,
exit_code: raw.exit_code,
timed_out: raw.timed_out,
signal: raw.signal,
parsed_json_ok,
outcome,
assertion_failures: failures,
artifact_paths: Vec::new(),
stdout_len: raw.stdout.len(),
stderr_len: raw.stderr.len(),
};
if let Some(path) = &spec.require_path
&& !path.exists()
{
let raw = RawRun {
exit_code: None,
timed_out: false,
signal: None,
stdout: String::new(),
stderr: String::new(),
elapsed_ms: 0,
};
return base(
&raw,
RunOutcome::MissingFixture {
path: path.display().to_string(),
},
true,
Vec::new(),
);
}
let raw = match execute_bounded(spec) {
Ok(raw) => raw,
Err(err) => {
let raw = RawRun {
exit_code: Some(-1),
timed_out: false,
signal: None,
stdout: String::new(),
stderr: format!("spawn failed: {err}"),
elapsed_ms: 0,
};
return base(
&raw,
RunOutcome::CommandFailure { exit_code: -1 },
true,
Vec::new(),
);
}
};
let parsed_json_ok = if expect.expect_json {
serde_json::from_str::<serde_json::Value>(raw.stdout.trim()).is_ok()
} else {
true
};
let outcome = classify_outcome(&raw, expect);
let failures = match &outcome {
RunOutcome::AssertionFailure { failed } => failed.clone(),
_ => Vec::new(),
};
base(&raw, outcome, parsed_json_ok, failures)
}
#[cfg(test)]
mod tests {
use super::*;
fn sh_spec(script: &str, timeout_ms: u64) -> RunSpec {
RunSpec {
binary_path: "/bin/sh".to_string(),
args: vec!["-c".to_string(), script.to_string()],
timeout: Duration::from_millis(timeout_ms),
env_overrides: BTreeMap::new(),
cwd: std::env::temp_dir(),
fixture_id: None,
require_path: None,
phase: "test".to_string(),
mode: RunMode::Quick,
}
}
#[test]
fn success_with_valid_json_classifies_success() {
let spec = sh_spec("printf '{\"ok\":true}'", 5_000);
let mut expect = RunExpectation {
expect_json: true,
..Default::default()
};
expect.assertions.push((
"has_ok".to_string(),
Box::new(|out: &str, _err: &str| out.contains("\"ok\"")),
));
let ev = run(&spec, &expect, 1_000);
assert_eq!(ev.outcome, RunOutcome::Success);
assert!(ev.parsed_json_ok);
assert_eq!(ev.exit_code, Some(0));
assert!(!ev.timed_out);
assert_eq!(ev.end_ms, 1_000 + ev.elapsed_ms);
}
#[test]
fn nonzero_exit_is_command_failure() {
let ev = run(&sh_spec("exit 3", 5_000), &RunExpectation::default(), 0);
assert_eq!(ev.outcome, RunOutcome::CommandFailure { exit_code: 3 });
assert_eq!(ev.exit_code, Some(3));
}
#[test]
fn slow_command_hits_bounded_timeout() {
let ev = run(&sh_spec("sleep 5", 150), &RunExpectation::default(), 0);
assert_eq!(ev.outcome, RunOutcome::Timeout);
assert!(ev.timed_out);
assert_eq!(ev.exit_code, None);
assert!(
ev.elapsed_ms < 3_000,
"timeout was not bounded: {}ms",
ev.elapsed_ms
);
}
#[test]
fn invalid_json_when_json_expected() {
let spec = sh_spec("printf 'not json at all'", 5_000);
let expect = RunExpectation {
expect_json: true,
..Default::default()
};
let ev = run(&spec, &expect, 0);
assert_eq!(ev.outcome, RunOutcome::InvalidJson);
assert!(!ev.parsed_json_ok);
}
#[test]
fn failed_assertion_is_reported_with_name() {
let spec = sh_spec("printf 'hello'", 5_000);
let mut expect = RunExpectation::default();
expect.assertions.push((
"contains_world".to_string(),
Box::new(|out: &str, _e: &str| out.contains("world")),
));
let ev = run(&spec, &expect, 0);
assert_eq!(
ev.outcome,
RunOutcome::AssertionFailure {
failed: vec!["contains_world".to_string()]
}
);
assert_eq!(ev.assertion_failures, vec!["contains_world".to_string()]);
}
#[test]
fn missing_fixture_short_circuits_without_executing() {
let mut spec = sh_spec("echo should-not-run", 5_000);
spec.require_path = Some(PathBuf::from("/no/such/fixture/path-xyz"));
let ev = run(&spec, &RunExpectation::default(), 0);
assert!(matches!(ev.outcome, RunOutcome::MissingFixture { .. }));
assert_eq!(ev.exit_code, None);
assert_eq!(ev.stdout_len, 0);
}
#[test]
fn stdout_and_stderr_are_captured_separately() {
let spec = sh_spec("printf 'DATA' ; printf 'DIAG' 1>&2", 5_000);
let ev = run(&spec, &RunExpectation::default(), 0);
assert_eq!(ev.outcome, RunOutcome::Success);
assert_eq!(ev.stdout_len, 4); assert_eq!(ev.stderr_len, 4); }
#[test]
fn run_event_jsonl_and_summary_are_stable_and_round_trip() {
let ev = run(
&sh_spec("printf '{}'", 5_000),
&RunExpectation {
expect_json: true,
..Default::default()
},
42,
);
let line = ev.to_jsonl();
assert!(!line.contains('\n'));
let value: serde_json::Value = serde_json::from_str(&line).unwrap();
assert_eq!(value["schema_version"], E2E_RUNNER_SCHEMA_VERSION);
assert_eq!(value["mode"], "quick");
assert_eq!(value["outcome"]["kind"], "success");
let back: RunEvent = serde_json::from_str(&line).unwrap();
assert_eq!(back, ev);
assert!(ev.human_summary().contains("success"));
}
#[test]
fn classify_outcome_precedence_is_timeout_then_exit_then_json_then_assert() {
let raw = RawRun {
exit_code: Some(2),
timed_out: true,
signal: None,
stdout: String::new(),
stderr: String::new(),
elapsed_ms: 10,
};
assert_eq!(
classify_outcome(&raw, &RunExpectation::default()),
RunOutcome::Timeout
);
let raw = RawRun {
exit_code: Some(2),
timed_out: false,
signal: None,
stdout: "bad".into(),
stderr: String::new(),
elapsed_ms: 1,
};
assert_eq!(
classify_outcome(
&raw,
&RunExpectation {
expect_json: true,
..Default::default()
}
),
RunOutcome::CommandFailure { exit_code: 2 }
);
}
}