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