use crate::actions::EffectExpectation;
use crate::view::EvidenceRef;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "route", rename_all = "snake_case")]
pub enum DispatchRoute {
Cdp,
Accessibility,
NativeInput,
Adapter { name: String, op: String },
Focus,
Other { detail: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ObservedStatus {
Observed,
TimedOut,
NotChecked,
Contradicted,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ObservedKind {
SelectorEffect { expectation: EffectExpectation },
AdapterReadback { action: String },
AxReread,
None,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ObservedEffect {
pub status: ObservedStatus,
pub kind: ObservedKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
impl ObservedEffect {
pub fn not_checked() -> Self {
Self {
status: ObservedStatus::NotChecked,
kind: ObservedKind::None,
detail: None,
}
}
pub fn selector_observed(expectation: EffectExpectation) -> Self {
Self {
status: ObservedStatus::Observed,
kind: ObservedKind::SelectorEffect { expectation },
detail: None,
}
}
pub fn selector_timed_out(expectation: EffectExpectation, detail: impl Into<String>) -> Self {
Self {
status: ObservedStatus::TimedOut,
kind: ObservedKind::SelectorEffect { expectation },
detail: Some(detail.into()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReceiptStatus {
Ok,
Failed,
Vetoed,
Denied,
TimedOut,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExecutionReceipt {
pub receipt_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trace_id: Option<String>,
pub action_kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
pub route: DispatchRoute,
pub observed_effect: ObservedEffect,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub evidence: Vec<EvidenceRef>,
pub requested_at_ms: u64,
pub completed_at_ms: u64,
pub duration_ms: u64,
pub status: ReceiptStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::actions::EffectExpectation;
fn sample() -> ExecutionReceipt {
ExecutionReceipt {
receipt_id: "rcpt_1".into(),
run_id: None,
trace_id: Some("trace-abc".into()),
action_kind: "click".into(),
target: Some("dom:button:submit".into()),
route: DispatchRoute::Cdp,
observed_effect: ObservedEffect::selector_observed(
EffectExpectation::SelectorAppears {
selector: ".success".into(),
timeout_ms: 2_000,
},
),
evidence: Vec::new(),
requested_at_ms: 100,
completed_at_ms: 250,
duration_ms: 150,
status: ReceiptStatus::Ok,
error: None,
}
}
#[test]
fn execution_receipt_round_trips() {
let r = sample();
let json = serde_json::to_string(&r).unwrap();
let back: ExecutionReceipt = serde_json::from_str(&json).unwrap();
assert_eq!(r, back);
assert_eq!(back.route, DispatchRoute::Cdp);
assert_eq!(back.observed_effect.status, ObservedStatus::Observed);
assert_eq!(back.duration_ms, 150);
}
#[test]
fn empty_optionals_are_omitted_from_json() {
let json = serde_json::to_string(&sample()).unwrap();
assert!(!json.contains("run_id"));
assert!(!json.contains("\"error\""));
assert!(!json.contains("evidence"));
assert!(json.contains("trace-abc"));
}
#[test]
fn not_checked_effect_defaults() {
let e = ObservedEffect::not_checked();
assert_eq!(e.status, ObservedStatus::NotChecked);
assert!(matches!(e.kind, ObservedKind::None));
}
#[test]
fn timed_out_effect_carries_detail() {
let e = ObservedEffect::selector_timed_out(
EffectExpectation::DomChanged { timeout_ms: 2_000 },
"no DOM change observed",
);
assert_eq!(e.status, ObservedStatus::TimedOut);
assert_eq!(e.detail.as_deref(), Some("no DOM change observed"));
}
#[test]
fn adapter_route_serializes_with_fields() {
let route = DispatchRoute::Adapter {
name: "numbers".into(),
op: "write_cells".into(),
};
let json = serde_json::to_string(&route).unwrap();
assert!(json.contains("\"route\":\"adapter\""));
let back: DispatchRoute = serde_json::from_str(&json).unwrap();
assert_eq!(route, back);
}
}