1use crate::actions::EffectExpectation;
19use crate::view::EvidenceRef;
20use serde::{Deserialize, Serialize};
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(tag = "route", rename_all = "snake_case")]
26pub enum DispatchRoute {
27 Cdp,
29 Accessibility,
31 NativeInput,
33 Adapter { name: String, op: String },
35 Focus,
37 Other { detail: String },
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum ObservedStatus {
45 Observed,
47 TimedOut,
49 NotChecked,
51 Contradicted,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(tag = "kind", rename_all = "snake_case")]
59pub enum ObservedKind {
60 SelectorEffect { expectation: EffectExpectation },
62 AdapterReadback { action: String },
64 AxReread,
66 None,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct ObservedEffect {
73 pub status: ObservedStatus,
74 pub kind: ObservedKind,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub detail: Option<String>,
78}
79
80impl ObservedEffect {
81 pub fn not_checked() -> Self {
83 Self {
84 status: ObservedStatus::NotChecked,
85 kind: ObservedKind::None,
86 detail: None,
87 }
88 }
89
90 pub fn selector_observed(expectation: EffectExpectation) -> Self {
92 Self {
93 status: ObservedStatus::Observed,
94 kind: ObservedKind::SelectorEffect { expectation },
95 detail: None,
96 }
97 }
98
99 pub fn selector_timed_out(expectation: EffectExpectation, detail: impl Into<String>) -> Self {
102 Self {
103 status: ObservedStatus::TimedOut,
104 kind: ObservedKind::SelectorEffect { expectation },
105 detail: Some(detail.into()),
106 }
107 }
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(rename_all = "snake_case")]
113pub enum ReceiptStatus {
114 Ok,
115 Failed,
116 Vetoed,
117 Denied,
118 TimedOut,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct ExecutionReceipt {
128 pub receipt_id: String,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub run_id: Option<String>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub trace_id: Option<String>,
137 pub action_kind: String,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub target: Option<String>,
142 pub route: DispatchRoute,
144 pub observed_effect: ObservedEffect,
146 #[serde(default, skip_serializing_if = "Vec::is_empty")]
149 pub evidence: Vec<EvidenceRef>,
150 pub requested_at_ms: u64,
151 pub completed_at_ms: u64,
152 pub duration_ms: u64,
153 pub status: ReceiptStatus,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub error: Option<String>,
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161 use crate::actions::EffectExpectation;
162
163 fn sample() -> ExecutionReceipt {
164 ExecutionReceipt {
165 receipt_id: "rcpt_1".into(),
166 run_id: None,
167 trace_id: Some("trace-abc".into()),
168 action_kind: "click".into(),
169 target: Some("dom:button:submit".into()),
170 route: DispatchRoute::Cdp,
171 observed_effect: ObservedEffect::selector_observed(
172 EffectExpectation::SelectorAppears {
173 selector: ".success".into(),
174 timeout_ms: 2_000,
175 },
176 ),
177 evidence: Vec::new(),
178 requested_at_ms: 100,
179 completed_at_ms: 250,
180 duration_ms: 150,
181 status: ReceiptStatus::Ok,
182 error: None,
183 }
184 }
185
186 #[test]
187 fn execution_receipt_round_trips() {
188 let r = sample();
189 let json = serde_json::to_string(&r).unwrap();
190 let back: ExecutionReceipt = serde_json::from_str(&json).unwrap();
191 assert_eq!(r, back);
192 assert_eq!(back.route, DispatchRoute::Cdp);
193 assert_eq!(back.observed_effect.status, ObservedStatus::Observed);
194 assert_eq!(back.duration_ms, 150);
195 }
196
197 #[test]
198 fn empty_optionals_are_omitted_from_json() {
199 let json = serde_json::to_string(&sample()).unwrap();
202 assert!(!json.contains("run_id"));
203 assert!(!json.contains("\"error\""));
204 assert!(!json.contains("evidence"));
205 assert!(json.contains("trace-abc"));
206 }
207
208 #[test]
209 fn not_checked_effect_defaults() {
210 let e = ObservedEffect::not_checked();
211 assert_eq!(e.status, ObservedStatus::NotChecked);
212 assert!(matches!(e.kind, ObservedKind::None));
213 }
214
215 #[test]
216 fn timed_out_effect_carries_detail() {
217 let e = ObservedEffect::selector_timed_out(
218 EffectExpectation::DomChanged { timeout_ms: 2_000 },
219 "no DOM change observed",
220 );
221 assert_eq!(e.status, ObservedStatus::TimedOut);
222 assert_eq!(e.detail.as_deref(), Some("no DOM change observed"));
223 }
224
225 #[test]
226 fn adapter_route_serializes_with_fields() {
227 let route = DispatchRoute::Adapter {
228 name: "numbers".into(),
229 op: "write_cells".into(),
230 };
231 let json = serde_json::to_string(&route).unwrap();
232 assert!(json.contains("\"route\":\"adapter\""));
233 let back: DispatchRoute = serde_json::from_str(&json).unwrap();
234 assert_eq!(route, back);
235 }
236}