1mod component_assets;
24mod error;
25mod module_preload;
26mod projection;
27pub mod server;
28pub mod streaming;
29
30pub use component_assets::{render_component_assets, ComponentAssetFile, ComponentAssetGraph};
31pub use error::WebUIError;
32
33pub use webui_handler::route_handler::{encode_inventory, get_needed_components, parse_inventory};
35pub use webui_handler::Result as HandlerResult;
36pub use webui_handler::{
37 plugin::HandlerPlugin, BoundaryId, BoundaryMode, FlushWriter, HandlerError, Protocol,
38 RenderOptions, ResponseWriter, StreamingResponse, WebUIHandler,
39};
40pub use webui_parser::plugin::{ComponentTemplateArtifact, StateSurface};
41pub use webui_parser::CssStrategy;
42pub use webui_parser::DomStrategy;
43pub use webui_parser::LegalComments;
44pub use webui_parser::ParserError;
45pub use webui_parser::ParserOptions;
46pub use webui_parser::Plugin;
47pub use webui_parser::DEFAULT_CSS_FILE_NAME_TEMPLATE;
48pub use webui_parser::{
49 AssetFileNameTemplate, AssetFileNameTemplateError, DEFAULT_ASSET_FILE_NAME_TEMPLATE,
50};
51pub use webui_parser::{CssFallbackChain, CssTokenAnalysis};
52pub use webui_parser::{Diagnostic, Severity};
53pub use webui_protocol::WebUIProtocol;
54pub use webui_tokens::{load_token_file, resolve_theme_path, TokenFile};
55
56use std::collections::HashSet;
57use std::ffi::{OsStr, OsString};
58use std::fs;
59use std::path::Path;
60use std::time::Instant;
61use webui_parser::plugin::fast_v2::FastV2ParserPlugin;
62use webui_parser::plugin::fast_v3::FastV3ParserPlugin;
63use webui_parser::plugin::webui::WebUIParserPlugin;
64use webui_parser::plugin::ParserPluginArtifacts;
65use webui_parser::HtmlParser;
66
67#[derive(Debug, Clone)]
69pub struct PreparedProjectionManifests {
70 snapshot: std::sync::Arc<projection::ProjectionSnapshot>,
71}
72
73#[derive(Debug)]
74enum ProjectionBarrierState {
75 Waiting,
76 Ready(PreparedProjectionManifests),
77 Failed(String),
78}
79
80#[doc(hidden)]
82#[derive(Debug, Clone)]
83pub struct PendingProjectionManifests {
84 state: std::sync::Arc<(std::sync::Mutex<ProjectionBarrierState>, std::sync::Condvar)>,
85}
86
87#[doc(hidden)]
89pub struct ProjectionManifestCompleter {
90 state: std::sync::Arc<(std::sync::Mutex<ProjectionBarrierState>, std::sync::Condvar)>,
91 completed: bool,
92}
93
94#[derive(Debug, Clone)]
96pub enum ProjectionManifestSource {
97 Path(std::path::PathBuf),
99 Inline {
104 manifest_path: std::path::PathBuf,
106 json: String,
108 },
109 Prepared(PreparedProjectionManifests),
112 #[doc(hidden)]
114 Pending(PendingProjectionManifests),
115}
116
117impl From<std::path::PathBuf> for ProjectionManifestSource {
118 fn from(path: std::path::PathBuf) -> Self {
119 Self::Path(path)
120 }
121}
122
123pub fn prepare_projection_manifests(
128 sources: &[ProjectionManifestSource],
129) -> Result<PreparedProjectionManifests, WebUIError> {
130 let snapshot = projection::load_and_merge(sources)?
131 .unwrap_or_else(|| std::sync::Arc::new(projection::ProjectionSnapshot::default()));
132 Ok(PreparedProjectionManifests { snapshot })
133}
134
135#[doc(hidden)]
142#[must_use]
143pub fn resolve_generated_module_preloads(
144 projection: &PreparedProjectionManifests,
145 existing_hrefs: &[String],
146 entry_outputs: &[&Path],
147 output_urls: &std::collections::BTreeMap<std::path::PathBuf, String>,
148) -> (Vec<String>, Vec<Diagnostic>) {
149 let mut resolved = module_preload::resolve_exact(
150 &projection.snapshot.entry_closures,
151 existing_hrefs,
152 entry_outputs,
153 output_urls,
154 );
155 (
156 std::mem::take(&mut resolved.hrefs),
157 std::mem::take(&mut resolved.warnings),
158 )
159}
160
161#[doc(hidden)]
163#[must_use]
164pub fn generated_module_preload_outputs(
165 projection: &PreparedProjectionManifests,
166 entry_outputs: &[&Path],
167) -> Vec<std::path::PathBuf> {
168 module_preload::exact_output_identities(&projection.snapshot.entry_closures, entry_outputs)
169}
170
171#[doc(hidden)]
173#[must_use]
174pub fn projection_manifest_barrier() -> (ProjectionManifestSource, ProjectionManifestCompleter) {
175 let state = std::sync::Arc::new((
176 std::sync::Mutex::new(ProjectionBarrierState::Waiting),
177 std::sync::Condvar::new(),
178 ));
179 (
180 ProjectionManifestSource::Pending(PendingProjectionManifests {
181 state: std::sync::Arc::clone(&state),
182 }),
183 ProjectionManifestCompleter {
184 state,
185 completed: false,
186 },
187 )
188}
189
190impl PendingProjectionManifests {
191 fn wait(&self) -> Result<PreparedProjectionManifests, WebUIError> {
192 let (lock, ready) = self.state.as_ref();
193 let mut state = lock.lock().map_err(|_| {
194 WebUIError::InvalidBuildOptions("projection synchronization lock poisoned".to_string())
195 })?;
196 loop {
197 match &*state {
198 ProjectionBarrierState::Waiting => {
199 state = ready.wait(state).map_err(|_| {
200 WebUIError::InvalidBuildOptions(
201 "projection synchronization lock poisoned".to_string(),
202 )
203 })?;
204 }
205 ProjectionBarrierState::Ready(projection) => {
206 return Ok(projection.clone());
207 }
208 ProjectionBarrierState::Failed(message) => {
209 return Err(WebUIError::InvalidBuildOptions(message.clone()));
210 }
211 }
212 }
213 }
214}
215
216impl ProjectionManifestCompleter {
217 pub fn complete(mut self, result: std::result::Result<PreparedProjectionManifests, String>) {
219 let (lock, ready) = self.state.as_ref();
220 if let Ok(mut state) = lock.lock() {
221 *state = match result {
222 Ok(projection) => ProjectionBarrierState::Ready(projection),
223 Err(message) => ProjectionBarrierState::Failed(message),
224 };
225 self.completed = true;
226 ready.notify_all();
227 }
228 }
229}
230
231impl Drop for ProjectionManifestCompleter {
232 fn drop(&mut self) {
233 if self.completed {
234 return;
235 }
236 let (lock, ready) = self.state.as_ref();
237 if let Ok(mut state) = lock.lock() {
238 *state = ProjectionBarrierState::Failed(
239 "projection producer terminated before completing metadata".to_string(),
240 );
241 ready.notify_all();
242 }
243 }
244}
245
246#[derive(Debug, Clone)]
248pub struct BuildOptions {
249 pub app_dir: std::path::PathBuf,
251 pub entry: String,
253 pub css: CssStrategy,
255 pub dom: DomStrategy,
257 pub plugin: Option<Plugin>,
259 pub components: Vec<String>,
261 pub component_asset_roots: Vec<String>,
267 pub metafile: bool,
269 pub css_file_name_template: String,
273 pub css_public_base: Option<String>,
276 pub legal_comments: LegalComments,
278 pub theme: Option<TokenFile>,
286 pub projection_manifests: Vec<ProjectionManifestSource>,
307}
308
309impl Default for BuildOptions {
310 fn default() -> Self {
311 Self {
312 app_dir: std::path::PathBuf::from("."),
313 entry: "index.html".to_string(),
314 css: CssStrategy::Link,
315 dom: DomStrategy::Shadow,
316 plugin: None,
317 components: Vec::new(),
318 component_asset_roots: Vec::new(),
319 metafile: false,
320 css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
321 css_public_base: None,
322 legal_comments: LegalComments::default(),
323 theme: None,
324 projection_manifests: Vec::new(),
325 }
326 }
327}
328
329#[derive(Debug, Clone)]
331pub struct BuildStats {
332 pub duration: std::time::Duration,
334 pub fragment_count: usize,
336 pub component_count: usize,
338 pub css_file_count: usize,
340 pub protocol_size_bytes: usize,
342 pub token_count: usize,
344}
345
346#[derive(Debug)]
348pub struct BuildResult {
349 pub protocol: WebUIProtocol,
351 pub protocol_bytes: Vec<u8>,
353 pub css_files: Vec<(String, String)>,
355 pub component_asset_files: Vec<ComponentAssetFile>,
359 pub metafile: Option<String>,
361 pub component_templates: Vec<ComponentTemplateArtifact>,
365 pub warnings: Vec<Diagnostic>,
372 pub stats: BuildStats,
374}
375
376#[cold]
381#[inline(never)]
382fn streaming_without_projection_warning(entry: &str, boundary_count: usize) -> Diagnostic {
383 let plural = if boundary_count == 1 { "" } else { "s" };
384 Diagnostic::warning(format!(
385 "{entry} declares {boundary_count} streaming boundary checkpoint{plural} \
386 but this build has no state-projection manifest"
387 ))
388 .code(webui_parser::codes::STREAMING_WITHOUT_PROJECTION)
389 .help(
390 "every checkpoint will serialize the whole state object instead of its own \
391 components' keys; pass the client build's `webui-projection.json` through \
392 `BuildOptions::projection_manifests` to send boundary-local state only",
393 )
394}
395
396#[must_use = "BuildResult contains the compiled protocol and statistics"]
406pub fn build(options: BuildOptions) -> Result<BuildResult, WebUIError> {
407 let started = Instant::now();
408
409 let raw = build_protocol_inner(&options)?;
410
411 let protocol_bytes = raw.protocol.to_protobuf()?;
412
413 let stats = BuildStats {
414 duration: started.elapsed(),
415 fragment_count: raw.fragment_count,
416 component_count: raw.component_count,
417 css_file_count: raw.css_files.len(),
418 protocol_size_bytes: protocol_bytes.len(),
419 token_count: raw.token_count,
420 };
421
422 Ok(BuildResult {
423 protocol: raw.protocol,
424 protocol_bytes,
425 css_files: raw.css_files,
426 component_asset_files: raw.component_asset_files,
427 metafile: raw.metafile,
428 component_templates: raw.component_templates,
429 warnings: raw.warnings,
430 stats,
431 })
432}
433
434pub fn build_to_disk(options: BuildOptions, out_dir: &Path) -> Result<BuildStats, WebUIError> {
444 let result = build(options)?;
445 validate_output_file_names(OsStr::new("protocol.bin"), &result)?;
446
447 fs::create_dir_all(out_dir).map_err(|source| WebUIError::Io {
448 context: format!("Failed to create {}", out_dir.display()),
449 source,
450 })?;
451
452 fs::write(out_dir.join("protocol.bin"), &result.protocol_bytes).map_err(|source| {
453 WebUIError::Io {
454 context: format!("Failed to write protocol.bin to {}", out_dir.display()),
455 source,
456 }
457 })?;
458
459 for (name, content) in &result.css_files {
460 fs::write(out_dir.join(name), content).map_err(|source| WebUIError::Io {
461 context: format!("Failed to write {name} to {}", out_dir.display()),
462 source,
463 })?;
464 }
465 for file in &result.component_asset_files {
466 fs::write(out_dir.join(&file.name), &file.content).map_err(|source| WebUIError::Io {
467 context: format!(
468 "Failed to write component asset {} to {}",
469 file.name,
470 out_dir.display()
471 ),
472 source,
473 })?;
474 }
475
476 Ok(result.stats)
477}
478
479pub fn inspect(protocol_path: &Path) -> Result<String, WebUIError> {
481 let bytes = fs::read(protocol_path).map_err(|source| WebUIError::Io {
482 context: format!("Failed to read {}", protocol_path.display()),
483 source,
484 })?;
485 inspect_bytes(&bytes)
486}
487
488pub fn inspect_bytes(protocol_bytes: &[u8]) -> Result<String, WebUIError> {
490 let protocol = WebUIProtocol::from_protobuf(protocol_bytes)?;
491 protocol
492 .to_json_pretty()
493 .map_err(|e| WebUIError::Serialization(e.to_string()))
494}
495
496struct RawBuildOutput {
498 protocol: WebUIProtocol,
499 css_files: Vec<(String, String)>,
500 component_asset_files: Vec<ComponentAssetFile>,
501 metafile: Option<String>,
502 component_templates: Vec<ComponentTemplateArtifact>,
503 warnings: Vec<Diagnostic>,
504 fragment_count: usize,
505 component_count: usize,
506 token_count: usize,
507}
508
509fn build_protocol_inner(options: &BuildOptions) -> Result<RawBuildOutput, WebUIError> {
511 let projection_enabled = !options.projection_manifests.is_empty();
512 if projection_enabled && options.plugin != Some(Plugin::WebUI) {
513 return Err(projection::incompatible_plugin_error());
514 }
515 let parser_options = ParserOptions::try_new(
516 options.css,
517 options.dom,
518 &options.css_file_name_template,
519 options.css_public_base.as_deref(),
520 options.legal_comments,
521 )
522 .map_err(|e| WebUIError::InvalidBuildOptions(e.to_string()))?;
523 let css_link_options = parser_options.css_link_options.clone();
524
525 let mut parser = match options.plugin {
526 Some(Plugin::Fast | Plugin::FastV2) => {
527 HtmlParser::with_plugin_options(Box::new(FastV2ParserPlugin::new()), parser_options)
528 }
529 Some(Plugin::FastV3) => {
530 HtmlParser::with_plugin_options(Box::new(FastV3ParserPlugin::new()), parser_options)
531 }
532 Some(Plugin::WebUI) => {
533 HtmlParser::with_plugin_options(Box::new(WebUIParserPlugin::new()), parser_options)
534 }
535 None => HtmlParser::with_options(parser_options),
536 };
537
538 parser
540 .component_registry_mut()
541 .register_from_paths(&[&options.app_dir])
542 .map_err(|e| {
543 WebUIError::ComponentRegistration(format!(
544 "Failed to register components from {}: {e}",
545 options.app_dir.display()
546 ))
547 })?;
548
549 for source in &options.components {
551 let result = webui_discovery::discover_source(source, &options.app_dir).map_err(|e| {
552 WebUIError::ComponentDiscovery(format!(
553 "Failed to discover components from {source}: {e}"
554 ))
555 })?;
556 for comp in &result.components {
557 parser
560 .component_registry_mut()
561 .register_component(webui_parser::ComponentRegistration {
562 tag_name: &comp.tag_name,
563 html_content: &comp.html_content,
564 css_content: comp.css_content.as_deref(),
565 is_client_owned: comp.is_client_owned,
566 })
567 .map_err(|e| {
568 WebUIError::ComponentRegistration(format!(
569 "Failed to register component '{}' from {}: {e}",
570 comp.tag_name, comp.source
571 ))
572 })?;
573 }
574 }
575
576 let component_count = parser.component_registry().len();
577
578 let entry_path = options.app_dir.join(&options.entry);
580 let html_content = fs::read_to_string(&entry_path).map_err(|source| WebUIError::Io {
581 context: format!("Failed to read {}", entry_path.display()),
582 source,
583 })?;
584 parser
585 .parse(&options.entry, &html_content)
586 .map_err(|source| WebUIError::Parse {
587 context: format!("Failed to parse {}", options.entry),
588 source,
589 })?;
590
591 let boundary_count = parser.boundary_count();
597 let boundary_names = parser.boundary_names().to_vec();
598 let module_entry_srcs = parser.module_entry_srcs().to_vec();
599
600 let synthetic_asset_fragments =
601 parse_component_asset_roots(&mut parser, &options.component_asset_roots)?;
602
603 let component_render_css = parser.component_registry().render_policy_css(
604 parser
605 .component_registry()
606 .get_all()
607 .filter(|component| parser.has_fragment(&component.tag_name))
608 .map(|component| component.tag_name.as_str()),
609 );
610 let css_snapshot: Vec<(String, String)> = parser
611 .component_registry()
612 .get_all()
613 .filter(|component| parser.has_fragment(&component.tag_name))
614 .filter_map(|component| {
615 component
616 .css_content
617 .as_ref()
618 .map(|css| (component.tag_name.clone(), css.clone()))
619 })
620 .collect();
621
622 let token_analysis = parser.token_analysis();
624 let mut warnings: Vec<Diagnostic> = Vec::new();
625 if let Some(theme) = options.theme.as_ref() {
626 token_analysis
627 .validate_theme_tokens(theme)
628 .map_err(|source| WebUIError::Parse {
629 context: "Failed to validate theme tokens".to_string(),
630 source,
631 })?;
632 warnings = token_analysis.theme_token_warnings(theme);
637 }
638 let token_count = token_analysis.protocol_tokens.len();
639
640 let component_templates =
641 match parser
642 .take_plugin_artifacts()
643 .map_err(|source| WebUIError::Parse {
644 context: format!(
645 "Failed to compile component templates for {}",
646 options.entry
647 ),
648 source,
649 })? {
650 ParserPluginArtifacts::None => Vec::new(),
651 ParserPluginArtifacts::ComponentTemplates(templates) => templates,
652 };
653 let mut fragment_records = parser.into_fragment_records();
655 for fragment_id in synthetic_asset_fragments {
656 fragment_records.remove(&fragment_id);
657 }
658 let merged_manifest = projection::load_and_merge(&options.projection_manifests)?;
662 let mut protocol = WebUIProtocol::with_tokens(fragment_records, token_analysis.protocol_tokens);
663 if !boundary_names.is_empty() {
664 protocol.streaming_boundaries.insert(
665 options.entry.clone(),
666 webui_protocol::StreamingBoundaryList {
667 names: boundary_names,
668 },
669 );
670 }
671 protocol.initial_state_strategy = if merged_manifest.is_some() {
672 webui_protocol::InitialStateStrategy::Components as i32
673 } else {
674 webui_protocol::InitialStateStrategy::Full as i32
675 };
676
677 if boundary_count > 0 && merged_manifest.is_none() {
682 warnings.push(streaming_without_projection_warning(
683 &options.entry,
684 boundary_count,
685 ));
686 }
687
688 if let Some(merged) = &merged_manifest {
693 let mut preloads = module_preload::resolve(&merged.entry_closures, &module_entry_srcs);
694 protocol.module_preloads = std::mem::take(&mut preloads.hrefs);
695 warnings.append(&mut preloads.warnings);
696 }
697
698 protocol.set_css_strategy(match options.css {
700 CssStrategy::Link => webui_protocol::CssStrategy::Link,
701 CssStrategy::Style => webui_protocol::CssStrategy::Style,
702 CssStrategy::Module => webui_protocol::CssStrategy::Module,
703 });
704 protocol.set_dom_strategy(match options.dom {
705 DomStrategy::Shadow => webui_protocol::DomStrategy::Shadow,
706 DomStrategy::Light => webui_protocol::DomStrategy::Light,
707 });
708 protocol.component_render_css = component_render_css;
709
710 let is_module = options.css == CssStrategy::Module;
713 let is_link = options.css == CssStrategy::Link;
714 let mut css_files: Vec<(String, String)> = Vec::new();
715 let mut emitted_names: HashSet<String> = HashSet::new();
716 for (tag, css) in css_snapshot {
717 if !protocol.fragments.contains_key(&tag) {
718 continue;
719 }
720 if is_module {
721 protocol.components.entry(tag).or_default().css = css.trim().to_string();
722 } else if is_link {
723 let resolved = css_link_options.resolve(&tag, &css);
724 if !emitted_names.insert(resolved.filename.clone()) {
725 return Err(WebUIError::InvalidBuildOptions(format!(
726 "CSS filename collision for Link strategy: '{}'. Adjust the asset filename template to include [name] or another unique component-specific segment.",
727 resolved.filename
728 )));
729 }
730 protocol.components.entry(tag).or_default().css_href = resolved.href;
731 css_files.push((resolved.filename, css));
732 }
733 }
736
737 for artifact in &component_templates {
741 let component = protocol
742 .components
743 .entry(artifact.tag_name.clone())
744 .or_default();
745 component.template = artifact.template.clone();
746 component.template_json = artifact.template_json.clone();
747 component.template_functions = artifact.template_functions.clone();
748 let manifest_entry = if artifact.is_scripted {
753 merged_manifest
754 .as_ref()
755 .and_then(|merged| merged.components.get(&artifact.tag_name))
756 } else {
757 None
758 };
759 let (hydration_mode, hydration_keys) = match manifest_entry {
760 Some(entry) => encode_state_surface(&StateSurface::Keys(entry.hydration_keys.clone())),
761 None => encode_state_surface(&artifact.hydration),
762 };
763 component.hydration_mode = hydration_mode;
764 component.hydration_keys = hydration_keys;
765 let (navigation_mode, navigation_keys) = match manifest_entry {
766 Some(entry) => {
767 let union =
768 projection::union_keys(&entry.navigation_keys, &artifact.template_roots);
769 encode_state_surface(&StateSurface::Keys(union))
770 }
771 None => encode_state_surface(&artifact.navigation),
772 };
773 component.navigation_mode = Some(navigation_mode);
774 component.navigation_keys = navigation_keys;
775 }
776
777 let component_asset_graph = component_assets::render_component_assets(
778 &protocol,
779 &options.entry,
780 &options.component_asset_roots,
781 &options.css_file_name_template,
782 options.metafile,
783 )?;
784 component_asset_graph.retain_entry_protocol(&mut protocol);
785 if let Some(merged) = &merged_manifest {
789 let entry_scripted_tags: Vec<&str> = component_templates
790 .iter()
791 .filter(|artifact| {
792 artifact.is_scripted && protocol.fragments.contains_key(&artifact.tag_name)
793 })
794 .map(|artifact| artifact.tag_name.as_str())
795 .collect();
796 projection::validate_coverage(&merged.components, &entry_scripted_tags)?;
797 }
798 let fragment_count = protocol
799 .fragments
800 .values()
801 .map(|fragments| fragments.fragments.len())
802 .sum();
803 let component_asset_files = component_asset_graph.files;
804 validate_generated_file_names(&css_files, &component_asset_files)?;
805
806 Ok(RawBuildOutput {
807 protocol,
808 css_files,
809 component_asset_files,
810 metafile: component_asset_graph.metafile,
811 component_templates,
812 warnings,
813 fragment_count,
814 component_count,
815 token_count,
816 })
817}
818
819fn encode_state_surface(surface: &StateSurface) -> (i32, Vec<String>) {
820 match surface {
821 StateSurface::None => (webui_protocol::StateProjectionMode::None as i32, Vec::new()),
822 StateSurface::Keys(keys) => (
823 webui_protocol::StateProjectionMode::Keys as i32,
824 keys.clone(),
825 ),
826 StateSurface::All => (webui_protocol::StateProjectionMode::All as i32, Vec::new()),
827 }
828}
829
830fn parse_component_asset_roots(
831 parser: &mut HtmlParser,
832 roots: &[String],
833) -> Result<Vec<String>, WebUIError> {
834 let mut synthetic_fragments = Vec::with_capacity(roots.len());
835 for (index, root) in roots.iter().enumerate() {
836 let root = root.trim();
837 if parser.has_fragment(root) {
838 continue;
839 }
840 let mut synthetic = String::with_capacity(root.len() * 2 + 5);
843 synthetic.push('<');
844 synthetic.push_str(root);
845 synthetic.push_str("></");
846 synthetic.push_str(root);
847 synthetic.push('>');
848
849 let fragment_id = format!("__webui_asset_root_{index}");
850 parser
851 .parse(&fragment_id, &synthetic)
852 .map_err(|source| WebUIError::Parse {
853 context: format!("Failed to parse component asset root <{root}>"),
854 source,
855 })?;
856 synthetic_fragments.push(fragment_id);
857 }
858 Ok(synthetic_fragments)
859}
860
861fn validate_generated_file_names(
862 css_files: &[(String, String)],
863 component_asset_files: &[ComponentAssetFile],
864) -> Result<(), WebUIError> {
865 let mut names = HashSet::with_capacity(css_files.len() + component_asset_files.len());
866 for (name, _) in css_files {
867 names.insert(name.as_str());
868 }
869 for file in component_asset_files {
870 if !names.insert(file.name.as_str()) {
871 return Err(WebUIError::InvalidBuildOptions(format!(
872 "output filename collision for '{}'. Adjust the asset filename template to include [ext] or another unique asset-type segment.",
873 file.name
874 )));
875 }
876 }
877 Ok(())
878}
879
880fn validate_output_file_names(
881 protocol_name: &OsStr,
882 result: &BuildResult,
883) -> Result<(), WebUIError> {
884 let mut names =
885 HashSet::with_capacity(1 + result.css_files.len() + result.component_asset_files.len());
886 names.insert(protocol_name.to_os_string());
887 for (name, _) in &result.css_files {
888 let file_name = OsString::from(name);
889 if !names.insert(file_name.clone()) {
890 return Err(output_file_collision_error(&file_name));
891 }
892 }
893 for file in &result.component_asset_files {
894 let file_name = OsString::from(&file.name);
895 if !names.insert(file_name.clone()) {
896 return Err(output_file_collision_error(&file_name));
897 }
898 }
899 Ok(())
900}
901
902fn output_file_collision_error(name: &OsStr) -> WebUIError {
903 WebUIError::InvalidBuildOptions(format!(
904 "output filename collision for '{}'. Adjust the asset filename template to include [ext] or another unique asset-type segment.",
905 name.to_string_lossy()
906 ))
907}
908
909#[cfg(test)]
910#[allow(clippy::disallowed_methods)]
911mod tests {
912 use super::*;
913 use std::collections::HashMap;
914 use std::path::PathBuf;
915 use tempfile::TempDir;
916 use webui_handler::plugin::webui::WebUIHydrationPlugin;
917 use webui_protocol::web_ui_fragment::Fragment;
918
919 struct StringWriter {
920 buf: String,
921 }
922
923 impl ResponseWriter for StringWriter {
924 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
925 self.buf.push_str(content);
926 Ok(())
927 }
928
929 fn end(&mut self) -> webui_handler::Result<()> {
930 Ok(())
931 }
932 }
933
934 fn create_app_dir(files: &[(&str, &str)]) -> TempDir {
935 let dir = TempDir::new().unwrap();
936 for (name, content) in files {
937 let path = dir.path().join(name);
938 if let Some(parent) = path.parent() {
939 fs::create_dir_all(parent).unwrap();
940 }
941 fs::write(&path, content).unwrap();
942 }
943 dir
944 }
945
946 fn default_options(app_dir: &Path) -> BuildOptions {
947 BuildOptions {
948 app_dir: app_dir.to_path_buf(),
949 ..BuildOptions::default()
950 }
951 }
952
953 #[test]
954 fn test_build_simple_html() {
955 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
956 let result = build(default_options(app.path())).unwrap();
957
958 assert!(result.protocol.fragments.contains_key("index.html"));
959 assert!(result.stats.fragment_count > 0);
960 assert!(result.stats.protocol_size_bytes > 0);
961 assert!(!result.stats.duration.is_zero());
962 assert_eq!(
963 result.protocol.initial_state_strategy,
964 webui_protocol::InitialStateStrategy::Full as i32
965 );
966 }
967
968 #[test]
969 fn pending_projection_unblocks_after_template_compilation() {
970 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
971 let (source, completer) = projection_manifest_barrier();
972 let options = BuildOptions {
973 app_dir: app.path().to_path_buf(),
974 plugin: Some(Plugin::WebUI),
975 projection_manifests: vec![source],
976 ..BuildOptions::default()
977 };
978 let handle = std::thread::spawn(move || build(options));
979 let prepared = prepare_projection_manifests(&[]).unwrap();
980 completer.complete(Ok(prepared));
981 let result = handle.join().unwrap().unwrap();
982 assert_eq!(
983 result.protocol.initial_state_strategy,
984 webui_protocol::InitialStateStrategy::Components as i32
985 );
986 }
987
988 #[test]
991 fn streaming_entry_without_projection_manifest_warns() {
992 let app = create_app_dir(&[(
993 "index.html",
994 concat!(
995 "<html><body>",
996 r#"<boundary name="a"><p>{{one}}</p></boundary>"#,
997 r#"<boundary name="b"><p>{{two}}</p></boundary>"#,
998 "</body></html>",
999 ),
1000 )]);
1001 let mut options = default_options(app.path());
1002 options.plugin = Some(Plugin::WebUI);
1003
1004 let result = build(options).unwrap();
1005
1006 assert_eq!(
1007 result.protocol.initial_state_strategy,
1008 webui_protocol::InitialStateStrategy::Full as i32
1009 );
1010 assert_eq!(
1011 result.protocol.streaming_boundaries["index.html"].names,
1012 ["a", "b"]
1013 );
1014 let decoded = WebUIProtocol::from_protobuf(&result.protocol_bytes).unwrap();
1015 assert_eq!(decoded.streaming_boundaries["index.html"].names, ["a", "b"]);
1016 let warning = result
1017 .warnings
1018 .iter()
1019 .find(|diag| {
1020 diag.error_code() == Some(webui_parser::codes::STREAMING_WITHOUT_PROJECTION)
1021 })
1022 .expect("a streaming build without projection must warn");
1023 assert_eq!(warning.severity(), webui_parser::Severity::Warning);
1024 assert!(
1025 warning.to_string().contains("2 streaming boundary"),
1026 "warning should report the boundary count: {warning}"
1027 );
1028 assert!(
1029 warning
1030 .help_text()
1031 .is_some_and(|help| help.contains("projection_manifests")),
1032 "warning should point at the fix: {warning}"
1033 );
1034 }
1035
1036 #[test]
1037 fn streaming_entry_with_projection_manifest_does_not_warn() {
1038 let app = create_app_dir(&[(
1039 "index.html",
1040 concat!(
1041 "<html><body>",
1042 r#"<boundary name="a"><p>{{one}}</p></boundary>"#,
1043 "</body></html>",
1044 ),
1045 )]);
1046 let manifest_json =
1047 projection::test_support::build_valid_manifest_json(app.path(), &[], &[], &[]);
1048 let manifest = projection::test_support::write_manifest(
1049 app.path(),
1050 "webui-projection.json",
1051 &manifest_json,
1052 );
1053 let mut options = default_options(app.path());
1054 options.plugin = Some(Plugin::WebUI);
1055 options.projection_manifests = vec![manifest.into()];
1056
1057 let result = build(options).unwrap();
1058
1059 assert_eq!(
1060 result.protocol.initial_state_strategy,
1061 webui_protocol::InitialStateStrategy::Components as i32
1062 );
1063 assert!(
1064 !result
1065 .warnings
1066 .iter()
1067 .any(|diag| diag.error_code()
1068 == Some(webui_parser::codes::STREAMING_WITHOUT_PROJECTION)),
1069 "a projected streaming build must not warn"
1070 );
1071 }
1072
1073 #[test]
1076 fn non_streaming_entry_without_projection_manifest_does_not_warn() {
1077 let app = create_app_dir(&[("index.html", "<html><body><p>{{one}}</p></body></html>")]);
1078 let mut options = default_options(app.path());
1079 options.plugin = Some(Plugin::WebUI);
1080
1081 let result = build(options).unwrap();
1082
1083 assert!(
1084 !result
1085 .warnings
1086 .iter()
1087 .any(|diag| diag.error_code()
1088 == Some(webui_parser::codes::STREAMING_WITHOUT_PROJECTION)),
1089 "a non-streaming build must not warn about projection"
1090 );
1091 }
1092
1093 #[test]
1094 fn test_build_keeps_scriptless_component_dormant_until_client_use() {
1095 let app = create_app_dir(&[
1096 (
1097 "index.html",
1098 "<html><body><demo-card></demo-card></body></html>",
1099 ),
1100 ("demo-card.html", "<p>{{name}} {{serverTitle}}</p>"),
1101 ]);
1102 let manifest_json =
1107 projection::test_support::build_valid_manifest_json(app.path(), &[], &[], &[]);
1108 let manifest = projection::test_support::write_manifest(
1109 app.path(),
1110 "webui-projection.json",
1111 &manifest_json,
1112 );
1113 let mut options = default_options(app.path());
1114 options.plugin = Some(Plugin::WebUI);
1115 options.projection_manifests = vec![manifest.into()];
1116 let result = build(options).unwrap();
1117 assert_eq!(
1118 result.protocol.initial_state_strategy,
1119 webui_protocol::InitialStateStrategy::Components as i32
1120 );
1121
1122 assert!(result
1123 .component_templates
1124 .iter()
1125 .any(|template| template.tag_name == "demo-card"));
1126 let component = result
1127 .protocol
1128 .components
1129 .get("demo-card")
1130 .expect("scriptless component metadata should be retained");
1131 assert!(component.hydration_keys.is_empty());
1132 assert_eq!(component.navigation_keys, ["name", "serverTitle"]);
1133 assert!(component.template_json.contains(r#""th":1"#));
1134
1135 let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
1136 let state = serde_json::json!({ "name": "Server rendered" });
1137 let protocol = Protocol::new(result.protocol.clone());
1138 let mut writer = StringWriter { buf: String::new() };
1139 handler
1140 .render(
1141 &protocol,
1142 &state,
1143 &RenderOptions::new("index.html", "/"),
1144 &mut writer,
1145 )
1146 .unwrap();
1147 assert!(writer.buf.contains("Server rendered"));
1148 assert!(writer.buf.contains(r#""state":{}"#));
1149 assert!(writer.buf.contains(r#""templates":{"demo-card":"#));
1150 }
1151
1152 #[test]
1153 fn test_build_treats_sibling_component_script_as_scripted() {
1154 let app = create_app_dir(&[
1155 ("index.html", "<demo-card></demo-card>"),
1156 (
1157 "demo-card.html",
1158 r#"<button @click="{onClick()}">{{name}}</button>"#,
1159 ),
1160 (
1161 "demo-card.ts",
1162 "import { WebUIElement } from '@microsoft/webui-framework';\n\
1163 class DemoCard extends WebUIElement { onClick() {} }\n\
1164 DemoCard.define('demo-card');",
1165 ),
1166 ]);
1167 let mut options = default_options(app.path());
1168 options.plugin = Some(Plugin::WebUI);
1169 let result = build(options).unwrap();
1170
1171 let template = result
1172 .component_templates
1173 .iter()
1174 .find(|template| template.tag_name == "demo-card")
1175 .unwrap();
1176 assert!(template.template_json.contains(r#""eg":[["click""#));
1177 }
1178
1179 #[test]
1180 fn test_build_populates_component_hydration_keys_from_script() {
1181 let app = create_app_dir(&[
1182 ("index.html", "<demo-card></demo-card>"),
1183 ("demo-card.html", "<p>{{name}}</p>"),
1184 (
1185 "demo-card.ts",
1186 "import { WebUIElement, attr, observable } from '@microsoft/webui-framework';\n\
1187 class DemoCard extends WebUIElement {\n\
1188 @observable name = '';\n\
1189 @attr({ attribute: 'cta-href' }) ctaHref = '/x';\n\
1190 @observable count = 0;\n\
1191 }\n\
1192 DemoCard.define('demo-card');",
1193 ),
1194 ]);
1195 let manifest_json = projection::test_support::build_valid_manifest_json(
1199 app.path(),
1200 &[("demo-card.ts", "source")],
1201 &[("demo-card.js", "output")],
1202 &[(
1203 "demo-card",
1204 "demo-card.ts",
1205 &["demo-card.js"],
1206 &["count", "ctaHref", "name"],
1207 &["count", "ctaHref", "name"],
1208 )],
1209 );
1210 let manifest = projection::test_support::write_manifest(
1211 app.path(),
1212 "webui-projection.json",
1213 &manifest_json,
1214 );
1215 let mut options = default_options(app.path());
1216 options.plugin = Some(Plugin::WebUI);
1217 options.projection_manifests = vec![manifest.into()];
1218 let result = build(options).unwrap();
1219
1220 assert_eq!(
1221 result.protocol.initial_state_strategy,
1222 webui_protocol::InitialStateStrategy::Components as i32
1223 );
1224 let component = result.protocol.components.get("demo-card").unwrap();
1225 assert_eq!(
1226 component.hydration_mode,
1227 webui_protocol::StateProjectionMode::Keys as i32
1228 );
1229 assert_eq!(component.hydration_keys, vec!["count", "ctaHref", "name"]);
1230 assert_eq!(
1231 component.navigation_mode,
1232 Some(webui_protocol::StateProjectionMode::Keys as i32)
1233 );
1234 assert_eq!(component.navigation_keys, vec!["count", "ctaHref", "name"]);
1235 }
1236
1237 #[test]
1238 fn test_build_scripted_component_without_manifest_falls_back_to_full_state() {
1239 let app = create_app_dir(&[
1240 (
1241 "index.html",
1242 "<html><body><demo-card></demo-card></body></html>",
1243 ),
1244 ("demo-card.html", "<p>{{name}}</p>"),
1245 (
1246 "demo-card.ts",
1247 "import { WebUIElement, observable } from '@microsoft/webui-framework';\n\
1248 class DemoCard extends WebUIElement {\n\
1249 @observable name = '';\n\
1250 }\n\
1251 DemoCard.define('demo-card');",
1252 ),
1253 ]);
1254 let mut options = default_options(app.path());
1255 options.plugin = Some(Plugin::WebUI);
1256 let result = build(options).unwrap();
1259
1260 assert_eq!(
1261 result.protocol.initial_state_strategy,
1262 webui_protocol::InitialStateStrategy::Full as i32
1263 );
1264 let component = result.protocol.components.get("demo-card").unwrap();
1265 assert_eq!(
1266 component.hydration_mode,
1267 webui_protocol::StateProjectionMode::All as i32
1268 );
1269 assert!(component.hydration_keys.is_empty());
1270 assert_eq!(
1271 component.navigation_mode,
1272 Some(webui_protocol::StateProjectionMode::All as i32)
1273 );
1274
1275 let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
1276 let state = serde_json::json!({ "name": "Visible" });
1277 let protocol = Protocol::new(result.protocol.clone());
1278 let mut writer = StringWriter { buf: String::new() };
1279 handler
1280 .render(
1281 &protocol,
1282 &state,
1283 &RenderOptions::new("index.html", "/"),
1284 &mut writer,
1285 )
1286 .unwrap();
1287 assert!(writer.buf.contains("Visible"));
1288 assert!(writer.buf.contains(r#""state":{"name":"Visible"}"#));
1291 }
1292
1293 #[test]
1294 fn test_build_rejects_scriptless_event_component() {
1295 let app = create_app_dir(&[
1296 ("index.html", "<demo-card></demo-card>"),
1297 (
1298 "demo-card.html",
1299 r#"<button @click="{onClick()}">Click</button>"#,
1300 ),
1301 ]);
1302 let mut options = default_options(app.path());
1303 options.plugin = Some(Plugin::WebUI);
1304 let err = build(options).expect_err("scriptless events should fail");
1305
1306 let message = err.chain_message();
1307 assert!(
1308 message.contains("scriptless-event-handler"),
1309 "msg: {message}"
1310 );
1311 }
1312
1313 #[test]
1314 fn test_build_returns_actionable_error_for_invalid_w_ref() {
1315 let app = create_app_dir(&[
1316 ("index.html", "<my-card>Hello</my-card>"),
1317 ("my-card.html", r#"<div><input w-ref="myInput" /></div>"#),
1318 ]);
1319 let mut options = default_options(app.path());
1320 options.plugin = Some(Plugin::WebUI);
1321
1322 let Err(err) = build(options) else {
1325 panic!("non-braced w-ref must fail the build");
1326 };
1327 let msg = err.chain_message();
1328 assert!(msg.contains("invalid w-ref binding"), "msg: {msg}");
1329 assert!(
1330 msg.contains("component <my-card> · element <input>"),
1331 "msg: {msg}"
1332 );
1333 assert!(msg.contains("help:"), "msg: {msg}");
1334 }
1335
1336 #[test]
1337 fn test_build_returns_actionable_error_for_invalid_event_handler() {
1338 let app = create_app_dir(&[
1339 ("index.html", "<my-btn>x</my-btn>"),
1340 (
1341 "my-btn.html",
1342 r#"<div><button @click="e.preventDefault()">x</button></div>"#,
1343 ),
1344 ]);
1345 let mut options = default_options(app.path());
1346 options.plugin = Some(Plugin::WebUI);
1347
1348 let Err(err) = build(options) else {
1349 panic!("invalid @event handler must fail the build");
1350 };
1351 let msg = err.chain_message();
1352 assert!(msg.contains("invalid @click handler"), "msg: {msg}");
1353 assert!(
1354 msg.contains("component <my-btn> · element <button>"),
1355 "msg: {msg}"
1356 );
1357 }
1358
1359 #[test]
1360 fn test_error_chain_message_has_no_duplicate_source() {
1361 let err = WebUIError::Parse {
1362 context: "Failed to parse index.html".to_string(),
1363 source: webui_parser::ParserError::Directive("Invalid for each: x".to_string()),
1364 };
1365 assert_eq!(err.to_string(), "Failed to parse index.html");
1368 assert_eq!(
1370 err.chain_message(),
1371 "Failed to parse index.html: Directive error: Invalid for each: x"
1372 );
1373 }
1374
1375 #[test]
1376 fn test_build_with_directives() {
1377 let html = r#"<h1>Hello</h1>
1378<for each="item in items">
1379 <p>{{item.name}}</p>
1380</for>
1381<if condition="show">
1382 <p>Visible</p>
1383</if>"#;
1384 let app = create_app_dir(&[("index.html", html)]);
1385 let result = build(default_options(app.path())).unwrap();
1386
1387 let index = &result.protocol.fragments["index.html"].fragments;
1388 assert!(index
1389 .iter()
1390 .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(_)))));
1391 assert!(index
1392 .iter()
1393 .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
1394 }
1395
1396 #[test]
1397 fn test_build_with_component_css() {
1398 let app = create_app_dir(&[
1399 ("index.html", "<my-card>Hello</my-card>"),
1400 ("my-card.html", "<div><slot></slot></div>"),
1401 ("my-card.css", ".card { color: red; }"),
1402 ("my-card.ts", "export {};"),
1403 ]);
1404 let result = build(default_options(app.path())).unwrap();
1405
1406 assert_eq!(result.css_files.len(), 1);
1407 assert_eq!(result.css_files[0].0, "my-card.css");
1408 assert!(result.css_files[0].1.contains("color: red"));
1409 assert_eq!(result.stats.css_file_count, 1);
1410 }
1411
1412 #[test]
1413 fn test_build_strips_non_legal_css_comments_from_output_file() {
1414 let app = create_app_dir(&[
1415 ("index.html", "<my-card>Hello</my-card>"),
1416 ("my-card.html", "<div><slot></slot></div>"),
1417 (
1418 "my-card.css",
1419 "/* remove var(--ignored) */ .card { color: var(--textColor); }",
1420 ),
1421 ]);
1422 let result = build(default_options(app.path())).unwrap();
1423
1424 assert_eq!(result.css_files[0].1, " .card { color: var(--textColor); }");
1425 assert_eq!(result.protocol.tokens, vec!["textColor"]);
1426 }
1427
1428 #[test]
1429 fn test_build_preserves_legal_css_comments_by_default() {
1430 let app = create_app_dir(&[
1431 ("index.html", "<my-card>Hello</my-card>"),
1432 ("my-card.html", "<div><slot></slot></div>"),
1433 (
1434 "my-card.css",
1435 "/*! @license MIT */ .card { color: red; } /* remove */",
1436 ),
1437 ]);
1438 let result = build(default_options(app.path())).unwrap();
1439
1440 assert_eq!(
1441 result.css_files[0].1,
1442 "/*! @license MIT */ .card { color: red; } "
1443 );
1444 }
1445
1446 #[test]
1447 fn test_build_legal_comments_none_strips_legal_css_comments() {
1448 let app = create_app_dir(&[
1449 ("index.html", "<my-card>Hello</my-card>"),
1450 ("my-card.html", "<div><slot></slot></div>"),
1451 ("my-card.css", "/*! @license MIT */ .card { color: red; }"),
1452 ]);
1453 let mut options = default_options(app.path());
1454 options.legal_comments = LegalComments::None;
1455 let result = build(options).unwrap();
1456
1457 assert_eq!(result.css_files[0].1, " .card { color: red; }");
1458 }
1459
1460 #[test]
1461 fn test_build_with_css_hashed_filename_template() {
1462 let app = create_app_dir(&[
1463 ("index.html", "<my-card>Hello</my-card>"),
1464 ("my-card.html", "<div><slot></slot></div>"),
1465 ("my-card.css", ".card { color: red; }"),
1466 ]);
1467 let mut options = default_options(app.path());
1468 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
1469 let result = build(options).unwrap();
1470
1471 assert_eq!(result.css_files.len(), 1);
1472 let generated = &result.css_files[0].0;
1473 assert!(generated.starts_with("my-card-"));
1474 assert!(generated.ends_with(".css"));
1475 assert_eq!(generated.len(), "my-card-".len() + 8 + ".css".len());
1476 assert_eq!(
1477 result
1478 .protocol
1479 .components
1480 .get("my-card")
1481 .map(|c| c.css_href.as_str()),
1482 Some(generated.as_str())
1483 );
1484 }
1485
1486 #[test]
1487 fn test_css_public_base_prefixes_css_href_only() {
1488 let app = create_app_dir(&[
1489 ("index.html", "<my-card>Hello</my-card>"),
1490 ("my-card.html", "<div><slot></slot></div>"),
1491 ("my-card.css", ".card { color: red; }"),
1492 ]);
1493 let mut options = default_options(app.path());
1494 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
1495 options.css_public_base = Some("https://cdn.example.com/assets".to_string());
1496 let result = build(options).unwrap();
1497
1498 let filename = &result.css_files[0].0;
1499 assert!(filename.starts_with("my-card-"));
1500 let expected_href = format!("https://cdn.example.com/assets/{filename}");
1501 assert_eq!(
1502 result
1503 .protocol
1504 .components
1505 .get("my-card")
1506 .map(|c| c.css_href.as_str()),
1507 Some(expected_href.as_str())
1508 );
1509 }
1510
1511 #[test]
1512 fn test_css_public_base_emits_parser_and_handler_links() {
1513 let app = create_app_dir(&[
1514 (
1515 "index.html",
1516 "<html><head></head><body><my-card>Hello</my-card></body></html>",
1517 ),
1518 ("my-card.html", "<div><slot></slot></div>"),
1519 ("my-card.css", ".card { color: red; }"),
1520 ]);
1521 let mut options = default_options(app.path());
1522 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
1523 options.css_public_base = Some("https://cdn.example.com/assets".to_string());
1524 let result = build(options).unwrap();
1525
1526 let filename = &result.css_files[0].0;
1527 let expected_href = format!("https://cdn.example.com/assets/{filename}");
1528 let component_html = result.protocol.fragments["my-card"]
1529 .fragments
1530 .iter()
1531 .filter_map(|fragment| match fragment.fragment.as_ref() {
1532 Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
1533 _ => None,
1534 })
1535 .collect::<String>();
1536 assert!(
1537 component_html.contains(&format!(
1538 r#"<link rel="stylesheet" href="{expected_href}">"#
1539 )),
1540 "parser-generated component template should use CDN href: {component_html}"
1541 );
1542
1543 let handler = WebUIHandler::new();
1544 let protocol = Protocol::new(result.protocol.clone());
1545 let mut writer = StringWriter { buf: String::new() };
1546 handler
1547 .render(
1548 &protocol,
1549 &serde_json::json!({}),
1550 &RenderOptions::new("index.html", "/"),
1551 &mut writer,
1552 )
1553 .unwrap();
1554 assert!(
1555 writer.buf.contains(&format!(
1556 r#"<link rel="preload" href="{expected_href}" as="style" data-webui-ssr-preload="style">"#
1557 )),
1558 "handler head preload should use CDN href: {}",
1559 writer.buf
1560 );
1561 }
1562
1563 #[test]
1564 fn test_css_public_base_emits_webui_plugin_template_links() {
1565 let app = create_app_dir(&[
1566 ("index.html", "<my-card>Hello</my-card>"),
1567 ("my-card.html", "<div><slot></slot></div>"),
1568 ("my-card.css", ".card { color: red; }"),
1569 ("my-card.ts", "export {};"),
1570 ]);
1571 let mut options = default_options(app.path());
1572 options.plugin = Some(Plugin::WebUI);
1573 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
1574 options.css_public_base = Some("https://cdn.example.com/assets".to_string());
1575 let result = build(options).unwrap();
1576
1577 let filename = &result.css_files[0].0;
1578 let expected_href = format!("https://cdn.example.com/assets/{filename}");
1579 let template = &result.protocol.components["my-card"].template_json;
1580 assert!(
1581 template.contains(&format!(r#"href=\"{expected_href}\""#)),
1582 "plugin component template should use CDN href: {template}"
1583 );
1584 }
1585
1586 #[test]
1587 fn test_css_public_base_emits_fast_plugin_template_links() {
1588 let app = create_app_dir(&[
1589 ("index.html", "<my-card>Hello</my-card>"),
1590 ("my-card.html", "<div><slot></slot></div>"),
1591 ("my-card.css", ".card { color: red; }"),
1592 ]);
1593 let mut options = default_options(app.path());
1594 options.plugin = Some(Plugin::FastV3);
1595 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
1596 options.css_public_base = Some("https://cdn.example.com/assets".to_string());
1597 let result = build(options).unwrap();
1598
1599 let filename = &result.css_files[0].0;
1600 let expected_href = format!("https://cdn.example.com/assets/{filename}");
1601 let template = &result.protocol.components["my-card"].template;
1602 assert!(
1603 template.contains(&format!(r#"href="{expected_href}""#)),
1604 "FAST component template should use CDN href: {template}"
1605 );
1606 }
1607
1608 #[test]
1609 fn test_invalid_css_template_is_rejected() {
1610 let app = create_app_dir(&[
1611 ("index.html", "<my-card>Hello</my-card>"),
1612 ("my-card.html", "<div><slot></slot></div>"),
1613 ("my-card.css", ".card { color: red; }"),
1614 ]);
1615 let mut options = default_options(app.path());
1616 options.css_file_name_template = "[name]-[bogus].[ext]".to_string();
1617 let result = build(options);
1618
1619 assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
1620 }
1621
1622 #[test]
1623 fn test_css_filename_collision_is_rejected() {
1624 let app = create_app_dir(&[
1625 ("index.html", "<card-a>A</card-a><card-b>B</card-b>"),
1626 ("card-a.html", "<div><slot></slot></div>"),
1627 ("card-a.css", ".x { color: red; }"),
1628 ("card-b.html", "<div><slot></slot></div>"),
1629 ("card-b.css", ".x { color: red; }"),
1630 ]);
1631 let mut options = default_options(app.path());
1632 options.css_file_name_template = "[hash].[ext]".to_string();
1633 let result = build(options);
1634
1635 assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
1636 }
1637
1638 #[test]
1639 fn test_build_returns_static_component_asset_files() {
1640 let app = create_app_dir(&[
1641 ("index.html", "<app-shell></app-shell>"),
1642 ("app-shell.html", "<div></div>"),
1643 (
1644 "lazy-panel.html",
1645 r#"<if condition="ready"><p>{{title}}</p></if>"#,
1646 ),
1647 ("lazy-panel.ts", "export {};"),
1648 ]);
1649 let mut options = default_options(app.path());
1650 options.plugin = Some(Plugin::WebUI);
1651 options.component_asset_roots = vec!["lazy-panel".to_string()];
1652
1653 let result = build(options).unwrap();
1654
1655 assert_eq!(result.component_asset_files.len(), 1);
1656 assert_eq!(result.component_asset_files[0].name, "lazy-panel.webui.js");
1657 assert!(result.component_asset_files[0]
1658 .content
1659 .contains(r#""type":"webui-component-asset""#));
1660 assert!(result.component_asset_files[0]
1661 .content
1662 .contains(r#""version":2"#));
1663 assert!(result.component_asset_files[0]
1664 .content
1665 .contains(r#""kind":"root""#));
1666 assert!(result.component_asset_files[0]
1667 .content
1668 .contains(r#""templateFunctions":{"lazy-panel":"#));
1669 assert!(!result.protocol.fragments.contains_key("lazy-panel"));
1670 assert!(
1671 !result
1672 .protocol
1673 .fragments
1674 .keys()
1675 .any(|key| key.starts_with("__webui_asset_root_")),
1676 "synthetic asset root fragments must not be serialized"
1677 );
1678 }
1679
1680 #[test]
1681 fn test_build_splits_shared_assets_and_keeps_them_out_of_protocol() {
1682 let app = create_app_dir(&[
1683 ("index.html", "<app-shell></app-shell>"),
1684 ("app-shell.html", "<entry-badge></entry-badge>"),
1685 ("entry-badge.html", "<span>Entry</span>"),
1686 (
1687 "lazy-panel.html",
1688 "<entry-badge></entry-badge><shared-detail></shared-detail><panel-only></panel-only>",
1689 ),
1690 (
1691 "secondary-panel.html",
1692 "<entry-badge></entry-badge><shared-detail></shared-detail><secondary-only></secondary-only>",
1693 ),
1694 ("shared-detail.html", "<p>Shared</p>"),
1695 ("panel-only.html", "<p>Panel</p>"),
1696 ("secondary-only.html", "<p>Secondary</p>"),
1697 ]);
1698 let mut options = default_options(app.path());
1699 options.plugin = Some(Plugin::WebUI);
1700 options.component_asset_roots =
1701 vec!["lazy-panel".to_string(), "secondary-panel".to_string()];
1702
1703 let result = build(options).unwrap();
1704
1705 for tag in [
1706 "lazy-panel",
1707 "secondary-panel",
1708 "shared-detail",
1709 "panel-only",
1710 "secondary-only",
1711 ] {
1712 assert!(
1713 !result.protocol.fragments.contains_key(tag),
1714 "asset-only fragment <{tag}> must not be serialized"
1715 );
1716 assert!(
1717 !result.protocol.components.contains_key(tag),
1718 "asset-only component <{tag}> must not be serialized"
1719 );
1720 }
1721 assert!(result.protocol.fragments.contains_key("app-shell"));
1722 assert!(result.protocol.components.contains_key("entry-badge"));
1723
1724 let names: Vec<&str> = result
1725 .component_asset_files
1726 .iter()
1727 .map(|file| file.name.as_str())
1728 .collect();
1729 assert_eq!(
1730 names,
1731 vec![
1732 "lazy-panel.webui.js",
1733 "secondary-panel.webui.js",
1734 "chunk-shared-detail.webui.js",
1735 ]
1736 );
1737 let lazy = &result.component_asset_files[0].content;
1738 assert!(lazy.contains(r#""version":2"#));
1739 assert!(lazy.contains(r#""kind":"root""#));
1740 assert!(lazy.contains(r#""externalComponents":["entry-badge"]"#));
1741 assert!(lazy
1742 .contains(r#""href":new URL("./chunk-shared-detail.webui.js",import.meta.url).href"#));
1743 assert!(lazy.contains(r#"import("./chunk-shared-detail.webui.js")"#));
1744 assert!(!lazy.contains(r#""templates":{"entry-badge":"#));
1745 assert!(!lazy.contains(r#""templates":{"shared-detail":"#));
1746
1747 let chunk = &result.component_asset_files[2].content;
1748 assert!(chunk.contains(r#""kind":"chunk""#));
1749 assert!(chunk.contains(r#""components":["shared-detail"]"#));
1750 assert!(chunk.contains(r#""templates":{"shared-detail":"#));
1751 }
1752
1753 #[test]
1754 fn test_component_asset_graph_groups_each_consumer_set() {
1755 let app = create_app_dir(&[
1756 ("index.html", "<app-shell></app-shell>"),
1757 ("app-shell.html", "<p>Entry</p>"),
1758 (
1759 "root-a.html",
1760 "<shared-ab></shared-ab><shared-all></shared-all><only-a></only-a>",
1761 ),
1762 (
1763 "root-b.html",
1764 "<shared-ab></shared-ab><shared-all></shared-all><only-b></only-b>",
1765 ),
1766 ("root-c.html", "<shared-all></shared-all><only-c></only-c>"),
1767 ("shared-ab.html", "<p>AB</p>"),
1768 ("shared-all.html", "<p>All</p>"),
1769 ("only-a.html", "<p>A</p>"),
1770 ("only-b.html", "<p>B</p>"),
1771 ("only-c.html", "<p>C</p>"),
1772 ]);
1773 let mut options = default_options(app.path());
1774 options.plugin = Some(Plugin::WebUI);
1775 options.component_asset_roots = vec![
1776 "root-a".to_string(),
1777 "root-b".to_string(),
1778 "root-c".to_string(),
1779 ];
1780
1781 let result = build(options).unwrap();
1782 let names: Vec<&str> = result
1783 .component_asset_files
1784 .iter()
1785 .map(|file| file.name.as_str())
1786 .collect();
1787 assert_eq!(
1788 names,
1789 [
1790 "root-a.webui.js",
1791 "root-b.webui.js",
1792 "root-c.webui.js",
1793 "chunk-shared-ab.webui.js",
1794 "chunk-shared-all.webui.js",
1795 ]
1796 );
1797 assert!(result.component_asset_files[0]
1798 .content
1799 .contains(r#""components":["only-a","root-a"]"#));
1800 assert!(result.component_asset_files[3]
1801 .content
1802 .contains(r#""components":["shared-ab"]"#));
1803 assert!(result.component_asset_files[4]
1804 .content
1805 .contains(r#""components":["shared-all"]"#));
1806 }
1807
1808 #[test]
1809 fn test_component_asset_graph_is_root_order_independent_with_hashed_imports() {
1810 let app = create_app_dir(&[
1811 ("index.html", "<app-shell></app-shell>"),
1812 ("app-shell.html", "<p>Entry</p>"),
1813 ("root-a.html", "<shared-detail></shared-detail>"),
1814 ("root-b.html", "<shared-detail></shared-detail>"),
1815 ("shared-detail.html", "<p>Shared</p>"),
1816 ]);
1817 let build_files = |roots: Vec<String>| {
1818 let mut options = default_options(app.path());
1819 options.plugin = Some(Plugin::WebUI);
1820 options.component_asset_roots = roots;
1821 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
1822 build(options).unwrap().component_asset_files
1823 };
1824
1825 let forward = build_files(vec!["root-a".to_string(), "root-b".to_string()]);
1826 let reverse = build_files(vec!["root-b".to_string(), "root-a".to_string()]);
1827
1828 assert_eq!(forward, reverse);
1829 let chunk_name = &forward[2].name;
1830 assert!(chunk_name.starts_with("chunk-shared-detail-"));
1831 assert!(forward[0].content.contains(chunk_name));
1832 assert!(forward[1].content.contains(chunk_name));
1833 }
1834
1835 #[test]
1836 fn test_metafile_describes_dynamic_import_graph() {
1837 let app = create_app_dir(&[
1838 ("index.html", "<app-shell></app-shell>"),
1839 ("app-shell.html", "<p>Entry</p>"),
1840 ("root-a.html", "<shared-detail></shared-detail>"),
1841 ("root-b.html", "<shared-detail></shared-detail>"),
1842 ("shared-detail.html", "<p>Shared</p>"),
1843 ]);
1844 let mut options = default_options(app.path());
1845 options.plugin = Some(Plugin::WebUI);
1846 options.component_asset_roots = vec!["root-a".to_string(), "root-b".to_string()];
1847 options.metafile = true;
1848
1849 let result = build(options).unwrap();
1850 let metafile = result.metafile.as_deref().unwrap();
1851 let value: serde_json::Value = serde_json::from_str(metafile).unwrap();
1852 assert!(value["inputs"]
1853 .get("webui:component/shared-detail")
1854 .is_some());
1855 assert_eq!(
1856 value["outputs"]["root-a.webui.js"]["entryPoint"],
1857 "webui:component/root-a"
1858 );
1859 assert_eq!(
1860 value["outputs"]["root-a.webui.js"]["imports"][0]["path"],
1861 "chunk-shared-detail.webui.js"
1862 );
1863 assert_eq!(
1864 value["outputs"]["root-a.webui.js"]["imports"][0]["kind"],
1865 "dynamic-import"
1866 );
1867 assert_eq!(
1868 value["inputs"]["webui:component/root-a"]["imports"][0]["kind"],
1869 "dynamic-import"
1870 );
1871 assert_eq!(
1872 value["outputs"]["chunk-shared-detail.webui.js"]["bytes"],
1873 result.component_asset_files[2].content.len()
1874 );
1875 }
1876
1877 #[test]
1878 fn test_component_assets_reject_routes_with_stable_diagnostic() {
1879 let app = create_app_dir(&[
1880 (
1881 "index.html",
1882 r#"<route path="/" component="app-shell" exact />"#,
1883 ),
1884 ("app-shell.html", "<p>Entry</p>"),
1885 ("lazy-panel.html", "<p>Lazy</p>"),
1886 ]);
1887 let mut options = default_options(app.path());
1888 options.plugin = Some(Plugin::WebUI);
1889 options.component_asset_roots = vec!["lazy-panel".to_string()];
1890
1891 let error = build(options).unwrap_err();
1892 match error {
1893 WebUIError::ComponentAssets(diagnostic) => {
1894 assert_eq!(
1895 diagnostic.error_code(),
1896 Some(webui_parser::codes::COMPONENT_ASSETS_WITH_ROUTES)
1897 );
1898 assert!(diagnostic.help_text().is_some());
1899 }
1900 other => panic!("expected route diagnostic, received {other}"),
1901 }
1902 }
1903
1904 #[test]
1905 fn test_component_asset_filename_collision_with_css_is_rejected() {
1906 let app = create_app_dir(&[
1907 ("index.html", "<app-shell></app-shell>"),
1908 ("app-shell.html", "<div></div>"),
1909 ("lazy-panel.html", "<p>Lazy</p>"),
1910 ("lazy-panel.css", ".panel { color: red; }"),
1911 ]);
1912 let mut options = default_options(app.path());
1913 options.plugin = Some(Plugin::WebUI);
1914 options.component_asset_roots = vec!["lazy-panel".to_string()];
1915 options.css_file_name_template = "[name]".to_string();
1916
1917 let result = build(options);
1918
1919 assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
1920 }
1921
1922 #[test]
1923 fn test_build_to_disk_rejects_protocol_name_collision_before_writing() {
1924 let app = create_app_dir(&[
1925 ("index.html", "<app-shell></app-shell>"),
1926 ("app-shell.html", "<div></div>"),
1927 ("lazy-panel.html", "<p>Lazy</p>"),
1928 ]);
1929 let out = TempDir::new().unwrap();
1930 let mut options = default_options(app.path());
1931 options.plugin = Some(Plugin::WebUI);
1932 options.component_asset_roots = vec!["lazy-panel".to_string()];
1933 options.css_file_name_template = "protocol.bin".to_string();
1934
1935 let result = build_to_disk(options, out.path());
1936
1937 assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
1938 assert!(!out.path().join("protocol.bin").exists());
1939 }
1940
1941 #[test]
1942 fn test_css_href_set_for_light_dom_link_strategy() {
1943 let app = create_app_dir(&[
1944 ("index.html", "<has-css>A</has-css><no-css>B</no-css>"),
1945 ("has-css.html", "<p><slot></slot></p>"),
1946 ("has-css.css", ".yes { color: green; }"),
1947 ("no-css.html", "<p><slot></slot></p>"),
1948 ]);
1949 let mut options = default_options(app.path());
1950 options.dom = DomStrategy::Light;
1951 let result = build(options).unwrap();
1952
1953 let href = result
1954 .protocol
1955 .components
1956 .get("has-css")
1957 .map(|c| c.css_href.as_str())
1958 .unwrap_or("");
1959 assert_eq!(
1960 href, "has-css.css",
1961 "Light×Link component with CSS should have css_href"
1962 );
1963
1964 let no_href = result
1965 .protocol
1966 .components
1967 .get("no-css")
1968 .map(|c| c.css_href.as_str())
1969 .unwrap_or("");
1970 assert!(
1971 no_href.is_empty(),
1972 "Component without CSS should have empty css_href"
1973 );
1974 }
1975
1976 #[test]
1977 fn test_shadow_dom_link_strategy_sets_css_href() {
1978 let app = create_app_dir(&[
1982 ("index.html", "<my-card>A</my-card>"),
1983 ("my-card.html", "<p><slot></slot></p>"),
1984 ("my-card.css", ".card { color: red; }"),
1985 ]);
1986 let result = build(default_options(app.path())).unwrap();
1987
1988 let comp = result.protocol.components.get("my-card").unwrap();
1989 assert_eq!(
1990 comp.css_href, "my-card.css",
1991 "Shadow×Link should set css_href"
1992 );
1993
1994 assert_eq!(
1996 result.protocol.css_strategy(),
1997 webui_protocol::CssStrategy::Link,
1998 );
1999 assert_eq!(
2000 result.protocol.dom_strategy(),
2001 webui_protocol::DomStrategy::Shadow,
2002 );
2003
2004 assert_eq!(
2006 result.css_files.len(),
2007 1,
2008 "CSS file should still be produced"
2009 );
2010 }
2011
2012 #[test]
2013 fn test_css_href_empty_for_style_strategy() {
2014 let app = create_app_dir(&[
2015 ("index.html", "<my-card>A</my-card>"),
2016 ("my-card.html", "<p><slot></slot></p>"),
2017 ("my-card.css", ".card { color: red; }"),
2018 ]);
2019 let mut options = default_options(app.path());
2020 options.css = CssStrategy::Style;
2021 let result = build(options).unwrap();
2022
2023 let href = result
2024 .protocol
2025 .components
2026 .get("my-card")
2027 .map(|c| c.css_href.as_str())
2028 .unwrap_or("");
2029 assert!(href.is_empty(), "Style-strategy should not set css_href");
2030 }
2031
2032 #[test]
2033 fn test_css_href_empty_for_module_strategy() {
2034 let app = create_app_dir(&[
2035 ("index.html", "<my-card>A</my-card>"),
2036 ("my-card.html", "<p><slot></slot></p>"),
2037 ("my-card.css", ".card { color: red; }"),
2038 ]);
2039 let mut options = default_options(app.path());
2040 options.css = CssStrategy::Module;
2041 let result = build(options).unwrap();
2042
2043 let href = result
2044 .protocol
2045 .components
2046 .get("my-card")
2047 .map(|c| c.css_href.as_str())
2048 .unwrap_or("");
2049 assert!(href.is_empty(), "Module-strategy should not set css_href");
2050 assert!(
2051 !result.protocol.components["my-card"].css.is_empty(),
2052 "Module-strategy should still populate css content"
2053 );
2054 }
2055
2056 #[test]
2057 fn test_build_to_disk_writes_files() {
2058 let app = create_app_dir(&[
2059 ("index.html", "<my-card>Hello</my-card>"),
2060 ("my-card.html", "<div><slot></slot></div>"),
2061 ("my-card.css", ".card { color: red; }"),
2062 ("my-card.ts", "export {};"),
2063 ]);
2064 let out = TempDir::new().unwrap();
2065
2066 let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
2067
2068 assert!(out.path().join("protocol.bin").exists());
2069 assert!(out.path().join("my-card.css").exists());
2070 assert_eq!(stats.css_file_count, 1);
2071 assert!(stats.fragment_count > 0);
2072 }
2073
2074 #[test]
2075 fn test_build_to_disk_creates_nested_output_dir() {
2076 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
2077 let out = TempDir::new().unwrap();
2078 let nested = out.path().join("nested").join("output");
2079
2080 let stats = build_to_disk(default_options(app.path()), &nested).unwrap();
2081 assert!(nested.join("protocol.bin").exists());
2082 assert!(stats.fragment_count > 0);
2083 }
2084
2085 #[test]
2086 fn test_build_missing_entry() {
2087 let app = create_app_dir(&[("other.html", "<h1>Not index</h1>")]);
2088 let result = build(default_options(app.path()));
2089
2090 assert!(result.is_err());
2091 let err = result.unwrap_err();
2092 assert!(matches!(err, WebUIError::Io { .. }));
2093 }
2094
2095 #[test]
2096 fn test_build_stats_populated() {
2097 let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
2098 let result = build(default_options(app.path())).unwrap();
2099
2100 assert!(result.stats.fragment_count > 0);
2101 assert!(result.stats.protocol_size_bytes > 0);
2102 assert_eq!(result.stats.css_file_count, 0);
2103 }
2104
2105 #[test]
2106 fn test_build_inline_css() {
2107 let app = create_app_dir(&[
2108 ("index.html", "<my-card>Hello</my-card>"),
2109 ("my-card.html", "<div><slot></slot></div>"),
2110 ("my-card.css", ".card { color: red; }"),
2111 ]);
2112 let mut options = default_options(app.path());
2113 options.css = CssStrategy::Style;
2114
2115 let result = build(options).unwrap();
2116 assert!(result.css_files.is_empty());
2118 assert_eq!(result.stats.css_file_count, 0);
2119 assert!(result.stats.fragment_count > 0);
2120 }
2121
2122 #[test]
2123 fn test_build_module_css() {
2124 let app = create_app_dir(&[
2125 ("index.html", "<my-card>Hello</my-card>"),
2126 ("my-card.html", "<div><slot></slot></div>"),
2127 ("my-card.css", ".card { color: red; }"),
2128 ]);
2129 let mut options = default_options(app.path());
2130 options.css = CssStrategy::Module;
2131
2132 let result = build(options).unwrap();
2133 assert!(result.css_files.is_empty());
2135 assert_eq!(result.stats.css_file_count, 0);
2136 assert!(result.stats.fragment_count > 0);
2137 }
2138
2139 #[test]
2140 fn test_build_custom_entry() {
2141 let app = create_app_dir(&[("page.html", "<h1>Custom</h1>")]);
2142 let mut options = default_options(app.path());
2143 options.entry = "page.html".to_string();
2144
2145 let result = build(options).unwrap();
2146 assert!(result.protocol.fragments.contains_key("page.html"));
2147 assert!(!result.protocol.fragments.contains_key("index.html"));
2148 }
2149
2150 #[test]
2151 fn test_build_protocol_roundtrip() {
2152 let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
2153 let result = build(default_options(app.path())).unwrap();
2154
2155 let restored = WebUIProtocol::from_protobuf(&result.protocol_bytes).unwrap();
2156 assert!(restored.fragments.contains_key("index.html"));
2157 }
2158
2159 #[test]
2160 fn protocol_is_available_from_facade() {
2161 let protocol = Protocol::new(WebUIProtocol::new(HashMap::new()));
2162 assert!(protocol.tokens().is_empty());
2163 }
2164
2165 #[test]
2166 fn test_inspect_bytes_valid() {
2167 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
2168 let result = build(default_options(app.path())).unwrap();
2169
2170 let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
2171 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
2172 assert!(parsed.get("fragments").is_some());
2173 }
2174
2175 #[test]
2176 fn test_inspect_bytes_invalid() {
2177 let result = inspect_bytes(b"not a protobuf");
2178 assert!(result.is_err());
2179 assert!(matches!(result.unwrap_err(), WebUIError::Protocol(_)));
2180 }
2181
2182 #[test]
2183 fn test_inspect_file() {
2184 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
2185 let out = TempDir::new().unwrap();
2186 build_to_disk(default_options(app.path()), out.path()).unwrap();
2187
2188 let json_str = inspect(&out.path().join("protocol.bin")).unwrap();
2189 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
2190 assert!(parsed["fragments"]["index.html"]["fragments"].is_array());
2191 }
2192
2193 #[test]
2194 fn test_inspect_missing_file() {
2195 let result = inspect(Path::new("/nonexistent/protocol.bin"));
2196 assert!(result.is_err());
2197 assert!(matches!(result.unwrap_err(), WebUIError::Io { .. }));
2198 }
2199
2200 #[test]
2201 fn test_build_with_components_local_path() {
2202 let app = create_app_dir(&[("index.html", "<ext-card>Hello</ext-card>")]);
2203 let ext_dir = TempDir::new().unwrap();
2204 fs::write(
2205 ext_dir.path().join("ext-card.html"),
2206 "<div class=\"card\"><slot></slot></div>",
2207 )
2208 .unwrap();
2209 fs::write(
2210 ext_dir.path().join("ext-card.css"),
2211 ".card { border: 1px solid #ccc; }",
2212 )
2213 .unwrap();
2214
2215 let mut options = default_options(app.path());
2216 options.components = vec![ext_dir.path().to_string_lossy().to_string()];
2217
2218 let result = build(options).unwrap();
2219 assert!(result.protocol.fragments.contains_key("index.html"));
2220 assert_eq!(result.css_files.len(), 1);
2221 assert!(result.css_files[0].1.contains("border"));
2222 }
2223
2224 #[test]
2225 fn test_build_with_scriptless_component_path_emits_dormant_template() {
2226 let app = create_app_dir(&[("index.html", "<ext-card></ext-card>")]);
2227 let ext_dir = TempDir::new().unwrap();
2228 fs::write(ext_dir.path().join("ext-card.html"), "<p>{{title}}</p>").unwrap();
2229
2230 let mut options = default_options(app.path());
2231 options.plugin = Some(Plugin::WebUI);
2232 options.components = vec![ext_dir.path().to_string_lossy().to_string()];
2233
2234 let result = build(options).unwrap();
2235 assert!(result
2236 .component_templates
2237 .iter()
2238 .any(|template| template.tag_name == "ext-card"));
2239 let component = result
2240 .protocol
2241 .components
2242 .get("ext-card")
2243 .expect("external scriptless component metadata should be retained");
2244 assert!(component.hydration_keys.is_empty());
2245 assert_eq!(component.navigation_keys, ["title"]);
2246 assert!(component.template_json.contains(r#""th":1"#));
2247 }
2248
2249 #[test]
2250 fn test_build_with_scripted_component_path_emits_client_artifact() {
2251 let app = create_app_dir(&[("index.html", "<ext-card></ext-card>")]);
2252 let ext_dir = TempDir::new().unwrap();
2253 fs::write(ext_dir.path().join("ext-card.html"), "<p>{{title}}</p>").unwrap();
2254 fs::write(ext_dir.path().join("ext-card.ts"), "export {};").unwrap();
2255
2256 let mut options = default_options(app.path());
2257 options.plugin = Some(Plugin::WebUI);
2258 options.components = vec![ext_dir.path().to_string_lossy().to_string()];
2259
2260 let result = build(options).unwrap();
2261 let template = &result.protocol.components["ext-card"].template_json;
2262 assert!(!template.is_empty(), "template should be emitted");
2263 }
2264
2265 #[test]
2266 fn test_build_rejects_projection_manifest_with_non_webui_plugin() {
2267 let app = create_app_dir(&[
2268 ("index.html", "<demo-card></demo-card>"),
2269 ("demo-card.html", "<p>{{name}}</p>"),
2270 ("demo-card.ts", "export {};"),
2271 ]);
2272 let manifest_json = projection::test_support::build_valid_manifest_json(
2273 app.path(),
2274 &[("demo-card.ts", "export {};")],
2275 &[("projection-bundle.js", "output")],
2276 &[(
2277 "demo-card",
2278 "demo-card.ts",
2279 &["projection-bundle.js"],
2280 &["name"],
2281 &["name"],
2282 )],
2283 );
2284 let manifest = projection::test_support::write_manifest(
2285 app.path(),
2286 "webui-projection.json",
2287 &manifest_json,
2288 );
2289 let mut options = default_options(app.path());
2290 options.projection_manifests = vec![manifest.into()];
2293 let err = build(options).expect_err("non-WebUI plugin + manifest should be rejected");
2294 assert!(
2295 err.chain_message().contains("PROJ-B002"),
2296 "msg: {}",
2297 err.chain_message()
2298 );
2299 }
2300
2301 #[test]
2302 fn test_build_rejects_missing_scripted_component_coverage() {
2303 let app = create_app_dir(&[
2304 ("index.html", "<demo-card></demo-card>"),
2305 ("demo-card.html", "<p>{{name}}</p>"),
2306 ("demo-card.ts", "export {};"),
2307 ]);
2308 let manifest_json =
2311 projection::test_support::build_valid_manifest_json(app.path(), &[], &[], &[]);
2312 let manifest = projection::test_support::write_manifest(
2313 app.path(),
2314 "webui-projection.json",
2315 &manifest_json,
2316 );
2317 let mut options = default_options(app.path());
2318 options.plugin = Some(Plugin::WebUI);
2319 options.projection_manifests = vec![manifest.into()];
2320 let err = build(options).expect_err("missing coverage should fail the build");
2321 let message = err.chain_message();
2322 assert!(message.contains("PROJ-B001"), "msg: {message}");
2323 assert!(message.contains("demo-card"), "msg: {message}");
2324 }
2325
2326 #[test]
2327 fn test_build_allows_unused_discovered_scripted_component_without_coverage() {
2328 let app = create_app_dir(&[
2329 ("index.html", "<demo-card></demo-card>"),
2330 ("demo-card.html", "<p>{{name}}</p>"),
2331 ("demo-card.ts", "export {};"),
2332 ("unused-card.html", "<p>{{title}}</p>"),
2336 ("unused-card.ts", "export {};"),
2337 ]);
2338 let manifest_json = projection::test_support::build_valid_manifest_json(
2339 app.path(),
2340 &[("demo-card.ts", "export {};")],
2341 &[("projection-bundle.js", "output")],
2342 &[(
2343 "demo-card",
2344 "demo-card.ts",
2345 &["projection-bundle.js"],
2346 &["name"],
2347 &["name"],
2348 )],
2349 );
2350 let manifest = projection::test_support::write_manifest(
2351 app.path(),
2352 "webui-projection.json",
2353 &manifest_json,
2354 );
2355 let mut options = default_options(app.path());
2356 options.plugin = Some(Plugin::WebUI);
2357 options.projection_manifests = vec![manifest.into()];
2358 let result = build(options).unwrap();
2359
2360 assert!(!result.protocol.fragments.contains_key("unused-card"));
2361 let component = result.protocol.components.get("demo-card").unwrap();
2362 assert_eq!(component.hydration_keys, vec!["name"]);
2363 }
2364
2365 #[test]
2366 fn test_build_allows_asset_only_scripted_component_without_projection_coverage() {
2367 let app = create_app_dir(&[
2368 ("index.html", "<app-shell></app-shell>"),
2369 ("app-shell.html", "<p>Entry</p>"),
2370 ("lazy-panel.html", "<p>{{title}}</p>"),
2371 ("lazy-panel.ts", "export {};"),
2372 ]);
2373 let manifest_json =
2374 projection::test_support::build_valid_manifest_json(app.path(), &[], &[], &[]);
2375 let manifest = projection::test_support::write_manifest(
2376 app.path(),
2377 "webui-projection.json",
2378 &manifest_json,
2379 );
2380 let mut options = default_options(app.path());
2381 options.plugin = Some(Plugin::WebUI);
2382 options.component_asset_roots = vec!["lazy-panel".to_string()];
2383 options.projection_manifests = vec![manifest.into()];
2384
2385 let result = build(options).unwrap();
2386
2387 assert_eq!(result.component_asset_files.len(), 1);
2388 assert!(!result.protocol.fragments.contains_key("lazy-panel"));
2389 assert!(!result.protocol.components.contains_key("lazy-panel"));
2390 }
2391
2392 #[test]
2393 fn test_build_merges_multiple_projection_manifest_fragments() {
2394 let app = create_app_dir(&[
2395 (
2396 "index.html",
2397 "<html><body><card-a></card-a><card-b></card-b></body></html>",
2398 ),
2399 ("card-a.html", "<p>{{alpha}}</p>"),
2400 ("card-a.ts", "export {};"),
2401 ("card-b.html", "<p>{{beta}}</p>"),
2402 ("card-b.ts", "export {};"),
2403 ]);
2404 let json_a = projection::test_support::build_valid_manifest_json(
2405 app.path(),
2406 &[("card-a.ts", "export {};")],
2407 &[("card-a.js", "output-a")],
2408 &[(
2409 "card-a",
2410 "card-a.ts",
2411 &["card-a.js"],
2412 &["alpha"],
2413 &["alpha"],
2414 )],
2415 );
2416 let manifest_a =
2417 projection::test_support::write_manifest(app.path(), "fragment-a.json", &json_a);
2418 let json_b = projection::test_support::build_valid_manifest_json(
2419 app.path(),
2420 &[("card-b.ts", "export {};")],
2421 &[("card-b.js", "output-b")],
2422 &[("card-b", "card-b.ts", &["card-b.js"], &["beta"], &["beta"])],
2423 );
2424 let manifest_b =
2425 projection::test_support::write_manifest(app.path(), "fragment-b.json", &json_b);
2426
2427 let mut options = default_options(app.path());
2428 options.plugin = Some(Plugin::WebUI);
2429 options.projection_manifests = vec![manifest_a.into(), manifest_b.into()];
2430 let result = build(options).unwrap();
2431
2432 assert_eq!(
2433 result.protocol.initial_state_strategy,
2434 webui_protocol::InitialStateStrategy::Components as i32
2435 );
2436 let card_a = result.protocol.components.get("card-a").unwrap();
2437 assert_eq!(card_a.hydration_keys, vec!["alpha"]);
2438 let card_b = result.protocol.components.get("card-b").unwrap();
2439 assert_eq!(card_b.hydration_keys, vec!["beta"]);
2440 }
2441
2442 #[test]
2443 fn test_build_hello_world_example() {
2444 let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
2445 let app_dir = manifest_dir.join("../../examples/app/hello-world/src");
2446
2447 let result = build(BuildOptions {
2448 app_dir,
2449 entry: "index.html".to_string(),
2450 ..BuildOptions::default()
2451 })
2452 .unwrap();
2453
2454 let index = &result.protocol.fragments["index.html"].fragments;
2455 assert!(index.iter().any(
2456 |f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(fl)) if fl.collection == "people")
2457 ));
2458 assert!(index
2459 .iter()
2460 .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
2461 }
2462
2463 #[test]
2466 fn test_css_filename_sanitizes_path_separators() {
2467 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
2470 let result = build(default_options(app.path())).unwrap();
2471
2472 for (filename, _) in &result.css_files {
2474 assert!(
2475 !filename.contains('/') && !filename.contains('\\'),
2476 "CSS filename contains path separator: {filename}"
2477 );
2478 }
2479 }
2480
2481 #[test]
2482 fn test_build_to_disk_css_stays_in_output_dir() {
2483 let app = create_app_dir(&[
2484 ("index.html", "<my-card>Hello</my-card>"),
2485 ("my-card.html", "<div><slot></slot></div>"),
2486 ("my-card.css", ".card { color: red; }"),
2487 ]);
2488 let out = TempDir::new().unwrap();
2489
2490 build_to_disk(default_options(app.path()), out.path()).unwrap();
2491
2492 let css_path = out.path().join("my-card.css");
2494 assert!(css_path.exists());
2495 let canonical = css_path.canonicalize().unwrap();
2496 let out_canonical = out.path().canonicalize().unwrap();
2497 assert!(
2498 canonical.starts_with(&out_canonical),
2499 "CSS file escaped output directory"
2500 );
2501 }
2502
2503 #[test]
2506 fn test_build_empty_html() {
2507 let app = create_app_dir(&[("index.html", "")]);
2508 let result = build(default_options(app.path())).unwrap();
2509
2510 assert!(result.protocol.fragments.contains_key("index.html"));
2511 assert_eq!(result.stats.fragment_count, 0);
2512 assert!(result.stats.protocol_size_bytes > 0);
2513 }
2514
2515 #[test]
2516 fn test_build_large_fragment_count() {
2517 let mut html = String::with_capacity(2000);
2519 for i in 0..50 {
2520 html.push_str(&format!("<p>Item {i}</p>\n"));
2521 }
2522 let app = create_app_dir(&[("index.html", &html)]);
2523 let result = build(default_options(app.path())).unwrap();
2524
2525 assert!(result.stats.fragment_count > 0);
2526 assert_eq!(result.stats.css_file_count, 0);
2527 }
2528
2529 #[test]
2530 fn test_build_with_fast_plugin() {
2531 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
2532 let mut options = default_options(app.path());
2533 options.plugin = Some(Plugin::FastV3);
2534
2535 let result = build(options).unwrap();
2536 assert!(result.protocol.fragments.contains_key("index.html"));
2537 assert_eq!(
2538 result.protocol.initial_state_strategy,
2539 webui_protocol::InitialStateStrategy::Full as i32
2540 );
2541 }
2542
2543 #[test]
2544 fn test_build_to_disk_inline_mode_no_css_files() {
2545 let app = create_app_dir(&[
2546 ("index.html", "<my-card>Hello</my-card>"),
2547 ("my-card.html", "<div><slot></slot></div>"),
2548 ("my-card.css", ".card { color: red; }"),
2549 ]);
2550 let out = TempDir::new().unwrap();
2551 let mut options = default_options(app.path());
2552 options.css = CssStrategy::Style;
2553
2554 let stats = build_to_disk(options, out.path()).unwrap();
2555
2556 assert!(out.path().join("protocol.bin").exists());
2557 assert!(!out.path().join("my-card.css").exists());
2558 assert_eq!(stats.css_file_count, 0);
2559 }
2560
2561 #[test]
2562 fn test_build_stats_duration_is_nonzero() {
2563 let app = create_app_dir(&[("index.html", "<h1>Hello {{name}}</h1>")]);
2564 let result = build(default_options(app.path())).unwrap();
2565
2566 assert!(!result.stats.duration.is_zero());
2567 }
2568
2569 #[test]
2570 fn test_build_multiple_components_css() {
2571 let app = create_app_dir(&[
2572 ("index.html", "<card-a>A</card-a><card-b>B</card-b>"),
2573 ("card-a.html", "<div><slot></slot></div>"),
2574 ("card-a.css", ".a { color: red; }"),
2575 ("card-b.html", "<span><slot></slot></span>"),
2576 ("card-b.css", ".b { color: blue; }"),
2577 ]);
2578 let result = build(default_options(app.path())).unwrap();
2579
2580 assert_eq!(result.css_files.len(), 2);
2581 assert_eq!(result.stats.css_file_count, 2);
2582 let filenames: Vec<&str> = result.css_files.iter().map(|(n, _)| n.as_str()).collect();
2583 assert!(filenames.contains(&"card-a.css"));
2584 assert!(filenames.contains(&"card-b.css"));
2585 }
2586
2587 #[test]
2588 fn test_build_unused_component_css_not_emitted() {
2589 let app = create_app_dir(&[
2591 ("index.html", "<card-a>A</card-a>"),
2592 ("card-a.html", "<div><slot></slot></div>"),
2593 ("card-a.css", ".a { color: red; }"),
2594 ("card-b.html", "<span><slot></slot></span>"),
2595 ("card-b.css", ".b { color: blue; }"),
2596 ]);
2597 let result = build(default_options(app.path())).unwrap();
2598
2599 assert_eq!(result.css_files.len(), 1);
2600 assert_eq!(result.css_files[0].0, "card-a.css");
2601 }
2602
2603 #[test]
2604 fn test_build_unused_component_render_policy_not_emitted() {
2605 let app = create_app_dir(&[
2606 ("index.html", "<card-a>A</card-a>"),
2607 (
2608 "card-a.html",
2609 r#"<template w-render="lazy" w-reserve-block-size="10px"><slot></slot></template>"#,
2610 ),
2611 (
2612 "card-b.html",
2613 r#"<template w-render="lazy" w-reserve-block-size="20px"><slot></slot></template>"#,
2614 ),
2615 ]);
2616 let result = build(default_options(app.path())).unwrap();
2617
2618 assert!(result.protocol.component_render_css.contains("card-a"));
2619 assert!(!result.protocol.component_render_css.contains("card-b"));
2620 }
2621
2622 #[test]
2623 fn test_inspect_roundtrip_preserves_content() {
2624 let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
2625 let result = build(default_options(app.path())).unwrap();
2626
2627 let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
2628 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
2629
2630 let fragments = &parsed["fragments"]["index.html"]["fragments"];
2631 assert!(fragments.is_array());
2632 assert!(!fragments.as_array().unwrap().is_empty());
2633 }
2634
2635 #[test]
2636 fn test_build_nonexistent_app_dir() {
2637 let options = BuildOptions {
2638 app_dir: PathBuf::from("/nonexistent/path/that/does/not/exist"),
2639 ..BuildOptions::default()
2640 };
2641 let result = build(options);
2642 assert!(result.is_err());
2643 }
2644
2645 #[test]
2646 fn test_build_allows_fallback_chain_when_all_theme_tokens_exist() {
2647 let app = create_app_dir(&[
2648 ("index.html", "<my-card></my-card>"),
2649 ("my-card.html", "<div>Card</div>"),
2650 (
2651 "my-card.css",
2652 ":host { color: var(--token-a, var(--token-b, var(--token-c))); }",
2653 ),
2654 ]);
2655 let mut options = default_options(app.path());
2656 options.theme = Some(TokenFile {
2657 themes: HashMap::from([(
2658 "light".to_string(),
2659 HashMap::from([
2660 ("token-a".to_string(), "red".to_string()),
2661 ("token-b".to_string(), "blue".to_string()),
2662 ("token-c".to_string(), "green".to_string()),
2663 ]),
2664 )]),
2665 });
2666
2667 let result = build(options).expect("all fallback tokens are defined");
2668 assert!(result.protocol.tokens.contains(&"token-a".to_string()));
2669 assert!(result.protocol.tokens.contains(&"token-b".to_string()));
2670 assert!(result.protocol.tokens.contains(&"token-c".to_string()));
2671 }
2672
2673 #[test]
2674 fn test_build_allows_literal_fallback_token_absent_from_theme() {
2675 let app = create_app_dir(&[
2676 ("index.html", "<my-card></my-card>"),
2677 ("my-card.html", "<div>Card</div>"),
2678 ("my-card.css", ":host { color: var(--brand, #000); }"),
2679 ]);
2680 let mut options = default_options(app.path());
2681 options.theme = Some(TokenFile {
2682 themes: HashMap::from([("light".to_string(), HashMap::new())]),
2683 });
2684
2685 let result = build(options).expect("literal fallback should not fail the build");
2689 assert!(result.protocol.tokens.contains(&"brand".to_string()));
2690 assert_eq!(result.warnings.len(), 1, "warnings: {:?}", result.warnings);
2693 assert!(result.warnings[0].body().contains("--brand"));
2694 }
2695
2696 #[test]
2697 fn test_build_ancestor_inline_style_definition_after_comment_satisfies_descendant() {
2698 let app = create_app_dir(&[
2703 (
2704 "index.html",
2705 "<html><head><style>:root {\n /*{{{tokens.light}}}*/\n --foo-bar: 100px;\n}</style></head><body><my-card></my-card></body></html>",
2706 ),
2707 ("my-card.html", "<div>Card</div>"),
2708 ("my-card.css", ":host { padding: var(--foo-bar); }"),
2709 ]);
2710 let mut options = default_options(app.path());
2711 options.theme = Some(TokenFile {
2712 themes: HashMap::from([("light".to_string(), HashMap::new())]),
2713 });
2714
2715 let result = build(options)
2716 .expect("ancestor inline-style definition (after a comment) should satisfy the child");
2717 assert!(
2718 !result.protocol.tokens.contains(&"foo-bar".to_string()),
2719 "ancestor-defined token should not be hoisted: {:?}",
2720 result.protocol.tokens
2721 );
2722 }
2723
2724 #[test]
2725 fn test_build_no_warning_when_literal_fallback_token_is_themed() {
2726 let app = create_app_dir(&[
2727 ("index.html", "<my-card></my-card>"),
2728 ("my-card.html", "<div>Card</div>"),
2729 ("my-card.css", ":host { color: var(--brand, #000); }"),
2730 ]);
2731 let mut options = default_options(app.path());
2732 options.theme = Some(TokenFile {
2733 themes: HashMap::from([(
2734 "light".to_string(),
2735 HashMap::from([("brand".to_string(), "#123456".to_string())]),
2736 )]),
2737 });
2738
2739 let result = build(options).expect("themed literal-fallback token builds");
2740 assert!(
2741 result.warnings.is_empty(),
2742 "a themed token must not warn: {:?}",
2743 result.warnings
2744 );
2745 }
2746
2747 #[test]
2748 fn test_build_without_theme_produces_no_warnings() {
2749 let app = create_app_dir(&[
2750 ("index.html", "<my-card></my-card>"),
2751 ("my-card.html", "<div>Card</div>"),
2752 ("my-card.css", ":host { color: var(--brand, #000); }"),
2753 ]);
2754 let result = build(default_options(app.path())).unwrap();
2755 assert!(result.warnings.is_empty());
2756 }
2757
2758 #[test]
2759 fn test_build_excludes_tokens_defined_by_ancestor_component_css() {
2760 let app = create_app_dir(&[
2761 ("index.html", "<component-a></component-a>"),
2762 ("component-a.html", "<component-b></component-b>"),
2763 ("component-a.css", ":host { --brand-color: red; }"),
2764 ("component-b.html", "<div>Child</div>"),
2765 ("component-b.css", ":host { color: var(--brand-color); }"),
2766 ]);
2767 let mut options = default_options(app.path());
2768 options.theme = Some(TokenFile {
2769 themes: HashMap::from([("light".to_string(), HashMap::new())]),
2770 });
2771
2772 let result =
2773 build(options).expect("ancestor component custom property should satisfy child");
2774 assert!(
2775 !result.protocol.tokens.contains(&"brand-color".to_string()),
2776 "ancestor-defined token should not be exposed: {:?}",
2777 result.protocol.tokens
2778 );
2779 }
2780
2781 #[test]
2782 fn test_build_does_not_use_sibling_component_definitions_for_theme_validation() {
2783 let app = create_app_dir(&[
2784 (
2785 "index.html",
2786 "<component-a></component-a><component-b></component-b>",
2787 ),
2788 ("component-a.html", "<div>A</div>"),
2789 ("component-a.css", ":host { --brand-color: red; }"),
2790 ("component-b.html", "<div>B</div>"),
2791 ("component-b.css", ":host { color: var(--brand-color); }"),
2792 ]);
2793 let mut options = default_options(app.path());
2794 options.theme = Some(TokenFile {
2795 themes: HashMap::from([("light".to_string(), HashMap::new())]),
2796 });
2797
2798 let Err(err) = build(options) else {
2799 panic!("sibling custom property must not satisfy component-b");
2800 };
2801 let message = err.chain_message();
2802 assert!(message.contains("missing theme token"), "msg: {message}");
2803 assert!(message.contains("missing-theme-token"), "msg: {message}");
2804 assert!(message.contains("--brand-color"), "msg: {message}");
2805 }
2806
2807 #[test]
2808 fn test_build_rejects_missing_theme_token_after_local_definition_filter() {
2809 let app = create_app_dir(&[
2810 ("index.html", "<my-card></my-card>"),
2811 ("my-card.html", "<div>Card</div>"),
2812 (
2813 "my-card.css",
2814 ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c))); }",
2815 ),
2816 ]);
2817 let mut options = default_options(app.path());
2818 options.theme = Some(TokenFile {
2819 themes: HashMap::from([(
2820 "light".to_string(),
2821 HashMap::from([("token-b".to_string(), "green".to_string())]),
2822 )]),
2823 });
2824
2825 let Err(err) = build(options) else {
2826 panic!("missing --token-c in the theme must fail the build");
2827 };
2828 let message = err.chain_message();
2829 assert!(message.contains("missing-theme-token"), "msg: {message}");
2830 assert!(message.contains("--token-c"), "msg: {message}");
2831 assert!(!message.contains("add --token-a"), "msg: {message}");
2834 }
2835
2836 #[test]
2837 fn test_build_to_disk_returns_accurate_stats() {
2838 let app = create_app_dir(&[
2839 ("index.html", "<my-card>Hello</my-card><p>{{name}}</p>"),
2840 ("my-card.html", "<div><slot></slot></div>"),
2841 ("my-card.css", ".card { color: red; }"),
2842 ]);
2843 let out = TempDir::new().unwrap();
2844
2845 let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
2846
2847 assert!(stats.fragment_count > 0);
2848 assert_eq!(stats.css_file_count, 1);
2849 assert!(stats.component_count > 0);
2850 assert!(stats.protocol_size_bytes > 0);
2851 assert!(!stats.duration.is_zero());
2852 }
2853}