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 component_render_css: String::new(),
337 }
338 }
339
340 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
356impl WebUiProtocol {
359 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 pub fn to_json_pretty(&self) -> std::result::Result<String, serde_json::Error> {
424 serde_json::to_string_pretty(self)
425 }
426
427 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 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 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 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 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 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 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}