1use prost::Message;
12use std::collections::HashMap;
13use std::fmt;
14use std::io;
15use thiserror::Error;
16
17pub mod plugin;
19
20#[cfg(feature = "projection-manifest")]
22pub mod projection_manifest;
23
24pub mod attrs;
26
27pub mod proto {
29 include!("gen_webui.rs");
30}
31
32pub use plugin::FastElementData;
34pub use plugin::WebUIElementData;
35pub use proto::*;
36
37pub 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
53pub 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
67impl 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 ¬.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
126impl WebUiFragment {
129 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 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 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 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 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 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 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 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 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 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 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 pub fn route_from(route: WebUiFragmentRoute) -> Self {
261 Self {
262 fragment: Some(web_ui_fragment::Fragment::Route(route)),
263 }
264 }
265
266 pub fn outlet() -> Self {
268 Self {
269 fragment: Some(web_ui_fragment::Fragment::Outlet(WebUiFragmentOutlet {})),
270 }
271 }
272}
273
274impl ConditionExpr {
275 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 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 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 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
322impl WebUiProtocol {
325 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 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
354impl WebUiProtocol {
357 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 pub fn to_json_pretty(&self) -> std::result::Result<String, serde_json::Error> {
422 serde_json::to_string_pretty(self)
423 }
424
425 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 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 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 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 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 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 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}