use serde::{Deserialize, Serialize};
use crate::convert::ResolvedPoint;
use crate::report::{StepOutcome, StepReport};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum Event {
Run {
utc: String,
session: String,
executed: bool,
},
Step {
utc: String,
index: usize,
summary: String,
outcome: StepOutcome,
points: Vec<ResolvedPoint>,
elapsed_ms: u64,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<String>,
},
}
impl Event {
#[must_use]
pub fn run(utc: String, session: String, executed: bool) -> Self {
Self::Run {
utc,
session,
executed,
}
}
#[must_use]
pub fn step(utc: String, report: &StepReport) -> Self {
Self::Step {
utc,
index: report.index,
summary: report.summary.clone(),
outcome: report.outcome,
points: report.points.clone(),
elapsed_ms: report.elapsed_ms,
detail: report.detail.clone(),
}
}
#[must_use]
pub fn line(&self) -> String {
match serde_json::to_string(self) {
Ok(json) => format!("{json}\n"),
Err(error) => format!("{{\"event\":\"broken\",\"detail\":\"{error}\"}}\n"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::convert::Space;
use crate::flow::Step;
fn point() -> ResolvedPoint {
ResolvedPoint {
x: 76.0,
y: 15.0,
space: Space::Logical,
monitor: 0,
scale: 2.0,
}
}
fn report(summary: &str) -> StepReport {
StepReport {
index: 0,
summary: summary.to_string(),
outcome: StepOutcome::Executed,
points: vec![point()],
detail: None,
elapsed_ms: 262,
}
}
#[test]
fn a_run_line_names_the_session_and_says_it_executed() {
let line = Event::run("2026-08-03T21:00:00Z".into(), "/tmp/s".into(), true).line();
assert!(line.contains(r#""event":"run""#), "{line}");
assert!(line.contains("/tmp/s"), "{line}");
assert!(line.contains(r#""executed":true"#), "{line}");
assert!(line.ends_with('\n'), "one line, newline included");
}
#[test]
fn a_step_line_carries_the_point_that_was_actually_sent() {
let line = Event::step("2026-08-03T21:00:00Z".into(), &report("click submit")).line();
assert!(line.contains("76"), "{line}");
assert!(line.contains("15"), "{line}");
assert!(line.contains(r#""outcome":"executed""#), "{line}");
assert!(line.contains("262"), "{line}");
}
#[test]
fn typed_text_cannot_reach_the_log() {
let secret = "hunter2-correct-horse-battery-staple";
let step = Step::Type {
text: secret.to_string(),
};
let summary = step.summary();
assert!(!summary.contains(secret), "summary leaked it: {summary}");
assert_eq!(summary, "type 36 chars");
let line = Event::step("2026-08-03T21:00:00Z".into(), &report(&summary)).line();
assert!(!line.contains(secret), "the log leaked it: {line}");
assert!(
!line.contains("hunter2"),
"the log leaked part of it: {line}"
);
}
#[test]
fn a_failure_carries_its_detail_and_a_success_omits_it() {
let mut failed = report("changed panel");
failed.outcome = StepOutcome::Failed;
failed.detail = Some("did not change".into());
assert!(
Event::step("t".into(), &failed)
.line()
.contains("did not change")
);
assert!(
!Event::step("t".into(), &report("click x"))
.line()
.contains("detail")
);
}
#[test]
fn every_line_is_one_line() {
let line = Event::step("t".into(), &report("click submit")).line();
assert_eq!(
line.matches('\n').count(),
1,
"NDJSON is one record per line"
);
}
}