Skip to main content

daemon8_types/
lib.rs

1// SPDX-License-Identifier: LicenseRef-FCL-1.0-ALv2
2// Copyright (c) 2026 Havy.tech, LLC
3
4use std::borrow::Borrow;
5use std::collections::HashMap;
6use std::fmt;
7use std::str::FromStr;
8use std::sync::Arc;
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use rmcp::schemars;
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14
15pub const SYSTEM_TAG: &str = "_system";
16
17macro_rules! arc_str_newtype {
18    ($name:ident) => {
19        #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
20        #[serde(transparent)]
21        pub struct $name(Arc<str>);
22
23        impl $name {
24            pub fn as_str(&self) -> &str {
25                &self.0
26            }
27        }
28
29        impl fmt::Display for $name {
30            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31                f.write_str(&self.0)
32            }
33        }
34
35        impl From<&str> for $name {
36            fn from(s: &str) -> Self {
37                Self(Arc::from(s))
38            }
39        }
40
41        impl From<String> for $name {
42            fn from(s: String) -> Self {
43                Self(Arc::from(s.as_str()))
44            }
45        }
46
47        impl From<Arc<str>> for $name {
48            fn from(s: Arc<str>) -> Self {
49                Self(s)
50            }
51        }
52
53        impl AsRef<str> for $name {
54            fn as_ref(&self) -> &str {
55                &self.0
56            }
57        }
58
59        impl Borrow<str> for $name {
60            fn borrow(&self) -> &str {
61                &self.0
62            }
63        }
64
65        impl PartialEq<str> for $name {
66            fn eq(&self, other: &str) -> bool {
67                self.as_str() == other
68            }
69        }
70
71        impl PartialEq<&str> for $name {
72            fn eq(&self, other: &&str) -> bool {
73                self.as_str() == *other
74            }
75        }
76
77        impl FromStr for $name {
78            type Err = std::convert::Infallible;
79            fn from_str(s: &str) -> Result<Self, Self::Err> {
80                Ok(Self::from(s))
81            }
82        }
83
84        impl JsonSchema for $name {
85            fn schema_name() -> std::borrow::Cow<'static, str> {
86                stringify!($name).into()
87            }
88            fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
89                String::json_schema(generator)
90            }
91        }
92    };
93}
94
95arc_str_newtype!(TabId);
96arc_str_newtype!(SessionId);
97arc_str_newtype!(DeviceSerial);
98arc_str_newtype!(AppName);
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
101#[serde(rename_all = "snake_case")]
102pub enum DebugAction {
103    EvalJs,
104    Screenshot,
105    InjectCss,
106    RevertCss,
107    ListTabs,
108    GetPerfMetrics,
109    GetDom,
110    SetViewport,
111    ClearViewport,
112    NetworkConditions,
113    Navigate,
114    StorageClear,
115    StorageInspect,
116    StorageSet,
117    ElementAtPoint,
118    NewTab,
119    CloseTab,
120}
121
122#[derive(
123    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
124)]
125#[serde(rename_all = "snake_case")]
126pub enum Severity {
127    Trace,
128    Debug,
129    Info,
130    Warn,
131    Error,
132}
133
134impl Severity {
135    pub fn level(self) -> u8 {
136        match self {
137            Self::Trace => 0,
138            Self::Debug => 1,
139            Self::Info => 2,
140            Self::Warn => 3,
141            Self::Error => 4,
142        }
143    }
144}
145
146impl fmt::Display for Severity {
147    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148        let s = match self {
149            Self::Trace => "trace",
150            Self::Debug => "debug",
151            Self::Info => "info",
152            Self::Warn => "warn",
153            Self::Error => "error",
154        };
155        f.write_str(s)
156    }
157}
158
159impl std::str::FromStr for Severity {
160    type Err = String;
161
162    fn from_str(s: &str) -> Result<Self, Self::Err> {
163        match s.to_ascii_lowercase().as_str() {
164            "trace" => Ok(Self::Trace),
165            "debug" => Ok(Self::Debug),
166            "info" => Ok(Self::Info),
167            "warn" => Ok(Self::Warn),
168            "error" => Ok(Self::Error),
169            other => Err(format!("unknown severity: {other}")),
170        }
171    }
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
175#[serde(rename_all = "snake_case", tag = "type")]
176pub enum Origin {
177    Application {
178        name: AppName,
179    },
180    Browser {
181        tab_id: TabId,
182        url: String,
183    },
184    Device {
185        serial: DeviceSerial,
186        platform: DevicePlatform,
187    },
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
191#[serde(rename_all = "snake_case")]
192pub enum DevicePlatform {
193    #[default]
194    Android,
195    Vega,
196}
197
198/// Enum discriminant without payload — use for filtering/matching without carrying variant data.
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
200#[serde(rename_all = "snake_case")]
201pub enum ObservationKindTag {
202    Log,
203    Query,
204    HttpExchange,
205    Exception,
206    StateSnapshot,
207    Metric,
208    Custom,
209    JsException,
210    Lifecycle,
211}
212
213impl fmt::Display for ObservationKindTag {
214    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
215        let s = match self {
216            Self::Log => "log",
217            Self::Query => "query",
218            Self::HttpExchange => "http_exchange",
219            Self::Exception => "exception",
220            Self::StateSnapshot => "state_snapshot",
221            Self::Metric => "metric",
222            Self::Custom => "custom",
223            Self::JsException => "js_exception",
224            Self::Lifecycle => "lifecycle",
225        };
226        f.write_str(s)
227    }
228}
229
230impl std::str::FromStr for ObservationKindTag {
231    type Err = String;
232
233    fn from_str(s: &str) -> Result<Self, Self::Err> {
234        match s.to_ascii_lowercase().as_str() {
235            "log" => Ok(Self::Log),
236            "query" => Ok(Self::Query),
237            "http_exchange" => Ok(Self::HttpExchange),
238            "exception" => Ok(Self::Exception),
239            "state_snapshot" => Ok(Self::StateSnapshot),
240            "metric" => Ok(Self::Metric),
241            "custom" => Ok(Self::Custom),
242            "js_exception" => Ok(Self::JsException),
243            "lifecycle" => Ok(Self::Lifecycle),
244            other => Err(format!("unknown observation kind: {other}")),
245        }
246    }
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize)]
250#[serde(rename_all = "snake_case", tag = "type")]
251pub enum ObservationKind {
252    Log,
253    Query {
254        sql: String,
255        duration_ms: f64,
256    },
257    HttpExchange {
258        method: String,
259        url: String,
260        status: Option<u16>,
261        duration_ms: Option<f64>,
262    },
263    Exception {
264        message: String,
265        trace: Option<String>,
266    },
267    StateSnapshot {
268        label: String,
269    },
270    Metric {
271        name: String,
272        value: f64,
273    },
274    Custom {
275        channel: String,
276    },
277    JsException {
278        message: String,
279        line: Option<u32>,
280        column: Option<u32>,
281    },
282    Lifecycle {
283        event_name: String,
284        frame_id: String,
285    },
286}
287
288impl ObservationKind {
289    pub fn tag(&self) -> ObservationKindTag {
290        match self {
291            Self::Log => ObservationKindTag::Log,
292            Self::Query { .. } => ObservationKindTag::Query,
293            Self::HttpExchange { .. } => ObservationKindTag::HttpExchange,
294            Self::Exception { .. } => ObservationKindTag::Exception,
295            Self::StateSnapshot { .. } => ObservationKindTag::StateSnapshot,
296            Self::Metric { .. } => ObservationKindTag::Metric,
297            Self::Custom { .. } => ObservationKindTag::Custom,
298            Self::JsException { .. } => ObservationKindTag::JsException,
299            Self::Lifecycle { .. } => ObservationKindTag::Lifecycle,
300        }
301    }
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct SourceLocation {
306    pub file: String,
307    pub line: u32,
308    pub function: Option<String>,
309}
310
311/// Monotonic sequence number. "I've seen everything up to this ID."
312#[derive(
313    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
314)]
315pub struct Checkpoint(pub u64);
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct Observation {
319    pub id: u64,
320    pub origin: Origin,
321    pub kind: ObservationKind,
322    pub data: serde_json::Value,
323    pub severity: Severity,
324    pub source_location: Option<SourceLocation>,
325    pub timestamp_ns: u64,
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub correlation_id: Option<Arc<str>>,
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    pub parent_id: Option<u64>,
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub tags: Option<Vec<String>>,
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    pub session_id: Option<Arc<str>>,
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub node_id: Option<Arc<str>>,
336}
337
338impl Observation {
339    /// Build a new observation. `id` starts at 0 (the store assigns the real
340    /// monotonic sequence on insert) and `timestamp_ns` is captured from the
341    /// system clock at call time.
342    pub fn new(
343        origin: Origin,
344        kind: ObservationKind,
345        data: serde_json::Value,
346        severity: Severity,
347        source_location: Option<SourceLocation>,
348    ) -> Self {
349        let timestamp_ns = SystemTime::now()
350            .duration_since(UNIX_EPOCH)
351            .expect("system clock before UNIX epoch")
352            .as_nanos() as u64;
353
354        Self {
355            id: 0,
356            origin,
357            kind,
358            data,
359            severity,
360            source_location,
361            timestamp_ns,
362            correlation_id: None,
363            parent_id: None,
364            tags: None,
365            session_id: None,
366            node_id: None,
367        }
368    }
369}
370
371#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
372#[serde(rename_all = "snake_case")]
373pub enum OriginPattern {
374    AnyApplication,
375    ApplicationNamed(AppName),
376    AnyBrowser,
377    BrowserTab(TabId),
378    AnyDevice,
379    DeviceSerial(DeviceSerial),
380}
381
382impl OriginPattern {
383    pub fn parse(s: &str) -> Self {
384        match s.split_once(':') {
385            Some(("app", "*")) | None if s == "app" => Self::AnyApplication,
386            Some(("app", name)) => Self::ApplicationNamed(name.into()),
387            Some(("browser", "*")) | None if s == "browser" => Self::AnyBrowser,
388            Some(("browser", tab_id)) => Self::BrowserTab(tab_id.into()),
389            Some(("device", "*")) | None if s == "device" => Self::AnyDevice,
390            Some(("device", serial)) => Self::DeviceSerial(serial.into()),
391            _ => Self::ApplicationNamed(s.into()),
392        }
393    }
394}
395
396#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
397pub struct Filter {
398    pub kinds: Option<Vec<ObservationKindTag>>,
399    pub severity_min: Option<Severity>,
400    pub origins: Option<Vec<OriginPattern>>,
401    pub text_match: Option<String>,
402    pub since: Option<Checkpoint>,
403    pub limit: Option<usize>,
404    pub correlation_id: Option<String>,
405    pub tags: Option<Vec<String>>,
406    #[serde(default, skip_serializing_if = "Option::is_none")]
407    pub include_system: Option<bool>,
408}
409
410impl Filter {
411    pub fn matches(&self, obs: &Observation) -> bool {
412        if !self.include_system.unwrap_or(false)
413            && let Some(ref tags) = obs.tags
414            && tags.iter().any(|t| t == SYSTEM_TAG)
415        {
416            return false;
417        }
418
419        if let Some(ref cp) = self.since
420            && obs.id <= cp.0
421        {
422            return false;
423        }
424
425        if let Some(min) = self.severity_min
426            && obs.severity.level() < min.level()
427        {
428            return false;
429        }
430
431        if let Some(ref kinds) = self.kinds
432            && !kinds.is_empty()
433            && !kinds.contains(&obs.kind.tag())
434        {
435            return false;
436        }
437
438        if let Some(ref origins) = self.origins
439            && !origins.is_empty()
440            && !origins.iter().any(|p| p.matches(&obs.origin))
441        {
442            return false;
443        }
444
445        if let Some(ref text) = self.text_match {
446            let haystack = obs.data.to_string().to_ascii_lowercase();
447            if !haystack.contains(&text.to_ascii_lowercase()) {
448                return false;
449            }
450        }
451
452        if let Some(ref cid) = self.correlation_id {
453            match &obs.correlation_id {
454                Some(obs_cid) if obs_cid.as_ref() == cid.as_str() => {}
455                _ => return false,
456            }
457        }
458
459        if let Some(ref required_tags) = self.tags
460            && !required_tags.is_empty()
461        {
462            match &obs.tags {
463                Some(obs_tags) => {
464                    if !required_tags.iter().all(|t| obs_tags.contains(t)) {
465                        return false;
466                    }
467                }
468                None => return false,
469            }
470        }
471
472        true
473    }
474
475    pub fn parse_severity(raw: &str) -> Option<Severity> {
476        raw.trim().parse::<Severity>().ok()
477    }
478
479    pub fn parse_kinds(raw: &str) -> Vec<ObservationKindTag> {
480        raw.split(',')
481            .filter_map(|s| s.trim().parse::<ObservationKindTag>().ok())
482            .collect()
483    }
484
485    pub fn parse_origins(raw: &str) -> Vec<OriginPattern> {
486        raw.split(',')
487            .map(|s| OriginPattern::parse(s.trim()))
488            .collect()
489    }
490
491    pub fn parse_tags(raw: &str) -> Vec<String> {
492        raw.split(',')
493            .map(|s| s.trim().to_string())
494            .filter(|s| !s.is_empty())
495            .collect()
496    }
497
498    pub fn kinds_from_vec(v: Vec<String>) -> Vec<ObservationKindTag> {
499        v.into_iter()
500            .filter_map(|s| s.trim().parse::<ObservationKindTag>().ok())
501            .collect()
502    }
503
504    pub fn origins_from_vec(v: Vec<String>) -> Vec<OriginPattern> {
505        v.into_iter()
506            .map(|s| OriginPattern::parse(s.trim()))
507            .collect()
508    }
509}
510
511impl OriginPattern {
512    pub fn matches(&self, origin: &Origin) -> bool {
513        match (self, origin) {
514            (Self::AnyApplication, Origin::Application { .. }) => true,
515            (Self::ApplicationNamed(name), Origin::Application { name: n }) => n == name,
516            (Self::AnyBrowser, Origin::Browser { .. }) => true,
517            (Self::BrowserTab(tab), Origin::Browser { tab_id, .. }) => tab_id == tab,
518            (Self::AnyDevice, Origin::Device { .. }) => true,
519            (Self::DeviceSerial(serial), Origin::Device { serial: s, .. }) => s == serial,
520            _ => false,
521        }
522    }
523}
524
525#[derive(Debug, Clone, Serialize, Deserialize)]
526pub struct SliceSummary {
527    pub total: usize,
528    pub counts_by_kind: HashMap<String, usize>,
529    pub counts_by_severity: HashMap<String, usize>,
530}
531
532#[derive(Debug, Clone, Serialize, Deserialize)]
533pub struct StateSlice {
534    pub observations: Vec<Observation>,
535    pub checkpoint: Checkpoint,
536    pub summary: SliceSummary,
537}
538
539#[derive(Debug, Clone, Serialize, Deserialize)]
540#[serde(rename_all = "snake_case")]
541pub enum HealthStatus {
542    Ok,
543    ErrorsDetected,
544    NoSources,
545}
546
547#[derive(Debug, Clone, Serialize, Deserialize)]
548#[serde(rename_all = "snake_case")]
549pub enum ConnectionKind {
550    Application,
551    Browser,
552    Device,
553}
554
555#[derive(Debug, Clone, Serialize, Deserialize)]
556pub struct ConnectionInfo {
557    pub id: String,
558    pub kind: ConnectionKind,
559    pub name: String,
560    pub observation_count: u64,
561}
562
563#[derive(Debug, Clone, Serialize, Deserialize)]
564pub struct RuntimeSummary {
565    pub observation_count: u64,
566    pub error_count_last_60s: u64,
567    pub active_channels: Vec<String>,
568    pub connections: Vec<ConnectionInfo>,
569    pub health: HealthStatus,
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575    use serde_json::{Value, json};
576
577    fn obs(origin: Origin, kind: ObservationKind) -> Observation {
578        Observation {
579            id: 0,
580            origin,
581            kind,
582            data: json!({"note": "roundtrip fixture"}),
583            severity: Severity::Info,
584            source_location: None,
585            timestamp_ns: 0,
586            correlation_id: None,
587            parent_id: None,
588            tags: None,
589            session_id: None,
590            node_id: None,
591        }
592    }
593
594    fn roundtrip_observation(o: &Observation) {
595        let text = serde_json::to_string(o).expect("serialize");
596        let back: Observation = serde_json::from_str(&text).expect("deserialize");
597        let text_again = serde_json::to_string(&back).expect("reserialize");
598        assert_eq!(
599            text, text_again,
600            "observation should roundtrip identically; first={text} second={text_again}"
601        );
602    }
603
604    #[test]
605    fn origin_application_roundtrip() {
606        roundtrip_observation(&obs(
607            Origin::Application {
608                name: AppName::from("test-app"),
609            },
610            ObservationKind::Log,
611        ));
612    }
613
614    #[test]
615    fn origin_browser_roundtrip() {
616        roundtrip_observation(&obs(
617            Origin::Browser {
618                tab_id: TabId::from("tab-abc-123"),
619                url: "https://example.com/page".into(),
620            },
621            ObservationKind::Log,
622        ));
623    }
624
625    #[test]
626    fn origin_device_roundtrip() {
627        roundtrip_observation(&obs(
628            Origin::Device {
629                serial: DeviceSerial::from("emulator-5554"),
630                platform: DevicePlatform::Android,
631            },
632            ObservationKind::Log,
633        ));
634    }
635
636    #[test]
637    fn observation_kind_variants_all_roundtrip() {
638        let cases = vec![
639            ObservationKind::Log,
640            ObservationKind::Query {
641                sql: "SELECT 1".into(),
642                duration_ms: 3.5,
643            },
644            ObservationKind::HttpExchange {
645                method: "GET".into(),
646                url: "/api/users".into(),
647                status: Some(200),
648                duration_ms: Some(42.0),
649            },
650            ObservationKind::Exception {
651                message: "boom".into(),
652                trace: Some("stack".into()),
653            },
654            ObservationKind::StateSnapshot {
655                label: "before-migration".into(),
656            },
657            ObservationKind::Metric {
658                name: "cpu".into(),
659                value: 0.75,
660            },
661            ObservationKind::Custom {
662                channel: "events".into(),
663            },
664            ObservationKind::JsException {
665                message: "undefined".into(),
666                line: Some(12),
667                column: Some(5),
668            },
669            ObservationKind::Lifecycle {
670                event_name: "ready".into(),
671                frame_id: "frame-1".into(),
672            },
673        ];
674
675        for kind in cases {
676            roundtrip_observation(&obs(Origin::Application { name: "x".into() }, kind));
677        }
678    }
679
680    #[test]
681    fn newtypes_serialize_transparent_as_strings() {
682        let origin = Origin::Browser {
683            tab_id: TabId::from("abc"),
684            url: "https://x".into(),
685        };
686        let v: Value = serde_json::to_value(&origin).unwrap();
687        assert_eq!(
688            v["tab_id"],
689            json!("abc"),
690            "TabId must serialize as a bare string, got {v:#?}"
691        );
692
693        let origin = Origin::Device {
694            serial: DeviceSerial::from("S123"),
695            platform: DevicePlatform::Vega,
696        };
697        let v: Value = serde_json::to_value(&origin).unwrap();
698        assert_eq!(v["serial"], json!("S123"));
699
700        let origin = Origin::Application {
701            name: AppName::from("my-app"),
702        };
703        let v: Value = serde_json::to_value(&origin).unwrap();
704        assert_eq!(v["name"], json!("my-app"));
705    }
706
707    #[test]
708    fn newtypes_deserialize_from_bare_strings() {
709        let v = json!({
710            "type": "browser",
711            "tab_id": "tab-42",
712            "url": "https://example.com"
713        });
714        let origin: Origin = serde_json::from_value(v).unwrap();
715        match origin {
716            Origin::Browser { tab_id, url } => {
717                assert_eq!(tab_id.as_str(), "tab-42");
718                assert_eq!(url, "https://example.com");
719            }
720            _ => panic!("expected Browser origin"),
721        }
722    }
723
724    #[test]
725    fn origin_pattern_roundtrip_all_variants() {
726        let patterns = vec![
727            OriginPattern::AnyApplication,
728            OriginPattern::ApplicationNamed(AppName::from("svc")),
729            OriginPattern::AnyBrowser,
730            OriginPattern::BrowserTab(TabId::from("tab-9")),
731            OriginPattern::AnyDevice,
732            OriginPattern::DeviceSerial(DeviceSerial::from("S-42")),
733        ];
734        for p in patterns {
735            let text = serde_json::to_string(&p).unwrap();
736            let back: OriginPattern = serde_json::from_str(&text).unwrap();
737            let text_again = serde_json::to_string(&back).unwrap();
738            assert_eq!(text, text_again);
739        }
740    }
741
742    #[test]
743    fn filter_matches_honors_newtype_identity() {
744        let matching = obs(
745            Origin::Browser {
746                tab_id: TabId::from("tab-1"),
747                url: "".into(),
748            },
749            ObservationKind::Log,
750        );
751        let other = obs(
752            Origin::Browser {
753                tab_id: TabId::from("tab-2"),
754                url: "".into(),
755            },
756            ObservationKind::Log,
757        );
758        let filter = Filter {
759            origins: Some(vec![OriginPattern::BrowserTab(TabId::from("tab-1"))]),
760            ..Filter::default()
761        };
762        assert!(filter.matches(&matching));
763        assert!(!filter.matches(&other));
764    }
765
766    #[test]
767    fn tab_id_equality_regardless_of_construction_path() {
768        let from_str = TabId::from("abc");
769        let from_string = TabId::from(String::from("abc"));
770        let from_arc: TabId = std::sync::Arc::<str>::from("abc").into();
771        assert_eq!(from_str, from_string);
772        assert_eq!(from_string, from_arc);
773        assert_eq!(from_str, "abc");
774    }
775
776    #[test]
777    fn system_tag_excluded_by_default() {
778        let mut system_obs = obs(
779            Origin::Application {
780                name: AppName::from("hook"),
781            },
782            ObservationKind::Log,
783        );
784        system_obs.tags = Some(vec![SYSTEM_TAG.to_string()]);
785
786        let normal_obs = obs(
787            Origin::Application {
788                name: AppName::from("app"),
789            },
790            ObservationKind::Log,
791        );
792
793        let default_filter = Filter::default();
794        assert!(!default_filter.matches(&system_obs));
795        assert!(default_filter.matches(&normal_obs));
796
797        let include_filter = Filter {
798            include_system: Some(true),
799            ..Filter::default()
800        };
801        assert!(include_filter.matches(&system_obs));
802        assert!(include_filter.matches(&normal_obs));
803    }
804
805    #[test]
806    fn debug_action_roundtrip_snake_case() {
807        let cases = vec![
808            (DebugAction::EvalJs, "eval_js"),
809            (DebugAction::GetPerfMetrics, "get_perf_metrics"),
810            (DebugAction::SetViewport, "set_viewport"),
811            (DebugAction::StorageInspect, "storage_inspect"),
812            (DebugAction::ElementAtPoint, "element_at_point"),
813        ];
814        for (variant, wire) in cases {
815            let text = serde_json::to_string(&variant).unwrap();
816            assert_eq!(text, format!("\"{wire}\""));
817            let back: DebugAction = serde_json::from_str(&text).unwrap();
818            assert_eq!(back, variant);
819        }
820    }
821}