pixelactions_core/
audit.rs1use serde::{Deserialize, Serialize};
24
25use crate::convert::ResolvedPoint;
26use crate::report::{StepOutcome, StepReport};
27
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30#[serde(tag = "event", rename_all = "snake_case")]
31pub enum Event {
32 Run {
35 utc: String,
37 session: String,
38 executed: bool,
41 },
42 Step {
44 utc: String,
45 index: usize,
46 summary: String,
49 outcome: StepOutcome,
50 points: Vec<ResolvedPoint>,
54 elapsed_ms: u64,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 detail: Option<String>,
57 },
58}
59
60impl Event {
61 #[must_use]
63 pub fn run(utc: String, session: String, executed: bool) -> Self {
64 Self::Run {
65 utc,
66 session,
67 executed,
68 }
69 }
70
71 #[must_use]
76 pub fn step(utc: String, report: &StepReport) -> Self {
77 Self::Step {
78 utc,
79 index: report.index,
80 summary: report.summary.clone(),
81 outcome: report.outcome,
82 points: report.points.clone(),
83 elapsed_ms: report.elapsed_ms,
84 detail: report.detail.clone(),
85 }
86 }
87
88 #[must_use]
95 pub fn line(&self) -> String {
96 match serde_json::to_string(self) {
97 Ok(json) => format!("{json}\n"),
98 Err(error) => format!("{{\"event\":\"broken\",\"detail\":\"{error}\"}}\n"),
99 }
100 }
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106 use crate::convert::Space;
107 use crate::flow::Step;
108
109 fn point() -> ResolvedPoint {
110 ResolvedPoint {
111 x: 76.0,
112 y: 15.0,
113 space: Space::Logical,
114 monitor: 0,
115 scale: 2.0,
116 }
117 }
118
119 fn report(summary: &str) -> StepReport {
120 StepReport {
121 index: 0,
122 summary: summary.to_string(),
123 outcome: StepOutcome::Executed,
124 points: vec![point()],
125 detail: None,
126 elapsed_ms: 262,
127 }
128 }
129
130 #[test]
131 fn a_run_line_names_the_session_and_says_it_executed() {
132 let line = Event::run("2026-08-03T21:00:00Z".into(), "/tmp/s".into(), true).line();
133 assert!(line.contains(r#""event":"run""#), "{line}");
134 assert!(line.contains("/tmp/s"), "{line}");
135 assert!(line.contains(r#""executed":true"#), "{line}");
136 assert!(line.ends_with('\n'), "one line, newline included");
137 }
138
139 #[test]
140 fn a_step_line_carries_the_point_that_was_actually_sent() {
141 let line = Event::step("2026-08-03T21:00:00Z".into(), &report("click submit")).line();
142 assert!(line.contains("76"), "{line}");
145 assert!(line.contains("15"), "{line}");
146 assert!(line.contains(r#""outcome":"executed""#), "{line}");
147 assert!(line.contains("262"), "{line}");
148 }
149
150 #[test]
156 fn typed_text_cannot_reach_the_log() {
157 let secret = "hunter2-correct-horse-battery-staple";
158 let step = Step::Type {
159 text: secret.to_string(),
160 };
161 let summary = step.summary();
162 assert!(!summary.contains(secret), "summary leaked it: {summary}");
163 assert_eq!(summary, "type 36 chars");
164
165 let line = Event::step("2026-08-03T21:00:00Z".into(), &report(&summary)).line();
166 assert!(!line.contains(secret), "the log leaked it: {line}");
167 assert!(
168 !line.contains("hunter2"),
169 "the log leaked part of it: {line}"
170 );
171 }
172
173 #[test]
174 fn a_failure_carries_its_detail_and_a_success_omits_it() {
175 let mut failed = report("changed panel");
176 failed.outcome = StepOutcome::Failed;
177 failed.detail = Some("did not change".into());
178 assert!(
179 Event::step("t".into(), &failed)
180 .line()
181 .contains("did not change")
182 );
183 assert!(
184 !Event::step("t".into(), &report("click x"))
185 .line()
186 .contains("detail")
187 );
188 }
189
190 #[test]
191 fn every_line_is_one_line() {
192 let line = Event::step("t".into(), &report("click submit")).line();
193 assert_eq!(
194 line.matches('\n').count(),
195 1,
196 "NDJSON is one record per line"
197 );
198 }
199}