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        }
337    }
338
339    /// Create a protocol from fragment records with CSS tokens.
340    pub fn with_tokens(fragments: WebUIFragmentRecords, tokens: Vec<String>) -> Self {
341        Self {
342            fragments,
343            tokens,
344            components: HashMap::new(),
345            css_strategy: 0,
346            dom_strategy: 0,
347            initial_state_strategy: InitialStateStrategy::Full as i32,
348            module_preloads: Vec::new(),
349            streaming_boundaries: HashMap::new(),
350        }
351    }
352}
353
354// ── Serialization / deserialization / validation ────────────────────────
355
356impl WebUiProtocol {
357    /// Validate that all fragment references point to existing fragment IDs.
358    fn validate_protocol(protocol: Self) -> Result<Self> {
359        let fragments = &protocol.fragments;
360
361        let invalid_ref = fragments.iter().find_map(|(_, fragment_list)| {
362            fragment_list
363                .fragments
364                .iter()
365                .find_map(|frag| match frag.fragment.as_ref() {
366                    Some(web_ui_fragment::Fragment::Component(comp))
367                        if !fragments.contains_key(&comp.fragment_id) =>
368                    {
369                        Some(ProtocolError::Validation(format!(
370                            "Component references non-existent fragment ID: {}",
371                            comp.fragment_id
372                        )))
373                    }
374                    Some(web_ui_fragment::Fragment::ForLoop(fl))
375                        if !fragments.contains_key(&fl.fragment_id) =>
376                    {
377                        Some(ProtocolError::Validation(format!(
378                            "For loop references non-existent fragment ID: {}",
379                            fl.fragment_id
380                        )))
381                    }
382                    Some(web_ui_fragment::Fragment::IfCond(ic))
383                        if !fragments.contains_key(&ic.fragment_id) =>
384                    {
385                        Some(ProtocolError::Validation(format!(
386                            "If condition references non-existent fragment ID: {}",
387                            ic.fragment_id
388                        )))
389                    }
390                    Some(web_ui_fragment::Fragment::Attribute(attr))
391                        if !attr.template.is_empty() && !fragments.contains_key(&attr.template) =>
392                    {
393                        Some(ProtocolError::Validation(format!(
394                            "Attribute references non-existent template fragment ID: {}",
395                            attr.template
396                        )))
397                    }
398                    Some(web_ui_fragment::Fragment::Route(route)) => {
399                        if !route.fragment_id.is_empty()
400                            && !fragments.contains_key(&route.fragment_id)
401                        {
402                            return Some(ProtocolError::Validation(format!(
403                                "Route references non-existent fragment ID: {}",
404                                route.fragment_id
405                            )));
406                        }
407                        None
408                    }
409                    _ => None,
410                })
411        });
412
413        if let Some(err) = invalid_ref {
414            return Err(err);
415        }
416
417        Ok(protocol)
418    }
419
420    /// Serialize protocol to pretty JSON (for debug/inspect output only).
421    pub fn to_json_pretty(&self) -> std::result::Result<String, serde_json::Error> {
422        serde_json::to_string_pretty(self)
423    }
424
425    /// Serialize protocol to protobuf binary format.
426    pub fn to_protobuf(&self) -> Result<Vec<u8>> {
427        let len = self.encoded_len();
428        let mut buf = Vec::with_capacity(len);
429        self.encode(&mut buf)
430            .map_err(|e| ProtocolError::Validation(format!("Protobuf encode error: {e}")))?;
431        Ok(buf)
432    }
433
434    /// Deserialize protocol from protobuf binary bytes with validation.
435    pub fn from_protobuf(bytes: &[u8]) -> Result<Self> {
436        let protocol = Self::decode(bytes)
437            .map_err(|e| ProtocolError::Validation(format!("Protobuf decode error: {e}")))?;
438        Self::validate_protocol(protocol)
439    }
440
441    /// Read and deserialize a protobuf file with validation.
442    pub fn from_protobuf_file<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
443        let bytes = std::fs::read(path)?;
444        Self::from_protobuf(&bytes)
445    }
446
447    /// Write protocol to a protobuf file.
448    pub fn to_protobuf_file<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
449        let bytes = self.to_protobuf()?;
450        std::fs::write(path, bytes)?;
451        Ok(())
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458
459    fn sample_protocol() -> WebUIProtocol {
460        let mut fragments = HashMap::new();
461        fragments.insert(
462            "index.html".to_string(),
463            FragmentList {
464                fragments: vec![
465                    WebUIFragment::raw("Hello, WebUI!\n"),
466                    WebUIFragment::for_loop("person", "people", "for-1"),
467                    WebUIFragment::signal("description", true),
468                    WebUIFragment::if_cond(ConditionExpr::identifier("contact"), "if-1"),
469                ],
470            },
471        );
472        fragments.insert(
473            "for-1".to_string(),
474            FragmentList {
475                fragments: vec![WebUIFragment::signal("person.name", false)],
476            },
477        );
478        fragments.insert(
479            "if-1".to_string(),
480            FragmentList {
481                fragments: vec![WebUIFragment::component("contact-card")],
482            },
483        );
484        fragments.insert(
485            "contact-card".to_string(),
486            FragmentList {
487                fragments: vec![
488                    WebUIFragment::raw("Hello, "),
489                    WebUIFragment::signal("name", false),
490                ],
491            },
492        );
493        WebUIProtocol::new(fragments)
494    }
495
496    #[test]
497    fn test_protobuf_roundtrip() {
498        let protocol = sample_protocol();
499        let bytes = protocol.to_protobuf().expect("encode failed");
500        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
501        assert_eq!(protocol, decoded);
502    }
503
504    #[test]
505    fn test_protobuf_module_preloads_roundtrip_preserves_order() {
506        // Order is load-bearing: preloads are issued in document order over one
507        // connection, and a measured 125 ms swing separates largest-first from
508        // smallest-first. A reordering encoder would silently erase the win.
509        let mut protocol = sample_protocol();
510        protocol.module_preloads = vec![
511            "/chunk-big.js".to_string(),
512            "/chunk-mid.js".to_string(),
513            "/chunk-small.js".to_string(),
514        ];
515
516        let bytes = protocol.to_protobuf().expect("encode failed");
517        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
518
519        assert_eq!(decoded.module_preloads, protocol.module_preloads);
520        assert_eq!(protocol, decoded);
521    }
522
523    #[test]
524    fn test_protobuf_no_module_preloads_is_absent_from_the_wire() {
525        // Builds without hints must not pay a byte for the field.
526        let protocol = sample_protocol();
527        let bytes = protocol.to_protobuf().expect("encode failed");
528        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
529
530        assert!(decoded.module_preloads.is_empty());
531        assert!(
532            !bytes.windows(2).any(|w| w == [0x3a, 0x00]),
533            "an empty repeated field must not be encoded"
534        );
535    }
536
537    #[test]
538    fn test_protobuf_streaming_boundary_names_roundtrip_in_declaration_order() {
539        let mut protocol = sample_protocol();
540        protocol.streaming_boundaries.insert(
541            "main".to_string(),
542            StreamingBoundaryList {
543                names: vec![
544                    "weather shell".to_string(),
545                    "composer/ready".to_string(),
546                    "feed:batch".to_string(),
547                ],
548            },
549        );
550
551        let bytes = protocol.to_protobuf().expect("encode failed");
552        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
553
554        assert_eq!(
555            decoded.streaming_boundaries["main"].names,
556            protocol.streaming_boundaries["main"].names
557        );
558        assert_eq!(protocol, decoded);
559    }
560
561    #[test]
562    fn test_protobuf_all_fragment_types() {
563        let mut fragments = HashMap::new();
564        fragments.insert(
565            "main".to_string(),
566            FragmentList {
567                fragments: vec![
568                    WebUIFragment::raw("text"),
569                    WebUIFragment::component("comp"),
570                    WebUIFragment::for_loop("x", "xs", "loop"),
571                    WebUIFragment::signal("sig", true),
572                    WebUIFragment::if_cond(
573                        ConditionExpr::predicate("a", ComparisonOperator::GreaterThan, "1"),
574                        "cond",
575                    ),
576                ],
577            },
578        );
579        fragments.insert(
580            "comp".to_string(),
581            FragmentList {
582                fragments: vec![WebUIFragment::raw("c")],
583            },
584        );
585        fragments.insert(
586            "loop".to_string(),
587            FragmentList {
588                fragments: vec![WebUIFragment::raw("l")],
589            },
590        );
591        fragments.insert(
592            "cond".to_string(),
593            FragmentList {
594                fragments: vec![WebUIFragment::raw("i")],
595            },
596        );
597
598        let protocol = WebUIProtocol::new(fragments);
599        let bytes = protocol.to_protobuf().unwrap();
600        let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
601        assert_eq!(protocol, decoded);
602    }
603
604    #[test]
605    fn test_protobuf_all_comparison_operators() {
606        let ops = [
607            ComparisonOperator::GreaterThan,
608            ComparisonOperator::LessThan,
609            ComparisonOperator::Equal,
610            ComparisonOperator::NotEqual,
611            ComparisonOperator::GreaterThanOrEqual,
612            ComparisonOperator::LessThanOrEqual,
613        ];
614        for op in &ops {
615            let mut fragments = HashMap::new();
616            fragments.insert(
617                "main".to_string(),
618                FragmentList {
619                    fragments: vec![WebUIFragment::if_cond(
620                        ConditionExpr::predicate("a", *op, "b"),
621                        "then",
622                    )],
623                },
624            );
625            fragments.insert(
626                "then".to_string(),
627                FragmentList {
628                    fragments: vec![WebUIFragment::raw("ok")],
629                },
630            );
631            let p = WebUIProtocol::new(fragments);
632            let bytes = p.to_protobuf().unwrap();
633            let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
634            assert_eq!(p, decoded);
635        }
636    }
637
638    #[test]
639    fn test_protobuf_nested_conditions() {
640        let nested = ConditionExpr::compound(
641            ConditionExpr::predicate("user.role", ComparisonOperator::Equal, "admin"),
642            LogicalOperator::And,
643            ConditionExpr::negated(ConditionExpr::predicate(
644                "user.disabled",
645                ComparisonOperator::Equal,
646                "true",
647            )),
648        );
649
650        let mut fragments = HashMap::new();
651        fragments.insert(
652            "main".to_string(),
653            FragmentList {
654                fragments: vec![WebUIFragment::if_cond(nested, "then")],
655            },
656        );
657        fragments.insert(
658            "then".to_string(),
659            FragmentList {
660                fragments: vec![WebUIFragment::raw("ok")],
661            },
662        );
663        let p = WebUIProtocol::new(fragments);
664        let bytes = p.to_protobuf().unwrap();
665        let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
666        assert_eq!(p, decoded);
667    }
668
669    #[test]
670    fn test_protobuf_compound_or_condition() {
671        let compound = ConditionExpr::compound(
672            ConditionExpr::identifier("isAdmin"),
673            LogicalOperator::Or,
674            ConditionExpr::identifier("isEditor"),
675        );
676
677        let mut fragments = HashMap::new();
678        fragments.insert(
679            "main".to_string(),
680            FragmentList {
681                fragments: vec![WebUIFragment::if_cond(compound, "body")],
682            },
683        );
684        fragments.insert(
685            "body".to_string(),
686            FragmentList {
687                fragments: vec![WebUIFragment::raw("yes")],
688            },
689        );
690        let p = WebUIProtocol::new(fragments);
691        let bytes = p.to_protobuf().unwrap();
692        let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
693        assert_eq!(p, decoded);
694    }
695
696    #[test]
697    fn test_protobuf_invalid_bytes() {
698        let result = WebUIProtocol::from_protobuf(&[0xFF, 0xFF, 0xFF]);
699        assert!(result.is_err());
700    }
701
702    #[test]
703    fn test_protobuf_empty_bytes() {
704        let result = WebUIProtocol::from_protobuf(&[]);
705        assert!(result.is_ok());
706        assert!(result.unwrap().fragments.is_empty());
707    }
708
709    #[test]
710    fn test_protobuf_file_roundtrip() {
711        let protocol = sample_protocol();
712        let dir = std::env::temp_dir().join("webui-proto-test");
713        std::fs::create_dir_all(&dir).unwrap();
714        let path = dir.join("test.bin");
715
716        protocol.to_protobuf_file(&path).unwrap();
717        let decoded = WebUIProtocol::from_protobuf_file(&path).unwrap();
718        assert_eq!(protocol, decoded);
719
720        std::fs::remove_dir_all(&dir).ok();
721    }
722
723    #[test]
724    fn test_protobuf_validation_catches_missing_reference() {
725        let mut fragments = HashMap::new();
726        fragments.insert(
727            "main".to_string(),
728            FragmentList {
729                fragments: vec![WebUIFragment::component("does-not-exist")],
730            },
731        );
732
733        let protocol = WebUIProtocol::new(fragments);
734        let buf = protocol.to_protobuf().unwrap();
735
736        let result = WebUIProtocol::from_protobuf(&buf);
737        assert!(result.is_err());
738    }
739
740    #[test]
741    fn test_protobuf_validation_catches_missing_for_reference() {
742        let mut fragments = HashMap::new();
743        fragments.insert(
744            "main".to_string(),
745            FragmentList {
746                fragments: vec![WebUIFragment::for_loop("item", "items", "missing-for")],
747            },
748        );
749
750        let protocol = WebUIProtocol::new(fragments);
751        let buf = protocol.to_protobuf().unwrap();
752
753        let result = WebUIProtocol::from_protobuf(&buf);
754        assert!(result.is_err());
755        if let Err(ProtocolError::Validation(msg)) = result {
756            assert!(msg.contains("missing-for"));
757        }
758    }
759
760    #[test]
761    fn test_protobuf_validation_catches_missing_if_reference() {
762        let mut fragments = HashMap::new();
763        fragments.insert(
764            "main".to_string(),
765            FragmentList {
766                fragments: vec![WebUIFragment::if_cond(
767                    ConditionExpr::identifier("flag"),
768                    "missing-if",
769                )],
770            },
771        );
772
773        let protocol = WebUIProtocol::new(fragments);
774        let buf = protocol.to_protobuf().unwrap();
775
776        let result = WebUIProtocol::from_protobuf(&buf);
777        assert!(result.is_err());
778        if let Err(ProtocolError::Validation(msg)) = result {
779            assert!(msg.contains("missing-if"));
780        }
781    }
782
783    #[test]
784    fn test_protobuf_signal_default_raw_false() {
785        let mut fragments = HashMap::new();
786        fragments.insert(
787            "main".to_string(),
788            FragmentList {
789                fragments: vec![WebUIFragment::signal("name", false)],
790            },
791        );
792        let p = WebUIProtocol::new(fragments);
793        let bytes = p.to_protobuf().unwrap();
794        let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
795        let frag = &decoded.fragments["main"].fragments[0];
796        match frag.fragment.as_ref() {
797            Some(web_ui_fragment::Fragment::Signal(s)) => assert!(!s.raw),
798            _ => panic!("expected signal"),
799        }
800    }
801
802    #[test]
803    fn test_protobuf_pre_allocated_buffer() {
804        let protocol = sample_protocol();
805        let bytes = protocol.to_protobuf().unwrap();
806        assert_eq!(bytes.len(), protocol.encoded_len());
807    }
808
809    #[test]
810    fn test_protocol_new_has_empty_tokens() {
811        let protocol = WebUIProtocol::new(HashMap::new());
812        assert!(protocol.tokens.is_empty());
813        assert!(protocol.fragments.is_empty());
814        assert_eq!(
815            protocol.initial_state_strategy,
816            InitialStateStrategy::Full as i32
817        );
818    }
819
820    #[test]
821    fn test_projection_metadata_roundtrips() {
822        let mut protocol = WebUIProtocol::new(HashMap::new());
823        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
824        protocol.components.insert(
825            "my-card".to_string(),
826            ComponentData {
827                hydration_keys: vec!["name".to_string()],
828                hydration_mode: StateProjectionMode::Keys as i32,
829                navigation_mode: Some(StateProjectionMode::All as i32),
830                ..Default::default()
831            },
832        );
833
834        let bytes = protocol.to_protobuf().unwrap();
835        let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
836        assert_eq!(
837            decoded.initial_state_strategy,
838            InitialStateStrategy::Components as i32
839        );
840        let component = &decoded.components["my-card"];
841        assert_eq!(component.hydration_mode, StateProjectionMode::Keys as i32);
842        assert_eq!(component.hydration_keys, ["name"]);
843        assert_eq!(
844            component.navigation_mode,
845            Some(StateProjectionMode::All as i32)
846        );
847        assert!(component.navigation_keys.is_empty());
848    }
849
850    #[test]
851    fn test_protocol_with_tokens() {
852        let tokens = vec!["color-primary".to_string(), "spacing-m".to_string()];
853        let protocol = WebUIProtocol::with_tokens(HashMap::new(), tokens.clone());
854        assert_eq!(protocol.tokens, tokens);
855    }
856
857    #[test]
858    fn test_protobuf_route_fragment_roundtrip() {
859        let mut fragments = HashMap::new();
860        fragments.insert(
861            "main".to_string(),
862            FragmentList {
863                fragments: vec![WebUIFragment::route("/profile/:id", "profile-page")],
864            },
865        );
866        fragments.insert(
867            "profile-page".to_string(),
868            FragmentList {
869                fragments: vec![WebUIFragment::raw("<h1>Profile</h1>")],
870            },
871        );
872        let protocol = WebUIProtocol::new(fragments);
873        let bytes = protocol.to_protobuf().expect("encode failed");
874        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
875        assert_eq!(protocol, decoded);
876
877        let frag = &decoded.fragments["main"].fragments[0];
878        match frag.fragment.as_ref() {
879            Some(web_ui_fragment::Fragment::Route(r)) => {
880                assert_eq!(r.path, "/profile/:id");
881                assert_eq!(r.fragment_id, "profile-page");
882            }
883            _ => panic!("expected route fragment"),
884        }
885    }
886
887    #[test]
888    fn test_protobuf_route_fragment_all_fields() {
889        let mut fragments = HashMap::new();
890        let route_frag = WebUiFragment {
891            fragment: Some(web_ui_fragment::Fragment::Route(WebUiFragmentRoute {
892                path: "/users/:id/posts/:postId".to_string(),
893                fragment_id: "user-posts".to_string(),
894                exact: true,
895                children: Vec::new(),
896                allowed_query: "action,to,subject".to_string(),
897                keep_alive: false,
898                cache_tags: vec!["user:{id}".to_string(), "posts".to_string()],
899                invalidates: vec!["posts".to_string(), "counts".to_string()],
900                pending_component: "loading-skeleton".to_string(),
901                error_component: "error-page".to_string(),
902            })),
903        };
904        fragments.insert(
905            "main".to_string(),
906            FragmentList {
907                fragments: vec![route_frag],
908            },
909        );
910        fragments.insert(
911            "user-posts".into(),
912            FragmentList {
913                fragments: vec![WebUIFragment::raw("posts")],
914            },
915        );
916
917        let protocol = WebUIProtocol::new(fragments);
918        let bytes = protocol.to_protobuf().expect("encode failed");
919        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
920        assert_eq!(protocol, decoded);
921    }
922
923    #[test]
924    fn test_protobuf_route_validation_missing_fragment() {
925        let mut fragments = HashMap::new();
926        fragments.insert(
927            "main".to_string(),
928            FragmentList {
929                fragments: vec![WebUIFragment::route("/test", "missing-fragment")],
930            },
931        );
932        let protocol = WebUIProtocol::new(fragments);
933        let buf = protocol.to_protobuf().expect("encode failed");
934        let result = WebUIProtocol::from_protobuf(&buf);
935        assert!(result.is_err());
936        if let Err(ProtocolError::Validation(msg)) = result {
937            assert!(msg.contains("missing-fragment"));
938        }
939    }
940
941    #[test]
942    fn test_protobuf_route_no_fragment_id_roundtrip() {
943        let mut fragments = HashMap::new();
944        let route_frag = WebUiFragment {
945            fragment: Some(web_ui_fragment::Fragment::Route(WebUiFragmentRoute {
946                path: "/old-path".to_string(),
947                keep_alive: false,
948                ..Default::default()
949            })),
950        };
951        fragments.insert(
952            "main".to_string(),
953            FragmentList {
954                fragments: vec![route_frag],
955            },
956        );
957        let protocol = WebUIProtocol::new(fragments);
958        let bytes = protocol.to_protobuf().expect("encode failed");
959        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
960        assert_eq!(protocol, decoded);
961    }
962
963    #[test]
964    fn test_protobuf_backward_compat_no_routes() {
965        // Protocol without any fragments should decode successfully
966        let protocol = WebUIProtocol::new(HashMap::new());
967        let bytes = protocol.to_protobuf().expect("encode failed");
968        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
969        assert!(decoded.fragments.is_empty());
970    }
971
972    #[test]
973    fn test_protobuf_roundtrip_with_tokens() {
974        let mut fragments = HashMap::new();
975        fragments.insert(
976            "index.html".to_string(),
977            FragmentList {
978                fragments: vec![WebUIFragment::raw("Hello")],
979            },
980        );
981        let tokens = vec!["border-radius-m".to_string(), "color-primary".to_string()];
982        let protocol = WebUIProtocol::with_tokens(fragments, tokens.clone());
983
984        let bytes = protocol.to_protobuf().expect("encode failed");
985        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
986
987        assert_eq!(decoded.tokens, tokens);
988        assert!(decoded.fragments.contains_key("index.html"));
989    }
990}