Skip to main content

webui_protocol/
lib.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! WebUI Protocol implementation.
5//!
6//! This crate defines the protocol used by the WebUI framework for cross-platform
7//! representation of UI components and templates. Types are generated directly
8//! from `proto/webui.proto` using prost for optimal runtime performance —
9//! no conversion layer between domain types and protobuf types.
10
11use prost::Message;
12use std::collections::HashMap;
13use std::fmt;
14use std::io;
15use thiserror::Error;
16
17/// Plugin-specific protocol helpers for framework hydration metadata.
18pub mod plugin;
19
20/// Bundler-neutral state-projection manifest contract.
21#[cfg(feature = "projection-manifest")]
22pub mod projection_manifest;
23
24/// Attribute-name ↔ property-name mapping for irregular HTML attributes.
25pub mod attrs;
26
27/// Generated protobuf types from `proto/webui.proto`.
28pub mod proto {
29    include!("gen_webui.rs");
30}
31
32// Re-export all generated types at the crate root.
33pub use plugin::FastElementData;
34pub use plugin::WebUIElementData;
35pub use proto::*;
36
37// Type aliases preserving the `WebUI` naming convention.
38// prost generates `WebUi*` from the proto `WebUI*` messages.
39pub type WebUIProtocol = WebUiProtocol;
40pub type WebUIFragment = WebUiFragment;
41pub type WebUIFragmentRaw = WebUiFragmentRaw;
42pub type WebUIFragmentComponent = WebUiFragmentComponent;
43pub type WebUIFragmentFor = WebUiFragmentFor;
44pub type WebUIFragmentSignal = WebUiFragmentSignal;
45pub type WebUIFragmentIf = WebUiFragmentIf;
46pub type WebUIFragmentAttribute = WebUiFragmentAttribute;
47pub type WebUIFragmentPlugin = WebUiFragmentPlugin;
48pub type WebUIFragmentRoute = WebUiFragmentRoute;
49pub type WebUIFragmentOutlet = WebUiFragmentOutlet;
50pub type ComponentData = proto::ComponentData;
51pub type StreamingBoundaryList = proto::StreamingBoundaryList;
52
53/// A mapping of unique fragment identifiers to their corresponding fragment lists.
54pub type WebUIFragmentRecords = HashMap<String, FragmentList>;
55
56#[derive(Debug, Error)]
57pub enum ProtocolError {
58    #[error("IO error: {0}")]
59    Io(#[from] io::Error),
60
61    #[error("Protocol validation error: {0}")]
62    Validation(String),
63}
64
65pub type Result<T> = std::result::Result<T, ProtocolError>;
66
67// ── Display implementations ─────────────────────────────────────────────
68
69impl fmt::Display for ComparisonOperator {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        match self {
72            ComparisonOperator::GreaterThan => write!(f, ">"),
73            ComparisonOperator::LessThan => write!(f, "<"),
74            ComparisonOperator::Equal => write!(f, "=="),
75            ComparisonOperator::NotEqual => write!(f, "!="),
76            ComparisonOperator::GreaterThanOrEqual => write!(f, ">="),
77            ComparisonOperator::LessThanOrEqual => write!(f, "<="),
78            ComparisonOperator::Unspecified => write!(f, "?"),
79        }
80    }
81}
82
83impl fmt::Display for LogicalOperator {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        match self {
86            LogicalOperator::And => write!(f, "&&"),
87            LogicalOperator::Or => write!(f, "||"),
88            LogicalOperator::Unspecified => write!(f, "?"),
89        }
90    }
91}
92
93impl fmt::Display for ConditionExpr {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        match &self.expr {
96            Some(condition_expr::Expr::Identifier(id)) => write!(f, "{}", id.value),
97            Some(condition_expr::Expr::Predicate(pred)) => {
98                let op = ComparisonOperator::try_from(pred.operator)
99                    .unwrap_or(ComparisonOperator::Unspecified);
100                write!(f, "{} {} {}", pred.left, op, pred.right)
101            }
102            Some(condition_expr::Expr::Not(not)) => match &not.condition {
103                Some(inner) => write!(f, "!({})", inner),
104                None => write!(f, "!(?)"),
105            },
106            Some(condition_expr::Expr::Compound(compound)) => {
107                let op =
108                    LogicalOperator::try_from(compound.op).unwrap_or(LogicalOperator::Unspecified);
109                let left_str = compound
110                    .left
111                    .as_ref()
112                    .map(|l| l.to_string())
113                    .unwrap_or_else(|| "?".to_string());
114                let right_str = compound
115                    .right
116                    .as_ref()
117                    .map(|r| r.to_string())
118                    .unwrap_or_else(|| "?".to_string());
119                write!(f, "({} {} {})", left_str, op, right_str)
120            }
121            None => write!(f, "<empty>"),
122        }
123    }
124}
125
126// ── Convenience constructors ────────────────────────────────────────────
127
128impl WebUiFragment {
129    /// Create a raw (static content) fragment.
130    pub fn raw(value: impl Into<String>) -> Self {
131        Self {
132            fragment: Some(web_ui_fragment::Fragment::Raw(WebUiFragmentRaw {
133                value: value.into(),
134            })),
135        }
136    }
137
138    /// Create a component fragment.
139    pub fn component(fragment_id: impl Into<String>) -> Self {
140        Self {
141            fragment: Some(web_ui_fragment::Fragment::Component(
142                WebUiFragmentComponent {
143                    fragment_id: fragment_id.into(),
144                },
145            )),
146        }
147    }
148
149    /// Create a for-loop fragment.
150    pub fn for_loop(
151        item: impl Into<String>,
152        collection: impl Into<String>,
153        fragment_id: impl Into<String>,
154    ) -> Self {
155        Self {
156            fragment: Some(web_ui_fragment::Fragment::ForLoop(WebUiFragmentFor {
157                item: item.into(),
158                collection: collection.into(),
159                fragment_id: fragment_id.into(),
160            })),
161        }
162    }
163
164    /// Create a signal fragment.
165    pub fn signal(value: impl Into<String>, raw: bool) -> Self {
166        Self {
167            fragment: Some(web_ui_fragment::Fragment::Signal(WebUiFragmentSignal {
168                value: value.into(),
169                raw,
170            })),
171        }
172    }
173
174    /// Create an if-condition fragment.
175    pub fn if_cond(condition: ConditionExpr, fragment_id: impl Into<String>) -> Self {
176        Self {
177            fragment: Some(web_ui_fragment::Fragment::IfCond(WebUiFragmentIf {
178                condition: Some(condition),
179                fragment_id: fragment_id.into(),
180            })),
181        }
182    }
183
184    /// Create a simple dynamic attribute fragment (value is a single signal name).
185    pub fn attribute(name: impl Into<String>, value: impl Into<String>) -> Self {
186        Self {
187            fragment: Some(web_ui_fragment::Fragment::Attribute(
188                WebUiFragmentAttribute {
189                    name: name.into(),
190                    value: value.into(),
191                    ..Default::default()
192                },
193            )),
194        }
195    }
196
197    /// Create a template attribute fragment (mixed static + dynamic content).
198    pub fn attribute_template(name: impl Into<String>, template: impl Into<String>) -> Self {
199        Self {
200            fragment: Some(web_ui_fragment::Fragment::Attribute(
201                WebUiFragmentAttribute {
202                    name: name.into(),
203                    template: template.into(),
204                    ..Default::default()
205                },
206            )),
207        }
208    }
209
210    /// Create a complex attribute fragment (:-prefixed).
211    pub fn attribute_complex(name: impl Into<String>, value: impl Into<String>) -> Self {
212        Self {
213            fragment: Some(web_ui_fragment::Fragment::Attribute(
214                WebUiFragmentAttribute {
215                    name: name.into(),
216                    value: value.into(),
217                    complex: true,
218                    ..Default::default()
219                },
220            )),
221        }
222    }
223
224    /// Create a boolean attribute fragment (?-prefixed) with a condition tree.
225    pub fn attribute_boolean(name: impl Into<String>, condition_tree: ConditionExpr) -> Self {
226        Self {
227            fragment: Some(web_ui_fragment::Fragment::Attribute(
228                WebUiFragmentAttribute {
229                    name: name.into(),
230                    condition_tree: Some(condition_tree),
231                    ..Default::default()
232                },
233            )),
234        }
235    }
236
237    /// Create a plugin data fragment with opaque bytes.
238    /// The data is passed through to the handler plugin without interpretation.
239    pub fn plugin(data: Vec<u8>) -> Self {
240        Self {
241            fragment: Some(web_ui_fragment::Fragment::Plugin(WebUiFragmentPlugin {
242                data,
243            })),
244        }
245    }
246
247    /// Create a route fragment linking a URL path template to a fragment.
248    pub fn route(path: impl Into<String>, fragment_id: impl Into<String>) -> Self {
249        Self {
250            fragment: Some(web_ui_fragment::Fragment::Route(WebUiFragmentRoute {
251                path: path.into(),
252                fragment_id: fragment_id.into(),
253                keep_alive: false,
254                ..Default::default()
255            })),
256        }
257    }
258
259    /// Create a route fragment from a pre-built `WebUiFragmentRoute`.
260    pub fn route_from(route: WebUiFragmentRoute) -> Self {
261        Self {
262            fragment: Some(web_ui_fragment::Fragment::Route(route)),
263        }
264    }
265
266    /// Create an outlet fragment.
267    pub fn outlet() -> Self {
268        Self {
269            fragment: Some(web_ui_fragment::Fragment::Outlet(WebUiFragmentOutlet {})),
270        }
271    }
272}
273
274impl ConditionExpr {
275    /// Create an identifier condition.
276    pub fn identifier(value: impl Into<String>) -> Self {
277        Self {
278            expr: Some(condition_expr::Expr::Identifier(IdentifierCondition {
279                value: value.into(),
280            })),
281        }
282    }
283
284    /// Create a predicate condition.
285    pub fn predicate(
286        left: impl Into<String>,
287        operator: ComparisonOperator,
288        right: impl Into<String>,
289    ) -> Self {
290        Self {
291            expr: Some(condition_expr::Expr::Predicate(Predicate {
292                left: left.into(),
293                operator: operator as i32,
294                right: right.into(),
295            })),
296        }
297    }
298
299    /// Create a negation condition.
300    pub fn negated(inner: ConditionExpr) -> Self {
301        Self {
302            expr: Some(condition_expr::Expr::Not(Box::new(NotCondition {
303                condition: Some(Box::new(inner)),
304            }))),
305        }
306    }
307
308    /// Create a compound condition.
309    pub fn compound(left: ConditionExpr, op: LogicalOperator, right: ConditionExpr) -> Self {
310        Self {
311            expr: Some(condition_expr::Expr::Compound(Box::new(
312                CompoundCondition {
313                    left: Some(Box::new(left)),
314                    op: op as i32,
315                    right: Some(Box::new(right)),
316                },
317            ))),
318        }
319    }
320}
321
322// ── Constructors ────────────────────────────────────────────────────────
323
324impl WebUiProtocol {
325    /// Create a protocol from fragment records with no CSS tokens.
326    pub fn new(fragments: WebUIFragmentRecords) -> Self {
327        Self {
328            fragments,
329            tokens: Vec::new(),
330            components: HashMap::new(),
331            css_strategy: 0,
332            dom_strategy: 0,
333            initial_state_strategy: InitialStateStrategy::Full as i32,
334            module_preloads: Vec::new(),
335            streaming_boundaries: HashMap::new(),
336            component_render_css: String::new(),
337        }
338    }
339
340    /// Create a protocol from fragment records with CSS tokens.
341    pub fn with_tokens(fragments: WebUIFragmentRecords, tokens: Vec<String>) -> Self {
342        Self {
343            fragments,
344            tokens,
345            components: HashMap::new(),
346            css_strategy: 0,
347            dom_strategy: 0,
348            initial_state_strategy: InitialStateStrategy::Full as i32,
349            module_preloads: Vec::new(),
350            streaming_boundaries: HashMap::new(),
351            component_render_css: String::new(),
352        }
353    }
354}
355
356// ── Serialization / deserialization / validation ────────────────────────
357
358impl WebUiProtocol {
359    /// Validate that all fragment references point to existing fragment IDs.
360    fn validate_protocol(protocol: Self) -> Result<Self> {
361        let fragments = &protocol.fragments;
362
363        let invalid_ref = fragments.iter().find_map(|(_, fragment_list)| {
364            fragment_list
365                .fragments
366                .iter()
367                .find_map(|frag| match frag.fragment.as_ref() {
368                    Some(web_ui_fragment::Fragment::Component(comp))
369                        if !fragments.contains_key(&comp.fragment_id) =>
370                    {
371                        Some(ProtocolError::Validation(format!(
372                            "Component references non-existent fragment ID: {}",
373                            comp.fragment_id
374                        )))
375                    }
376                    Some(web_ui_fragment::Fragment::ForLoop(fl))
377                        if !fragments.contains_key(&fl.fragment_id) =>
378                    {
379                        Some(ProtocolError::Validation(format!(
380                            "For loop references non-existent fragment ID: {}",
381                            fl.fragment_id
382                        )))
383                    }
384                    Some(web_ui_fragment::Fragment::IfCond(ic))
385                        if !fragments.contains_key(&ic.fragment_id) =>
386                    {
387                        Some(ProtocolError::Validation(format!(
388                            "If condition references non-existent fragment ID: {}",
389                            ic.fragment_id
390                        )))
391                    }
392                    Some(web_ui_fragment::Fragment::Attribute(attr))
393                        if !attr.template.is_empty() && !fragments.contains_key(&attr.template) =>
394                    {
395                        Some(ProtocolError::Validation(format!(
396                            "Attribute references non-existent template fragment ID: {}",
397                            attr.template
398                        )))
399                    }
400                    Some(web_ui_fragment::Fragment::Route(route)) => {
401                        if !route.fragment_id.is_empty()
402                            && !fragments.contains_key(&route.fragment_id)
403                        {
404                            return Some(ProtocolError::Validation(format!(
405                                "Route references non-existent fragment ID: {}",
406                                route.fragment_id
407                            )));
408                        }
409                        None
410                    }
411                    _ => None,
412                })
413        });
414
415        if let Some(err) = invalid_ref {
416            return Err(err);
417        }
418
419        Ok(protocol)
420    }
421
422    /// Serialize protocol to pretty JSON (for debug/inspect output only).
423    pub fn to_json_pretty(&self) -> std::result::Result<String, serde_json::Error> {
424        serde_json::to_string_pretty(self)
425    }
426
427    /// Serialize protocol to protobuf binary format.
428    pub fn to_protobuf(&self) -> Result<Vec<u8>> {
429        let len = self.encoded_len();
430        let mut buf = Vec::with_capacity(len);
431        self.encode(&mut buf)
432            .map_err(|e| ProtocolError::Validation(format!("Protobuf encode error: {e}")))?;
433        Ok(buf)
434    }
435
436    /// Deserialize protocol from protobuf binary bytes with validation.
437    pub fn from_protobuf(bytes: &[u8]) -> Result<Self> {
438        let protocol = Self::decode(bytes)
439            .map_err(|e| ProtocolError::Validation(format!("Protobuf decode error: {e}")))?;
440        Self::validate_protocol(protocol)
441    }
442
443    /// Read and deserialize a protobuf file with validation.
444    pub fn from_protobuf_file<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
445        let bytes = std::fs::read(path)?;
446        Self::from_protobuf(&bytes)
447    }
448
449    /// Write protocol to a protobuf file.
450    pub fn to_protobuf_file<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
451        let bytes = self.to_protobuf()?;
452        std::fs::write(path, bytes)?;
453        Ok(())
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460
461    fn sample_protocol() -> WebUIProtocol {
462        let mut fragments = HashMap::new();
463        fragments.insert(
464            "index.html".to_string(),
465            FragmentList {
466                fragments: vec![
467                    WebUIFragment::raw("Hello, WebUI!\n"),
468                    WebUIFragment::for_loop("person", "people", "for-1"),
469                    WebUIFragment::signal("description", true),
470                    WebUIFragment::if_cond(ConditionExpr::identifier("contact"), "if-1"),
471                ],
472            },
473        );
474        fragments.insert(
475            "for-1".to_string(),
476            FragmentList {
477                fragments: vec![WebUIFragment::signal("person.name", false)],
478            },
479        );
480        fragments.insert(
481            "if-1".to_string(),
482            FragmentList {
483                fragments: vec![WebUIFragment::component("contact-card")],
484            },
485        );
486        fragments.insert(
487            "contact-card".to_string(),
488            FragmentList {
489                fragments: vec![
490                    WebUIFragment::raw("Hello, "),
491                    WebUIFragment::signal("name", false),
492                ],
493            },
494        );
495        WebUIProtocol::new(fragments)
496    }
497
498    #[test]
499    fn test_protobuf_roundtrip() {
500        let protocol = sample_protocol();
501        let bytes = protocol.to_protobuf().expect("encode failed");
502        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
503        assert_eq!(protocol, decoded);
504    }
505
506    #[test]
507    fn test_protobuf_module_preloads_roundtrip_preserves_order() {
508        // Order is load-bearing: preloads are issued in document order over one
509        // connection, and a measured 125 ms swing separates largest-first from
510        // smallest-first. A reordering encoder would silently erase the win.
511        let mut protocol = sample_protocol();
512        protocol.module_preloads = vec![
513            "/chunk-big.js".to_string(),
514            "/chunk-mid.js".to_string(),
515            "/chunk-small.js".to_string(),
516        ];
517
518        let bytes = protocol.to_protobuf().expect("encode failed");
519        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
520
521        assert_eq!(decoded.module_preloads, protocol.module_preloads);
522        assert_eq!(protocol, decoded);
523    }
524
525    #[test]
526    fn test_protobuf_no_module_preloads_is_absent_from_the_wire() {
527        // Builds without hints must not pay a byte for the field.
528        let protocol = sample_protocol();
529        let bytes = protocol.to_protobuf().expect("encode failed");
530        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
531
532        assert!(decoded.module_preloads.is_empty());
533        assert!(
534            !bytes.windows(2).any(|w| w == [0x3a, 0x00]),
535            "an empty repeated field must not be encoded"
536        );
537    }
538
539    #[test]
540    fn test_protobuf_streaming_boundary_names_roundtrip_in_declaration_order() {
541        let mut protocol = sample_protocol();
542        protocol.streaming_boundaries.insert(
543            "main".to_string(),
544            StreamingBoundaryList {
545                names: vec![
546                    "weather shell".to_string(),
547                    "composer/ready".to_string(),
548                    "feed:batch".to_string(),
549                ],
550            },
551        );
552
553        let bytes = protocol.to_protobuf().expect("encode failed");
554        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
555
556        assert_eq!(
557            decoded.streaming_boundaries["main"].names,
558            protocol.streaming_boundaries["main"].names
559        );
560        assert_eq!(protocol, decoded);
561    }
562
563    #[test]
564    fn test_protobuf_all_fragment_types() {
565        let mut fragments = HashMap::new();
566        fragments.insert(
567            "main".to_string(),
568            FragmentList {
569                fragments: vec![
570                    WebUIFragment::raw("text"),
571                    WebUIFragment::component("comp"),
572                    WebUIFragment::for_loop("x", "xs", "loop"),
573                    WebUIFragment::signal("sig", true),
574                    WebUIFragment::if_cond(
575                        ConditionExpr::predicate("a", ComparisonOperator::GreaterThan, "1"),
576                        "cond",
577                    ),
578                ],
579            },
580        );
581        fragments.insert(
582            "comp".to_string(),
583            FragmentList {
584                fragments: vec![WebUIFragment::raw("c")],
585            },
586        );
587        fragments.insert(
588            "loop".to_string(),
589            FragmentList {
590                fragments: vec![WebUIFragment::raw("l")],
591            },
592        );
593        fragments.insert(
594            "cond".to_string(),
595            FragmentList {
596                fragments: vec![WebUIFragment::raw("i")],
597            },
598        );
599
600        let protocol = WebUIProtocol::new(fragments);
601        let bytes = protocol.to_protobuf().unwrap();
602        let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
603        assert_eq!(protocol, decoded);
604    }
605
606    #[test]
607    fn test_protobuf_all_comparison_operators() {
608        let ops = [
609            ComparisonOperator::GreaterThan,
610            ComparisonOperator::LessThan,
611            ComparisonOperator::Equal,
612            ComparisonOperator::NotEqual,
613            ComparisonOperator::GreaterThanOrEqual,
614            ComparisonOperator::LessThanOrEqual,
615        ];
616        for op in &ops {
617            let mut fragments = HashMap::new();
618            fragments.insert(
619                "main".to_string(),
620                FragmentList {
621                    fragments: vec![WebUIFragment::if_cond(
622                        ConditionExpr::predicate("a", *op, "b"),
623                        "then",
624                    )],
625                },
626            );
627            fragments.insert(
628                "then".to_string(),
629                FragmentList {
630                    fragments: vec![WebUIFragment::raw("ok")],
631                },
632            );
633            let p = WebUIProtocol::new(fragments);
634            let bytes = p.to_protobuf().unwrap();
635            let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
636            assert_eq!(p, decoded);
637        }
638    }
639
640    #[test]
641    fn test_protobuf_nested_conditions() {
642        let nested = ConditionExpr::compound(
643            ConditionExpr::predicate("user.role", ComparisonOperator::Equal, "admin"),
644            LogicalOperator::And,
645            ConditionExpr::negated(ConditionExpr::predicate(
646                "user.disabled",
647                ComparisonOperator::Equal,
648                "true",
649            )),
650        );
651
652        let mut fragments = HashMap::new();
653        fragments.insert(
654            "main".to_string(),
655            FragmentList {
656                fragments: vec![WebUIFragment::if_cond(nested, "then")],
657            },
658        );
659        fragments.insert(
660            "then".to_string(),
661            FragmentList {
662                fragments: vec![WebUIFragment::raw("ok")],
663            },
664        );
665        let p = WebUIProtocol::new(fragments);
666        let bytes = p.to_protobuf().unwrap();
667        let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
668        assert_eq!(p, decoded);
669    }
670
671    #[test]
672    fn test_protobuf_compound_or_condition() {
673        let compound = ConditionExpr::compound(
674            ConditionExpr::identifier("isAdmin"),
675            LogicalOperator::Or,
676            ConditionExpr::identifier("isEditor"),
677        );
678
679        let mut fragments = HashMap::new();
680        fragments.insert(
681            "main".to_string(),
682            FragmentList {
683                fragments: vec![WebUIFragment::if_cond(compound, "body")],
684            },
685        );
686        fragments.insert(
687            "body".to_string(),
688            FragmentList {
689                fragments: vec![WebUIFragment::raw("yes")],
690            },
691        );
692        let p = WebUIProtocol::new(fragments);
693        let bytes = p.to_protobuf().unwrap();
694        let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
695        assert_eq!(p, decoded);
696    }
697
698    #[test]
699    fn test_protobuf_invalid_bytes() {
700        let result = WebUIProtocol::from_protobuf(&[0xFF, 0xFF, 0xFF]);
701        assert!(result.is_err());
702    }
703
704    #[test]
705    fn test_protobuf_empty_bytes() {
706        let result = WebUIProtocol::from_protobuf(&[]);
707        assert!(result.is_ok());
708        assert!(result.unwrap().fragments.is_empty());
709    }
710
711    #[test]
712    fn test_protobuf_file_roundtrip() {
713        let protocol = sample_protocol();
714        let dir = std::env::temp_dir().join("webui-proto-test");
715        std::fs::create_dir_all(&dir).unwrap();
716        let path = dir.join("test.bin");
717
718        protocol.to_protobuf_file(&path).unwrap();
719        let decoded = WebUIProtocol::from_protobuf_file(&path).unwrap();
720        assert_eq!(protocol, decoded);
721
722        std::fs::remove_dir_all(&dir).ok();
723    }
724
725    #[test]
726    fn test_protobuf_validation_catches_missing_reference() {
727        let mut fragments = HashMap::new();
728        fragments.insert(
729            "main".to_string(),
730            FragmentList {
731                fragments: vec![WebUIFragment::component("does-not-exist")],
732            },
733        );
734
735        let protocol = WebUIProtocol::new(fragments);
736        let buf = protocol.to_protobuf().unwrap();
737
738        let result = WebUIProtocol::from_protobuf(&buf);
739        assert!(result.is_err());
740    }
741
742    #[test]
743    fn test_protobuf_validation_catches_missing_for_reference() {
744        let mut fragments = HashMap::new();
745        fragments.insert(
746            "main".to_string(),
747            FragmentList {
748                fragments: vec![WebUIFragment::for_loop("item", "items", "missing-for")],
749            },
750        );
751
752        let protocol = WebUIProtocol::new(fragments);
753        let buf = protocol.to_protobuf().unwrap();
754
755        let result = WebUIProtocol::from_protobuf(&buf);
756        assert!(result.is_err());
757        if let Err(ProtocolError::Validation(msg)) = result {
758            assert!(msg.contains("missing-for"));
759        }
760    }
761
762    #[test]
763    fn test_protobuf_validation_catches_missing_if_reference() {
764        let mut fragments = HashMap::new();
765        fragments.insert(
766            "main".to_string(),
767            FragmentList {
768                fragments: vec![WebUIFragment::if_cond(
769                    ConditionExpr::identifier("flag"),
770                    "missing-if",
771                )],
772            },
773        );
774
775        let protocol = WebUIProtocol::new(fragments);
776        let buf = protocol.to_protobuf().unwrap();
777
778        let result = WebUIProtocol::from_protobuf(&buf);
779        assert!(result.is_err());
780        if let Err(ProtocolError::Validation(msg)) = result {
781            assert!(msg.contains("missing-if"));
782        }
783    }
784
785    #[test]
786    fn test_protobuf_signal_default_raw_false() {
787        let mut fragments = HashMap::new();
788        fragments.insert(
789            "main".to_string(),
790            FragmentList {
791                fragments: vec![WebUIFragment::signal("name", false)],
792            },
793        );
794        let p = WebUIProtocol::new(fragments);
795        let bytes = p.to_protobuf().unwrap();
796        let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
797        let frag = &decoded.fragments["main"].fragments[0];
798        match frag.fragment.as_ref() {
799            Some(web_ui_fragment::Fragment::Signal(s)) => assert!(!s.raw),
800            _ => panic!("expected signal"),
801        }
802    }
803
804    #[test]
805    fn test_protobuf_pre_allocated_buffer() {
806        let protocol = sample_protocol();
807        let bytes = protocol.to_protobuf().unwrap();
808        assert_eq!(bytes.len(), protocol.encoded_len());
809    }
810
811    #[test]
812    fn test_protocol_new_has_empty_tokens() {
813        let protocol = WebUIProtocol::new(HashMap::new());
814        assert!(protocol.tokens.is_empty());
815        assert!(protocol.fragments.is_empty());
816        assert_eq!(
817            protocol.initial_state_strategy,
818            InitialStateStrategy::Full as i32
819        );
820    }
821
822    #[test]
823    fn test_projection_metadata_roundtrips() {
824        let mut protocol = WebUIProtocol::new(HashMap::new());
825        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
826        protocol.components.insert(
827            "my-card".to_string(),
828            ComponentData {
829                hydration_keys: vec!["name".to_string()],
830                hydration_mode: StateProjectionMode::Keys as i32,
831                navigation_mode: Some(StateProjectionMode::All as i32),
832                ..Default::default()
833            },
834        );
835
836        let bytes = protocol.to_protobuf().unwrap();
837        let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
838        assert_eq!(
839            decoded.initial_state_strategy,
840            InitialStateStrategy::Components as i32
841        );
842        let component = &decoded.components["my-card"];
843        assert_eq!(component.hydration_mode, StateProjectionMode::Keys as i32);
844        assert_eq!(component.hydration_keys, ["name"]);
845        assert_eq!(
846            component.navigation_mode,
847            Some(StateProjectionMode::All as i32)
848        );
849        assert!(component.navigation_keys.is_empty());
850    }
851
852    #[test]
853    fn test_protocol_with_tokens() {
854        let tokens = vec!["color-primary".to_string(), "spacing-m".to_string()];
855        let protocol = WebUIProtocol::with_tokens(HashMap::new(), tokens.clone());
856        assert_eq!(protocol.tokens, tokens);
857    }
858
859    #[test]
860    fn test_protobuf_route_fragment_roundtrip() {
861        let mut fragments = HashMap::new();
862        fragments.insert(
863            "main".to_string(),
864            FragmentList {
865                fragments: vec![WebUIFragment::route("/profile/:id", "profile-page")],
866            },
867        );
868        fragments.insert(
869            "profile-page".to_string(),
870            FragmentList {
871                fragments: vec![WebUIFragment::raw("<h1>Profile</h1>")],
872            },
873        );
874        let protocol = WebUIProtocol::new(fragments);
875        let bytes = protocol.to_protobuf().expect("encode failed");
876        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
877        assert_eq!(protocol, decoded);
878
879        let frag = &decoded.fragments["main"].fragments[0];
880        match frag.fragment.as_ref() {
881            Some(web_ui_fragment::Fragment::Route(r)) => {
882                assert_eq!(r.path, "/profile/:id");
883                assert_eq!(r.fragment_id, "profile-page");
884            }
885            _ => panic!("expected route fragment"),
886        }
887    }
888
889    #[test]
890    fn test_protobuf_route_fragment_all_fields() {
891        let mut fragments = HashMap::new();
892        let route_frag = WebUiFragment {
893            fragment: Some(web_ui_fragment::Fragment::Route(WebUiFragmentRoute {
894                path: "/users/:id/posts/:postId".to_string(),
895                fragment_id: "user-posts".to_string(),
896                exact: true,
897                children: Vec::new(),
898                allowed_query: "action,to,subject".to_string(),
899                keep_alive: false,
900                cache_tags: vec!["user:{id}".to_string(), "posts".to_string()],
901                invalidates: vec!["posts".to_string(), "counts".to_string()],
902                pending_component: "loading-skeleton".to_string(),
903                error_component: "error-page".to_string(),
904            })),
905        };
906        fragments.insert(
907            "main".to_string(),
908            FragmentList {
909                fragments: vec![route_frag],
910            },
911        );
912        fragments.insert(
913            "user-posts".into(),
914            FragmentList {
915                fragments: vec![WebUIFragment::raw("posts")],
916            },
917        );
918
919        let protocol = WebUIProtocol::new(fragments);
920        let bytes = protocol.to_protobuf().expect("encode failed");
921        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
922        assert_eq!(protocol, decoded);
923    }
924
925    #[test]
926    fn test_protobuf_route_validation_missing_fragment() {
927        let mut fragments = HashMap::new();
928        fragments.insert(
929            "main".to_string(),
930            FragmentList {
931                fragments: vec![WebUIFragment::route("/test", "missing-fragment")],
932            },
933        );
934        let protocol = WebUIProtocol::new(fragments);
935        let buf = protocol.to_protobuf().expect("encode failed");
936        let result = WebUIProtocol::from_protobuf(&buf);
937        assert!(result.is_err());
938        if let Err(ProtocolError::Validation(msg)) = result {
939            assert!(msg.contains("missing-fragment"));
940        }
941    }
942
943    #[test]
944    fn test_protobuf_route_no_fragment_id_roundtrip() {
945        let mut fragments = HashMap::new();
946        let route_frag = WebUiFragment {
947            fragment: Some(web_ui_fragment::Fragment::Route(WebUiFragmentRoute {
948                path: "/old-path".to_string(),
949                keep_alive: false,
950                ..Default::default()
951            })),
952        };
953        fragments.insert(
954            "main".to_string(),
955            FragmentList {
956                fragments: vec![route_frag],
957            },
958        );
959        let protocol = WebUIProtocol::new(fragments);
960        let bytes = protocol.to_protobuf().expect("encode failed");
961        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
962        assert_eq!(protocol, decoded);
963    }
964
965    #[test]
966    fn test_protobuf_backward_compat_no_routes() {
967        // Protocol without any fragments should decode successfully
968        let protocol = WebUIProtocol::new(HashMap::new());
969        let bytes = protocol.to_protobuf().expect("encode failed");
970        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
971        assert!(decoded.fragments.is_empty());
972    }
973
974    #[test]
975    fn test_protobuf_roundtrip_with_tokens() {
976        let mut fragments = HashMap::new();
977        fragments.insert(
978            "index.html".to_string(),
979            FragmentList {
980                fragments: vec![WebUIFragment::raw("Hello")],
981            },
982        );
983        let tokens = vec!["border-radius-m".to_string(), "color-primary".to_string()];
984        let protocol = WebUIProtocol::with_tokens(fragments, tokens.clone());
985
986        let bytes = protocol.to_protobuf().expect("encode failed");
987        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
988
989        assert_eq!(decoded.tokens, tokens);
990        assert!(decoded.fragments.contains_key("index.html"));
991    }
992}