1mod chunking;
24mod component_assets;
25mod error;
26mod module_preload;
27mod projection;
28pub mod server;
29pub mod streaming;
30mod style_bundle;
31
32pub use component_assets::{render_component_assets, ComponentAssetFile, ComponentAssetGraph};
33pub use error::WebUIError;
34
35pub use webui_handler::route_handler::{encode_inventory, get_needed_components, parse_inventory};
37pub use webui_handler::Result as HandlerResult;
38pub use webui_handler::{
39 plugin::HandlerPlugin, BoundaryDescriptor, BoundaryInstanceId, BoundaryKey, BoundaryMode,
40 FlushWriter, HandlerError, Protocol, RenderOptions, ResponseWriter, SessionOptions,
41 SpanInstanceId, StreamStatus, StreamStep, StreamingResponse, StreamingSession, StreamingState,
42 WebUIHandler,
43};
44pub use webui_parser::plugin::{ComponentTemplateArtifact, StateSurface};
45pub use webui_parser::CssStrategy;
46pub use webui_parser::DomStrategy;
47pub use webui_parser::LegalComments;
48pub use webui_parser::ParserError;
49pub use webui_parser::ParserOptions;
50pub use webui_parser::Plugin;
51pub use webui_parser::DEFAULT_CSS_FILE_NAME_TEMPLATE;
52pub use webui_parser::{
53 AssetFileNameTemplate, AssetFileNameTemplateError, DEFAULT_ASSET_FILE_NAME_TEMPLATE,
54};
55pub use webui_parser::{CssFallbackChain, CssTokenAnalysis};
56pub use webui_parser::{Diagnostic, Severity};
57pub use webui_protocol::WebUIProtocol;
58pub use webui_tokens::{load_token_file, resolve_theme_path, TokenFile};
59
60use std::collections::HashSet;
61use std::ffi::{OsStr, OsString};
62use std::fs;
63use std::path::Path;
64use std::time::Instant;
65use webui_discovery::{DiscoveryPlugin, FastDiscoveryPlugin, WebUIDiscoveryPlugin};
66use webui_parser::plugin::fast_v2::FastV2ParserPlugin;
67use webui_parser::plugin::fast_v3::FastV3ParserPlugin;
68use webui_parser::plugin::webui::WebUIParserPlugin;
69use webui_parser::plugin::ParserPluginArtifacts;
70use webui_parser::HtmlParser;
71
72#[derive(Debug, Clone)]
74pub struct PreparedProjectionManifests {
75 snapshot: std::sync::Arc<projection::ProjectionSnapshot>,
76}
77
78#[derive(Debug)]
79enum ProjectionBarrierState {
80 Waiting,
81 Ready(PreparedProjectionManifests),
82 Failed(String),
83}
84
85#[doc(hidden)]
87#[derive(Debug, Clone)]
88pub struct PendingProjectionManifests {
89 state: std::sync::Arc<(std::sync::Mutex<ProjectionBarrierState>, std::sync::Condvar)>,
90}
91
92#[doc(hidden)]
94pub struct ProjectionManifestCompleter {
95 state: std::sync::Arc<(std::sync::Mutex<ProjectionBarrierState>, std::sync::Condvar)>,
96 completed: bool,
97}
98
99#[derive(Debug, Clone)]
101pub enum ProjectionManifestSource {
102 Path(std::path::PathBuf),
104 Inline {
109 manifest_path: std::path::PathBuf,
111 json: String,
113 },
114 Prepared(PreparedProjectionManifests),
117 #[doc(hidden)]
119 Pending(PendingProjectionManifests),
120}
121
122impl From<std::path::PathBuf> for ProjectionManifestSource {
123 fn from(path: std::path::PathBuf) -> Self {
124 Self::Path(path)
125 }
126}
127
128pub fn prepare_projection_manifests(
133 sources: &[ProjectionManifestSource],
134) -> Result<PreparedProjectionManifests, WebUIError> {
135 let snapshot = projection::load_and_merge(sources)?
136 .unwrap_or_else(|| std::sync::Arc::new(projection::ProjectionSnapshot::default()));
137 Ok(PreparedProjectionManifests { snapshot })
138}
139
140#[doc(hidden)]
147#[must_use]
148pub fn resolve_generated_module_preloads(
149 projection: &PreparedProjectionManifests,
150 existing_hrefs: &[String],
151 entry_outputs: &[&Path],
152 output_urls: &std::collections::BTreeMap<std::path::PathBuf, String>,
153) -> (Vec<String>, Vec<Diagnostic>) {
154 let mut resolved = module_preload::resolve_exact(
155 &projection.snapshot.entry_closures,
156 existing_hrefs,
157 entry_outputs,
158 output_urls,
159 );
160 (
161 std::mem::take(&mut resolved.hrefs),
162 std::mem::take(&mut resolved.warnings),
163 )
164}
165
166#[doc(hidden)]
168#[must_use]
169pub fn generated_module_preload_outputs(
170 projection: &PreparedProjectionManifests,
171 entry_outputs: &[&Path],
172) -> Vec<std::path::PathBuf> {
173 module_preload::exact_output_identities(&projection.snapshot.entry_closures, entry_outputs)
174}
175
176#[doc(hidden)]
178#[must_use]
179pub fn projection_manifest_barrier() -> (ProjectionManifestSource, ProjectionManifestCompleter) {
180 let state = std::sync::Arc::new((
181 std::sync::Mutex::new(ProjectionBarrierState::Waiting),
182 std::sync::Condvar::new(),
183 ));
184 (
185 ProjectionManifestSource::Pending(PendingProjectionManifests {
186 state: std::sync::Arc::clone(&state),
187 }),
188 ProjectionManifestCompleter {
189 state,
190 completed: false,
191 },
192 )
193}
194
195impl PendingProjectionManifests {
196 fn wait(&self) -> Result<PreparedProjectionManifests, WebUIError> {
197 let (lock, ready) = self.state.as_ref();
198 let mut state = lock.lock().map_err(|_| {
199 WebUIError::InvalidBuildOptions("projection synchronization lock poisoned".to_string())
200 })?;
201 loop {
202 match &*state {
203 ProjectionBarrierState::Waiting => {
204 state = ready.wait(state).map_err(|_| {
205 WebUIError::InvalidBuildOptions(
206 "projection synchronization lock poisoned".to_string(),
207 )
208 })?;
209 }
210 ProjectionBarrierState::Ready(projection) => {
211 return Ok(projection.clone());
212 }
213 ProjectionBarrierState::Failed(message) => {
214 return Err(WebUIError::InvalidBuildOptions(message.clone()));
215 }
216 }
217 }
218 }
219}
220
221impl ProjectionManifestCompleter {
222 pub fn complete(mut self, result: std::result::Result<PreparedProjectionManifests, String>) {
224 let (lock, ready) = self.state.as_ref();
225 if let Ok(mut state) = lock.lock() {
226 *state = match result {
227 Ok(projection) => ProjectionBarrierState::Ready(projection),
228 Err(message) => ProjectionBarrierState::Failed(message),
229 };
230 self.completed = true;
231 ready.notify_all();
232 }
233 }
234}
235
236impl Drop for ProjectionManifestCompleter {
237 fn drop(&mut self) {
238 if self.completed {
239 return;
240 }
241 let (lock, ready) = self.state.as_ref();
242 if let Ok(mut state) = lock.lock() {
243 *state = ProjectionBarrierState::Failed(
244 "projection producer terminated before completing metadata".to_string(),
245 );
246 ready.notify_all();
247 }
248 }
249}
250
251#[derive(Debug, Clone)]
253pub struct BuildOptions {
254 pub app_dir: std::path::PathBuf,
256 pub entry: String,
258 pub css: CssStrategy,
260 pub dom: DomStrategy,
262 pub css_bundle: bool,
269 pub plugin: Option<Plugin>,
271 pub components: Vec<String>,
273 pub component_asset_roots: Vec<String>,
279 pub metafile: bool,
281 pub css_file_name_template: String,
285 pub css_public_base: Option<String>,
288 pub legal_comments: LegalComments,
290 pub theme: Option<TokenFile>,
298 pub projection_manifests: Vec<ProjectionManifestSource>,
319}
320
321impl Default for BuildOptions {
322 fn default() -> Self {
323 Self {
324 app_dir: std::path::PathBuf::from("."),
325 entry: "index.html".to_string(),
326 css: CssStrategy::Link,
327 dom: DomStrategy::Shadow,
328 css_bundle: false,
329 plugin: None,
330 components: Vec::new(),
331 component_asset_roots: Vec::new(),
332 metafile: false,
333 css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
334 css_public_base: None,
335 legal_comments: LegalComments::default(),
336 theme: None,
337 projection_manifests: Vec::new(),
338 }
339 }
340}
341
342#[derive(Debug, Clone)]
344pub struct BuildStats {
345 pub duration: std::time::Duration,
347 pub fragment_count: usize,
349 pub component_count: usize,
351 pub css_file_count: usize,
353 pub protocol_size_bytes: usize,
355 pub token_count: usize,
357}
358
359#[derive(Debug)]
361pub struct BuildResult {
362 pub protocol: WebUIProtocol,
364 pub protocol_bytes: Vec<u8>,
366 pub css_files: Vec<(String, String)>,
368 pub component_asset_files: Vec<ComponentAssetFile>,
372 pub metafile: Option<String>,
374 pub component_templates: Vec<ComponentTemplateArtifact>,
378 pub warnings: Vec<Diagnostic>,
385 pub stats: BuildStats,
387}
388
389#[cold]
394#[inline(never)]
395fn streaming_without_projection_warning(entry: &str, boundary_count: usize) -> Diagnostic {
396 let plural = if boundary_count == 1 { "" } else { "s" };
397 Diagnostic::warning(format!(
398 "{entry} declares {boundary_count} streaming boundary checkpoint{plural} \
399 but this build has no state-projection manifest"
400 ))
401 .code(webui_parser::codes::STREAMING_WITHOUT_PROJECTION)
402 .help(
403 "every checkpoint will serialize the whole state object instead of its own \
404 components' keys; pass the client build's `webui-projection.json` through \
405 `BuildOptions::projection_manifests` to send boundary-local state only",
406 )
407}
408
409#[must_use = "BuildResult contains the compiled protocol and statistics"]
419pub fn build(options: BuildOptions) -> Result<BuildResult, WebUIError> {
420 let started = Instant::now();
421
422 let raw = build_protocol_inner(&options)?;
423
424 let protocol_bytes = raw.protocol.to_protobuf()?;
425
426 let stats = BuildStats {
427 duration: started.elapsed(),
428 fragment_count: raw.fragment_count,
429 component_count: raw.component_count,
430 css_file_count: raw.css_files.len(),
431 protocol_size_bytes: protocol_bytes.len(),
432 token_count: raw.token_count,
433 };
434
435 Ok(BuildResult {
436 protocol: raw.protocol,
437 protocol_bytes,
438 css_files: raw.css_files,
439 component_asset_files: raw.component_asset_files,
440 metafile: raw.metafile,
441 component_templates: raw.component_templates,
442 warnings: raw.warnings,
443 stats,
444 })
445}
446
447pub fn build_to_disk(options: BuildOptions, out_dir: &Path) -> Result<BuildStats, WebUIError> {
457 let result = build(options)?;
458 validate_output_file_names(OsStr::new("protocol.bin"), &result)?;
459
460 fs::create_dir_all(out_dir).map_err(|source| WebUIError::Io {
461 context: format!("Failed to create {}", out_dir.display()),
462 source,
463 })?;
464
465 fs::write(out_dir.join("protocol.bin"), &result.protocol_bytes).map_err(|source| {
466 WebUIError::Io {
467 context: format!("Failed to write protocol.bin to {}", out_dir.display()),
468 source,
469 }
470 })?;
471
472 for (name, content) in &result.css_files {
473 fs::write(out_dir.join(name), content).map_err(|source| WebUIError::Io {
474 context: format!("Failed to write {name} to {}", out_dir.display()),
475 source,
476 })?;
477 }
478 for file in &result.component_asset_files {
479 fs::write(out_dir.join(&file.name), &file.content).map_err(|source| WebUIError::Io {
480 context: format!(
481 "Failed to write component asset {} to {}",
482 file.name,
483 out_dir.display()
484 ),
485 source,
486 })?;
487 }
488
489 Ok(result.stats)
490}
491
492pub fn inspect(protocol_path: &Path) -> Result<String, WebUIError> {
494 let bytes = fs::read(protocol_path).map_err(|source| WebUIError::Io {
495 context: format!("Failed to read {}", protocol_path.display()),
496 source,
497 })?;
498 inspect_bytes(&bytes)
499}
500
501pub fn inspect_bytes(protocol_bytes: &[u8]) -> Result<String, WebUIError> {
503 let protocol = WebUIProtocol::from_protobuf(protocol_bytes)?;
504 protocol
505 .to_json_pretty()
506 .map_err(|e| WebUIError::Serialization(e.to_string()))
507}
508
509struct RawBuildOutput {
511 protocol: WebUIProtocol,
512 css_files: Vec<(String, String)>,
513 component_asset_files: Vec<ComponentAssetFile>,
514 metafile: Option<String>,
515 component_templates: Vec<ComponentTemplateArtifact>,
516 warnings: Vec<Diagnostic>,
517 fragment_count: usize,
518 component_count: usize,
519 token_count: usize,
520}
521
522fn build_protocol_inner(options: &BuildOptions) -> Result<RawBuildOutput, WebUIError> {
524 let projection_enabled = !options.projection_manifests.is_empty();
525 if projection_enabled && options.plugin != Some(Plugin::WebUI) {
526 return Err(projection::incompatible_plugin_error());
527 }
528 if options.css_bundle && options.css == CssStrategy::Module {
529 return Err(css_bundle_module_error());
530 }
531 let parser_options = ParserOptions::try_new(
532 options.css,
533 options.dom,
534 &options.css_file_name_template,
535 options.css_public_base.as_deref(),
536 options.legal_comments,
537 )
538 .map_err(|e| WebUIError::InvalidBuildOptions(e.to_string()))?;
539 let css_link_options = parser_options.css_link_options.clone();
540
541 let mut parser = match options.plugin {
542 Some(Plugin::Fast | Plugin::FastV2) => {
543 HtmlParser::with_plugin_options(Box::new(FastV2ParserPlugin::new()), parser_options)
544 }
545 Some(Plugin::FastV3) => {
546 HtmlParser::with_plugin_options(Box::new(FastV3ParserPlugin::new()), parser_options)
547 }
548 Some(Plugin::WebUI) => {
549 HtmlParser::with_plugin_options(Box::new(WebUIParserPlugin::new()), parser_options)
550 }
551 None => HtmlParser::with_options(parser_options),
552 };
553 let discovery_plugin: Box<dyn DiscoveryPlugin> = match options.plugin {
554 Some(Plugin::Fast | Plugin::FastV2 | Plugin::FastV3) => {
555 Box::new(FastDiscoveryPlugin::new())
556 }
557 Some(Plugin::WebUI) | None => Box::new(WebUIDiscoveryPlugin::new()),
558 };
559
560 let app_components = discovery_plugin
561 .discover_local(&options.app_dir)
562 .map_err(|error| {
563 WebUIError::ComponentDiscovery(format!(
564 "Failed to discover components from {}: {error}",
565 options.app_dir.display()
566 ))
567 })?;
568 for component in &app_components {
569 parser
570 .component_registry_mut()
571 .register_component(webui_parser::ComponentRegistration {
572 tag_name: &component.tag_name,
573 html_content: &component.html_content,
574 css_content: component.css_content.as_deref(),
575 is_client_owned: component.is_client_owned,
576 })
577 .map_err(|source| WebUIError::ComponentRegistration {
578 context: format!(
579 "Failed to register component '{}' from {}",
580 component.tag_name, component.source
581 ),
582 source,
583 })?;
584 }
585
586 for source in &options.components {
588 let result = webui_discovery::discover_source_with_plugin(
589 source,
590 &options.app_dir,
591 discovery_plugin.as_ref(),
592 )
593 .map_err(|e| {
594 WebUIError::ComponentDiscovery(format!(
595 "Failed to discover components from {source}: {e}"
596 ))
597 })?;
598 for comp in &result.components {
599 parser
602 .component_registry_mut()
603 .register_component(webui_parser::ComponentRegistration {
604 tag_name: &comp.tag_name,
605 html_content: &comp.html_content,
606 css_content: comp.css_content.as_deref(),
607 is_client_owned: comp.is_client_owned,
608 })
609 .map_err(|source| WebUIError::ComponentRegistration {
610 context: format!(
611 "Failed to register component '{}' from {}",
612 comp.tag_name, comp.source
613 ),
614 source,
615 })?;
616 }
617 }
618
619 let component_count = parser.component_registry().len();
620
621 let entry_path = options.app_dir.join(&options.entry);
623 let html_content = fs::read_to_string(&entry_path).map_err(|source| WebUIError::Io {
624 context: format!("Failed to read {}", entry_path.display()),
625 source,
626 })?;
627 parser
628 .parse(&options.entry, &html_content)
629 .map_err(|source| WebUIError::Parse {
630 context: format!("Failed to parse {}", options.entry),
631 source,
632 })?;
633
634 let module_entry_srcs = parser.module_entry_srcs().to_vec();
637
638 let synthetic_asset_fragments =
639 parse_component_asset_roots(&mut parser, &options.component_asset_roots)?;
640
641 let component_render_css = parser.component_registry().render_policy_css(
642 parser
643 .component_registry()
644 .get_all()
645 .filter(|component| parser.has_fragment(&component.tag_name))
646 .map(|component| component.tag_name.as_str()),
647 );
648 let css_snapshot: Vec<(String, String)> = parser
649 .component_registry()
650 .get_all()
651 .filter(|component| parser.has_fragment(&component.tag_name))
652 .filter_map(|component| {
653 component
654 .css_content
655 .as_ref()
656 .map(|css| (component.tag_name.clone(), css.clone()))
657 })
658 .collect();
659 let component_shadow_dom_usage: Vec<(String, bool)> = parser
660 .component_shadow_dom_usage()
661 .map(|(tag_name, uses_shadow_dom)| (tag_name.to_string(), uses_shadow_dom))
662 .collect();
663 let component_work_policies: Vec<(String, u8)> = parser
664 .component_work_policies()
665 .map(|(tag_name, policy)| (tag_name.to_string(), policy))
666 .collect();
667
668 let token_analysis = parser.token_analysis();
670 let mut warnings: Vec<Diagnostic> = Vec::new();
671 if let Some(theme) = options.theme.as_ref() {
672 token_analysis
673 .validate_theme_tokens(theme)
674 .map_err(|source| WebUIError::Parse {
675 context: "Failed to validate theme tokens".to_string(),
676 source,
677 })?;
678 warnings = token_analysis.theme_token_warnings(theme);
683 }
684 let token_count = token_analysis.protocol_tokens.len();
685
686 let component_templates =
687 match parser
688 .take_plugin_artifacts()
689 .map_err(|source| WebUIError::Parse {
690 context: format!(
691 "Failed to compile component templates for {}",
692 options.entry
693 ),
694 source,
695 })? {
696 ParserPluginArtifacts::None => Vec::new(),
697 ParserPluginArtifacts::ComponentTemplates(templates) => templates,
698 };
699 let mut fragment_records = parser.into_fragment_records();
701 for fragment_id in synthetic_asset_fragments {
702 fragment_records.remove(&fragment_id);
703 }
704 let boundary_count = fragment_records
705 .values()
706 .flat_map(|list| list.fragments.iter())
707 .filter(|fragment| {
708 matches!(
709 fragment.fragment.as_ref(),
710 Some(webui_protocol::web_ui_fragment::Fragment::Boundary(boundary))
711 if boundary.phase() == webui_protocol::BoundaryPhase::Start
712 )
713 })
714 .count();
715 let merged_manifest = projection::load_and_merge(&options.projection_manifests)?;
719 let mut protocol = WebUIProtocol::with_tokens(fragment_records, token_analysis.protocol_tokens);
720 protocol.initial_state_strategy = if merged_manifest.is_some() {
721 webui_protocol::InitialStateStrategy::Components as i32
722 } else {
723 webui_protocol::InitialStateStrategy::Full as i32
724 };
725
726 if boundary_count > 0 && merged_manifest.is_none() {
731 warnings.push(streaming_without_projection_warning(
732 &options.entry,
733 boundary_count,
734 ));
735 }
736
737 if let Some(merged) = &merged_manifest {
742 let mut preloads = module_preload::resolve(&merged.entry_closures, &module_entry_srcs);
743 protocol.module_preloads = std::mem::take(&mut preloads.hrefs);
744 warnings.append(&mut preloads.warnings);
745 }
746
747 protocol.set_css_strategy(match options.css {
749 CssStrategy::Link => webui_protocol::CssStrategy::Link,
750 CssStrategy::Style => webui_protocol::CssStrategy::Style,
751 CssStrategy::Module => webui_protocol::CssStrategy::Module,
752 });
753 for (tag_name, uses_shadow_dom) in component_shadow_dom_usage {
754 if !protocol.fragments.contains_key(&tag_name) {
755 continue;
756 }
757 protocol
758 .components
759 .entry(tag_name)
760 .or_default()
761 .uses_shadow_dom = uses_shadow_dom;
762 }
763 for (tag_name, work_policy) in component_work_policies {
764 if protocol.fragments.contains_key(&tag_name) {
765 protocol.components.entry(tag_name).or_default().work_policy = i32::from(work_policy);
766 }
767 }
768 protocol.component_render_css = component_render_css;
769
770 let stores_css = options.css != CssStrategy::Link;
773 let mut css_files: Vec<(String, String)> = Vec::new();
774 let mut emitted_names: HashSet<String> = HashSet::new();
775 let mut bundle_inputs: Vec<(String, String)> = Vec::new();
776 for (tag, css) in css_snapshot {
777 if !protocol.fragments.contains_key(&tag) {
778 continue;
779 }
780 if options.css_bundle {
781 bundle_inputs.push((tag.clone(), css.clone()));
782 }
783 if stores_css {
784 protocol.components.entry(tag).or_default().css = css;
785 } else {
786 let resolved = css_link_options.resolve(&tag, &css);
787 if !emitted_names.insert(resolved.filename.clone()) {
788 return Err(WebUIError::InvalidBuildOptions(format!(
789 "CSS filename collision for Link strategy: '{}'. Adjust the asset filename template to include [name] or another unique component-specific segment.",
790 resolved.filename
791 )));
792 }
793 protocol.components.entry(tag).or_default().css_href = resolved.href;
794 css_files.push((resolved.filename, css));
795 }
796 }
797
798 protocol.populate_style_closures(&[options.entry.as_str()]);
801
802 if options.css_bundle {
803 let by_tag = bundle_inputs
806 .iter()
807 .map(|(tag, css)| (tag.as_str(), css.as_str()))
808 .collect();
809 let chunk_files = style_bundle::bundle_style_chunks(
810 &mut protocol,
811 options.entry.as_str(),
812 &by_tag,
813 &css_link_options,
814 )?;
815 if !stores_css {
816 for (filename, css) in chunk_files {
820 if emitted_names.insert(filename.clone()) {
821 css_files.push((filename, css));
822 continue;
823 }
824 let identical = css_files
827 .iter()
828 .any(|(name, existing)| *name == filename && *existing == css);
829 if !identical {
830 return Err(WebUIError::InvalidBuildOptions(format!(
831 "CSS filename collision between bundled chunk '{filename}' and a component stylesheet of the same name. Adjust the asset filename template to include [hash] or another unique segment."
832 )));
833 }
834 }
835 }
836 }
837
838 for artifact in &component_templates {
842 let component = protocol
843 .components
844 .entry(artifact.tag_name.clone())
845 .or_default();
846 component.template = artifact.template.clone();
847 component.template_json = artifact.template_json.clone();
848 component.template_functions = artifact.template_functions.clone();
849 let manifest_entry = if artifact.is_scripted {
854 merged_manifest
855 .as_ref()
856 .and_then(|merged| merged.components.get(&artifact.tag_name))
857 } else {
858 None
859 };
860 let (hydration_mode, hydration_keys) = match manifest_entry {
861 Some(entry) => encode_state_surface(&StateSurface::Keys(entry.hydration_keys.clone())),
862 None => encode_state_surface(&artifact.hydration),
863 };
864 component.hydration_mode = hydration_mode;
865 component.hydration_keys = hydration_keys;
866 let (navigation_mode, navigation_keys) = match manifest_entry {
867 Some(entry) => {
868 let union =
869 projection::union_keys(&entry.navigation_keys, &artifact.template_roots);
870 encode_state_surface(&StateSurface::Keys(union))
871 }
872 None => encode_state_surface(&artifact.navigation),
873 };
874 component.navigation_mode = Some(navigation_mode);
875 component.navigation_keys = navigation_keys;
876 }
877
878 let mut component_asset_graph = component_assets::render_component_assets(
879 &protocol,
880 &options.entry,
881 &options.component_asset_roots,
882 &options.css_file_name_template,
883 options.metafile,
884 )?;
885 component_asset_graph.retain_entry_protocol(&mut protocol)?;
886 if let Some(merged) = &merged_manifest {
890 let entry_scripted_tags: Vec<&str> = component_templates
891 .iter()
892 .filter(|artifact| {
893 artifact.is_scripted && protocol.fragments.contains_key(&artifact.tag_name)
894 })
895 .map(|artifact| artifact.tag_name.as_str())
896 .collect();
897 projection::validate_coverage(&merged.components, &entry_scripted_tags)?;
898 }
899 let fragment_count = protocol
900 .fragments
901 .values()
902 .map(|fragments| fragments.fragments.len())
903 .sum();
904 let component_asset_files = component_asset_graph.files;
905 validate_generated_file_names(&css_files, &component_asset_files)?;
906
907 Ok(RawBuildOutput {
908 protocol,
909 css_files,
910 component_asset_files,
911 metafile: component_asset_graph.metafile,
912 component_templates,
913 warnings,
914 fragment_count,
915 component_count,
916 token_count,
917 })
918}
919
920fn encode_state_surface(surface: &StateSurface) -> (i32, Vec<String>) {
921 match surface {
922 StateSurface::None => (webui_protocol::StateProjectionMode::None as i32, Vec::new()),
923 StateSurface::Keys(keys) => (
924 webui_protocol::StateProjectionMode::Keys as i32,
925 keys.clone(),
926 ),
927 StateSurface::All => (webui_protocol::StateProjectionMode::All as i32, Vec::new()),
928 }
929}
930
931fn parse_component_asset_roots(
932 parser: &mut HtmlParser,
933 roots: &[String],
934) -> Result<Vec<String>, WebUIError> {
935 let mut synthetic_fragments = Vec::with_capacity(roots.len());
936 for (index, root) in roots.iter().enumerate() {
937 let root = root.trim();
938 if parser.has_fragment(root) {
939 continue;
940 }
941 let mut synthetic = String::with_capacity(root.len() * 2 + 5);
944 synthetic.push('<');
945 synthetic.push_str(root);
946 synthetic.push_str("></");
947 synthetic.push_str(root);
948 synthetic.push('>');
949
950 let fragment_id = format!("__webui_asset_root_{index}");
951 parser
952 .parse(&fragment_id, &synthetic)
953 .map_err(|source| WebUIError::Parse {
954 context: format!("Failed to parse component asset root <{root}>"),
955 source,
956 })?;
957 synthetic_fragments.push(fragment_id);
958 }
959 Ok(synthetic_fragments)
960}
961
962fn validate_generated_file_names(
963 css_files: &[(String, String)],
964 component_asset_files: &[ComponentAssetFile],
965) -> Result<(), WebUIError> {
966 let mut names = HashSet::with_capacity(css_files.len() + component_asset_files.len());
967 for (name, _) in css_files {
968 names.insert(name.as_str());
969 }
970 for file in component_asset_files {
971 if !names.insert(file.name.as_str()) {
972 return Err(WebUIError::InvalidBuildOptions(format!(
973 "output filename collision for '{}'. Adjust the asset filename template to include [ext] or another unique asset-type segment.",
974 file.name
975 )));
976 }
977 }
978 Ok(())
979}
980
981fn validate_output_file_names(
982 protocol_name: &OsStr,
983 result: &BuildResult,
984) -> Result<(), WebUIError> {
985 let mut names =
986 HashSet::with_capacity(1 + result.css_files.len() + result.component_asset_files.len());
987 names.insert(protocol_name.to_os_string());
988 for (name, _) in &result.css_files {
989 let file_name = OsString::from(name);
990 if !names.insert(file_name.clone()) {
991 return Err(output_file_collision_error(&file_name));
992 }
993 }
994 for file in &result.component_asset_files {
995 let file_name = OsString::from(&file.name);
996 if !names.insert(file_name.clone()) {
997 return Err(output_file_collision_error(&file_name));
998 }
999 }
1000 Ok(())
1001}
1002
1003fn output_file_collision_error(name: &OsStr) -> WebUIError {
1004 WebUIError::InvalidBuildOptions(format!(
1005 "output filename collision for '{}'. Adjust the asset filename template to include [ext] or another unique asset-type segment.",
1006 name.to_string_lossy()
1007 ))
1008}
1009
1010#[cold]
1011#[inline(never)]
1012fn css_bundle_module_error() -> WebUIError {
1013 WebUIError::InvalidBuildOptions(
1014 "CSS bundling is not supported with the Module strategy. Module builds already inline every stylesheet as a data URI, so there are no requests to merge, and their import-map specifiers are resolved per component before chunks exist. Use --css link (or --css style) with --css-bundle, or drop --css-bundle.".to_string(),
1015 )
1016}
1017
1018#[cfg(test)]
1019#[allow(clippy::disallowed_methods)]
1020mod tests {
1021 use super::*;
1022 use std::collections::HashMap;
1023 use std::path::PathBuf;
1024 use tempfile::TempDir;
1025 use webui_handler::plugin::webui::WebUIHydrationPlugin;
1026 use webui_protocol::web_ui_fragment::Fragment;
1027
1028 struct StringWriter {
1029 buf: String,
1030 }
1031
1032 impl ResponseWriter for StringWriter {
1033 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
1034 self.buf.push_str(content);
1035 Ok(())
1036 }
1037
1038 fn end(&mut self) -> webui_handler::Result<()> {
1039 Ok(())
1040 }
1041 }
1042
1043 fn create_app_dir(files: &[(&str, &str)]) -> TempDir {
1044 let dir = TempDir::new().unwrap();
1045 for (name, content) in files {
1046 let path = dir.path().join(name);
1047 if let Some(parent) = path.parent() {
1048 fs::create_dir_all(parent).unwrap();
1049 }
1050 fs::write(&path, content).unwrap();
1051 }
1052 dir
1053 }
1054
1055 fn default_options(app_dir: &Path) -> BuildOptions {
1056 BuildOptions {
1057 app_dir: app_dir.to_path_buf(),
1058 ..BuildOptions::default()
1059 }
1060 }
1061
1062 fn light_options(app_dir: &Path) -> BuildOptions {
1063 BuildOptions {
1064 dom: DomStrategy::Light,
1065 ..default_options(app_dir)
1066 }
1067 }
1068
1069 mod fast;
1070
1071 #[test]
1072 fn test_build_simple_html() {
1073 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
1074 let result = build(default_options(app.path())).unwrap();
1075
1076 assert!(result.protocol.fragments.contains_key("index.html"));
1077 assert!(result.stats.fragment_count > 0);
1078 assert!(result.stats.protocol_size_bytes > 0);
1079 assert!(!result.stats.duration.is_zero());
1080 assert_eq!(
1081 result.protocol.initial_state_strategy,
1082 webui_protocol::InitialStateStrategy::Full as i32
1083 );
1084 }
1085
1086 #[test]
1087 fn light_build_uses_light_unless_component_authors_shadow() {
1088 let app = create_app_dir(&[
1089 (
1090 "index.html",
1091 "<my-card></my-card><shadow-card></shadow-card>",
1092 ),
1093 ("my-card.html", "<p>content</p>"),
1094 (
1095 "shadow-card.html",
1096 r#"<template shadowrootmode="open"><p>shadow</p></template>"#,
1097 ),
1098 ]);
1099
1100 let result = build(BuildOptions {
1101 app_dir: app.path().to_path_buf(),
1102 dom: DomStrategy::Light,
1103 ..BuildOptions::default()
1104 })
1105 .unwrap();
1106 assert!(!result.protocol.component_uses_shadow_dom("my-card"));
1107 assert!(result.protocol.component_uses_shadow_dom("shadow-card"));
1108 let light_html: String = result.protocol.fragments["my-card"]
1109 .fragments
1110 .iter()
1111 .filter_map(|fragment| match fragment.fragment.as_ref() {
1112 Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
1113 _ => None,
1114 })
1115 .collect();
1116 assert!(!light_html.contains("shadowrootmode"));
1117
1118 let shadow_html: String = result.protocol.fragments["shadow-card"]
1119 .fragments
1120 .iter()
1121 .filter_map(|fragment| match fragment.fragment.as_ref() {
1122 Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
1123 _ => None,
1124 })
1125 .collect();
1126 assert!(shadow_html.contains(r#"<template shadowrootmode="open">"#));
1127 }
1128
1129 #[test]
1130 fn build_defaults_unwrapped_components_to_shadow() {
1131 let app = create_app_dir(&[
1132 ("index.html", "<my-card></my-card>"),
1133 ("my-card.html", "<p>content</p>"),
1134 ]);
1135
1136 let result = build(default_options(app.path())).unwrap();
1137 assert!(result.protocol.component_uses_shadow_dom("my-card"));
1138 let html: String = result.protocol.fragments["my-card"]
1139 .fragments
1140 .iter()
1141 .filter_map(|fragment| match fragment.fragment.as_ref() {
1142 Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
1143 _ => None,
1144 })
1145 .collect();
1146 assert!(html.contains(r#"<template shadowrootmode="open">"#));
1147 }
1148
1149 #[test]
1150 fn build_rejects_slot_in_unwrapped_light_component() {
1151 let app = create_app_dir(&[
1152 ("index.html", "<my-card></my-card>"),
1153 ("my-card.html", "<div><slot></slot></div>"),
1154 ]);
1155
1156 let error = build(BuildOptions {
1157 app_dir: app.path().to_path_buf(),
1158 dom: DomStrategy::Light,
1159 ..BuildOptions::default()
1160 })
1161 .expect_err("unwrapped Light DOM slot must fail the build");
1162
1163 assert!(
1164 error.chain_message().contains("[light-dom-slot]"),
1165 "unexpected build error: {}",
1166 error.chain_message()
1167 );
1168 }
1169
1170 #[test]
1171 fn pending_projection_unblocks_after_template_compilation() {
1172 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
1173 let (source, completer) = projection_manifest_barrier();
1174 let options = BuildOptions {
1175 app_dir: app.path().to_path_buf(),
1176 plugin: Some(Plugin::WebUI),
1177 projection_manifests: vec![source],
1178 ..BuildOptions::default()
1179 };
1180 let handle = std::thread::spawn(move || build(options));
1181 let prepared = prepare_projection_manifests(&[]).unwrap();
1182 completer.complete(Ok(prepared));
1183 let result = handle.join().unwrap().unwrap();
1184 assert_eq!(
1185 result.protocol.initial_state_strategy,
1186 webui_protocol::InitialStateStrategy::Components as i32
1187 );
1188 }
1189
1190 #[test]
1193 fn streaming_entry_without_projection_manifest_warns() {
1194 let app = create_app_dir(&[(
1195 "index.html",
1196 concat!(
1197 "<html><body>",
1198 r#"<boundary name="a"><p>{{one}}</p></boundary>"#,
1199 r#"<boundary name="b"><p>{{two}}</p></boundary>"#,
1200 "</body></html>",
1201 ),
1202 )]);
1203 let mut options = default_options(app.path());
1204 options.plugin = Some(Plugin::WebUI);
1205
1206 let result = build(options).unwrap();
1207
1208 assert_eq!(
1209 result.protocol.initial_state_strategy,
1210 webui_protocol::InitialStateStrategy::Full as i32
1211 );
1212 let boundary_names = |protocol: &WebUIProtocol| {
1213 protocol.fragments["index.html"]
1214 .fragments
1215 .iter()
1216 .filter_map(|fragment| match fragment.fragment.as_ref() {
1217 Some(webui_protocol::web_ui_fragment::Fragment::Boundary(boundary))
1218 if boundary.phase() == webui_protocol::BoundaryPhase::Start =>
1219 {
1220 Some(boundary.name.clone())
1221 }
1222 _ => None,
1223 })
1224 .collect::<Vec<_>>()
1225 };
1226 assert_eq!(boundary_names(&result.protocol), ["a", "b"]);
1227 let decoded = WebUIProtocol::from_protobuf(&result.protocol_bytes).unwrap();
1228 assert_eq!(boundary_names(&decoded), ["a", "b"]);
1229 let warning = result
1230 .warnings
1231 .iter()
1232 .find(|diag| {
1233 diag.error_code() == Some(webui_parser::codes::STREAMING_WITHOUT_PROJECTION)
1234 })
1235 .expect("a streaming build without projection must warn");
1236 assert_eq!(warning.severity(), webui_parser::Severity::Warning);
1237 assert!(
1238 warning.to_string().contains("2 streaming boundary"),
1239 "warning should report the boundary count: {warning}"
1240 );
1241 assert!(
1242 warning
1243 .help_text()
1244 .is_some_and(|help| help.contains("projection_manifests")),
1245 "warning should point at the fix: {warning}"
1246 );
1247 }
1248
1249 #[test]
1250 fn streaming_entry_with_projection_manifest_does_not_warn() {
1251 let app = create_app_dir(&[(
1252 "index.html",
1253 concat!(
1254 "<html><body>",
1255 r#"<boundary name="a"><p>{{one}}</p></boundary>"#,
1256 "</body></html>",
1257 ),
1258 )]);
1259 let manifest_json =
1260 projection::test_support::build_valid_manifest_json(app.path(), &[], &[], &[]);
1261 let manifest = projection::test_support::write_manifest(
1262 app.path(),
1263 "webui-projection.json",
1264 &manifest_json,
1265 );
1266 let mut options = default_options(app.path());
1267 options.plugin = Some(Plugin::WebUI);
1268 options.projection_manifests = vec![manifest.into()];
1269
1270 let result = build(options).unwrap();
1271
1272 assert_eq!(
1273 result.protocol.initial_state_strategy,
1274 webui_protocol::InitialStateStrategy::Components as i32
1275 );
1276 assert!(
1277 !result
1278 .warnings
1279 .iter()
1280 .any(|diag| diag.error_code()
1281 == Some(webui_parser::codes::STREAMING_WITHOUT_PROJECTION)),
1282 "a projected streaming build must not warn"
1283 );
1284 }
1285
1286 #[test]
1289 fn non_streaming_entry_without_projection_manifest_does_not_warn() {
1290 let app = create_app_dir(&[("index.html", "<html><body><p>{{one}}</p></body></html>")]);
1291 let mut options = default_options(app.path());
1292 options.plugin = Some(Plugin::WebUI);
1293
1294 let result = build(options).unwrap();
1295
1296 assert!(
1297 !result
1298 .warnings
1299 .iter()
1300 .any(|diag| diag.error_code()
1301 == Some(webui_parser::codes::STREAMING_WITHOUT_PROJECTION)),
1302 "a non-streaming build must not warn about projection"
1303 );
1304 }
1305
1306 #[test]
1307 fn test_build_keeps_scriptless_component_dormant_until_client_use() {
1308 let app = create_app_dir(&[
1309 (
1310 "index.html",
1311 "<html><body><demo-card></demo-card></body></html>",
1312 ),
1313 ("demo-card.html", "<p>{{name}} {{serverTitle}}</p>"),
1314 ]);
1315 let manifest_json =
1320 projection::test_support::build_valid_manifest_json(app.path(), &[], &[], &[]);
1321 let manifest = projection::test_support::write_manifest(
1322 app.path(),
1323 "webui-projection.json",
1324 &manifest_json,
1325 );
1326 let mut options = default_options(app.path());
1327 options.plugin = Some(Plugin::WebUI);
1328 options.projection_manifests = vec![manifest.into()];
1329 let result = build(options).unwrap();
1330 assert_eq!(
1331 result.protocol.initial_state_strategy,
1332 webui_protocol::InitialStateStrategy::Components as i32
1333 );
1334
1335 assert!(result
1336 .component_templates
1337 .iter()
1338 .any(|template| template.tag_name == "demo-card"));
1339 let component = result
1340 .protocol
1341 .components
1342 .get("demo-card")
1343 .expect("scriptless component metadata should be retained");
1344 assert!(component.hydration_keys.is_empty());
1345 assert_eq!(component.navigation_keys, ["name", "serverTitle"]);
1346 assert!(component.template_json.contains(r#""th":1"#));
1347
1348 let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
1349 let state = serde_json::json!({ "name": "Server rendered" });
1350 let protocol = Protocol::new(result.protocol.clone());
1351 let mut writer = StringWriter { buf: String::new() };
1352 handler
1353 .render(
1354 &protocol,
1355 &state,
1356 &RenderOptions::new("index.html", "/"),
1357 &mut writer,
1358 )
1359 .unwrap();
1360 assert!(writer.buf.contains("Server rendered"));
1361 assert!(writer.buf.contains(r#""state":{}"#));
1362 assert!(writer.buf.contains(r#""templates":{"demo-card":"#));
1363 }
1364
1365 #[test]
1366 fn test_build_treats_sibling_component_script_as_scripted() {
1367 let app = create_app_dir(&[
1368 ("index.html", "<demo-card></demo-card>"),
1369 (
1370 "demo-card.html",
1371 r#"<button @click="{onClick()}">{{name}}</button>"#,
1372 ),
1373 (
1374 "demo-card.ts",
1375 "import { WebUIElement } from '@microsoft/webui-framework';\n\
1376 class DemoCard extends WebUIElement { onClick() {} }\n\
1377 DemoCard.define('demo-card');",
1378 ),
1379 ]);
1380 let mut options = default_options(app.path());
1381 options.plugin = Some(Plugin::WebUI);
1382 let result = build(options).unwrap();
1383
1384 let template = result
1385 .component_templates
1386 .iter()
1387 .find(|template| template.tag_name == "demo-card")
1388 .unwrap();
1389 assert!(template.template_json.contains(r#""eg":[["click""#));
1390 }
1391
1392 #[test]
1393 fn test_build_populates_component_hydration_keys_from_script() {
1394 let app = create_app_dir(&[
1395 ("index.html", "<demo-card></demo-card>"),
1396 ("demo-card.html", "<p>{{name}}</p>"),
1397 (
1398 "demo-card.ts",
1399 "import { WebUIElement, attr, observable } from '@microsoft/webui-framework';\n\
1400 class DemoCard extends WebUIElement {\n\
1401 @observable name = '';\n\
1402 @attr({ attribute: 'cta-href' }) ctaHref = '/x';\n\
1403 @observable count = 0;\n\
1404 }\n\
1405 DemoCard.define('demo-card');",
1406 ),
1407 ]);
1408 let manifest_json = projection::test_support::build_valid_manifest_json(
1412 app.path(),
1413 &[("demo-card.ts", "source")],
1414 &[("demo-card.js", "output")],
1415 &[(
1416 "demo-card",
1417 "demo-card.ts",
1418 &["demo-card.js"],
1419 &["count", "ctaHref", "name"],
1420 &["count", "ctaHref", "name"],
1421 )],
1422 );
1423 let manifest = projection::test_support::write_manifest(
1424 app.path(),
1425 "webui-projection.json",
1426 &manifest_json,
1427 );
1428 let mut options = default_options(app.path());
1429 options.plugin = Some(Plugin::WebUI);
1430 options.projection_manifests = vec![manifest.into()];
1431 let result = build(options).unwrap();
1432
1433 assert_eq!(
1434 result.protocol.initial_state_strategy,
1435 webui_protocol::InitialStateStrategy::Components as i32
1436 );
1437 let component = result.protocol.components.get("demo-card").unwrap();
1438 assert_eq!(
1439 component.hydration_mode,
1440 webui_protocol::StateProjectionMode::Keys as i32
1441 );
1442 assert_eq!(component.hydration_keys, vec!["count", "ctaHref", "name"]);
1443 assert_eq!(
1444 component.navigation_mode,
1445 Some(webui_protocol::StateProjectionMode::Keys as i32)
1446 );
1447 assert_eq!(component.navigation_keys, vec!["count", "ctaHref", "name"]);
1448 }
1449
1450 #[test]
1451 fn test_build_scripted_component_without_manifest_falls_back_to_full_state() {
1452 let app = create_app_dir(&[
1453 (
1454 "index.html",
1455 "<html><body><demo-card></demo-card></body></html>",
1456 ),
1457 ("demo-card.html", "<p>{{name}}</p>"),
1458 (
1459 "demo-card.ts",
1460 "import { WebUIElement, observable } from '@microsoft/webui-framework';\n\
1461 class DemoCard extends WebUIElement {\n\
1462 @observable name = '';\n\
1463 }\n\
1464 DemoCard.define('demo-card');",
1465 ),
1466 ]);
1467 let mut options = default_options(app.path());
1468 options.plugin = Some(Plugin::WebUI);
1469 let result = build(options).unwrap();
1472
1473 assert_eq!(
1474 result.protocol.initial_state_strategy,
1475 webui_protocol::InitialStateStrategy::Full as i32
1476 );
1477 let component = result.protocol.components.get("demo-card").unwrap();
1478 assert_eq!(
1479 component.hydration_mode,
1480 webui_protocol::StateProjectionMode::All as i32
1481 );
1482 assert!(component.hydration_keys.is_empty());
1483 assert_eq!(
1484 component.navigation_mode,
1485 Some(webui_protocol::StateProjectionMode::All as i32)
1486 );
1487
1488 let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
1489 let state = serde_json::json!({ "name": "Visible" });
1490 let protocol = Protocol::new(result.protocol.clone());
1491 let mut writer = StringWriter { buf: String::new() };
1492 handler
1493 .render(
1494 &protocol,
1495 &state,
1496 &RenderOptions::new("index.html", "/"),
1497 &mut writer,
1498 )
1499 .unwrap();
1500 assert!(writer.buf.contains("Visible"));
1501 assert!(writer.buf.contains(r#""state":{"name":"Visible"}"#));
1504 }
1505
1506 #[test]
1507 fn test_build_rejects_scriptless_event_component() {
1508 let app = create_app_dir(&[
1509 ("index.html", "<demo-card></demo-card>"),
1510 (
1511 "demo-card.html",
1512 r#"<button @click="{onClick()}">Click</button>"#,
1513 ),
1514 ]);
1515 let mut options = default_options(app.path());
1516 options.plugin = Some(Plugin::WebUI);
1517 let err = build(options).expect_err("scriptless events should fail");
1518
1519 let message = err.chain_message();
1520 assert!(
1521 message.contains("scriptless-event-handler"),
1522 "msg: {message}"
1523 );
1524 }
1525
1526 #[test]
1527 fn test_build_returns_actionable_error_for_invalid_w_ref() {
1528 let app = create_app_dir(&[
1529 ("index.html", "<my-card>Hello</my-card>"),
1530 ("my-card.html", r#"<div><input w-ref="myInput" /></div>"#),
1531 ]);
1532 let mut options = default_options(app.path());
1533 options.plugin = Some(Plugin::WebUI);
1534
1535 let Err(err) = build(options) else {
1538 panic!("non-braced w-ref must fail the build");
1539 };
1540 let msg = err.chain_message();
1541 assert!(msg.contains("invalid w-ref binding"), "msg: {msg}");
1542 assert!(
1543 msg.contains("component <my-card> · element <input>"),
1544 "msg: {msg}"
1545 );
1546 assert!(msg.contains("help:"), "msg: {msg}");
1547 }
1548
1549 #[test]
1550 fn test_build_returns_actionable_error_for_invalid_event_handler() {
1551 let app = create_app_dir(&[
1552 ("index.html", "<my-btn>x</my-btn>"),
1553 (
1554 "my-btn.html",
1555 r#"<div><button @click="e.preventDefault()">x</button></div>"#,
1556 ),
1557 ]);
1558 let mut options = default_options(app.path());
1559 options.plugin = Some(Plugin::WebUI);
1560
1561 let Err(err) = build(options) else {
1562 panic!("invalid @event handler must fail the build");
1563 };
1564 let msg = err.chain_message();
1565 assert!(msg.contains("invalid @click handler"), "msg: {msg}");
1566 assert!(
1567 msg.contains("component <my-btn> · element <button>"),
1568 "msg: {msg}"
1569 );
1570 }
1571
1572 #[test]
1573 fn test_error_chain_message_has_no_duplicate_source() {
1574 let err = WebUIError::Parse {
1575 context: "Failed to parse index.html".to_string(),
1576 source: webui_parser::ParserError::Directive("Invalid for each: x".to_string()),
1577 };
1578 assert_eq!(err.to_string(), "Failed to parse index.html");
1581 assert_eq!(
1583 err.chain_message(),
1584 "Failed to parse index.html: Directive error: Invalid for each: x"
1585 );
1586 }
1587
1588 #[test]
1589 fn test_build_with_directives() {
1590 let html = r#"<h1>Hello</h1>
1591<for each="item in items">
1592 <p>{{item.name}}</p>
1593</for>
1594<if condition="show">
1595 <p>Visible</p>
1596</if>"#;
1597 let app = create_app_dir(&[("index.html", html)]);
1598 let result = build(default_options(app.path())).unwrap();
1599
1600 let index = &result.protocol.fragments["index.html"].fragments;
1601 assert!(index
1602 .iter()
1603 .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(_)))));
1604 assert!(index
1605 .iter()
1606 .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
1607 }
1608
1609 #[test]
1610 fn test_build_with_component_css() {
1611 let app = create_app_dir(&[
1612 ("index.html", "<my-card>Hello</my-card>"),
1613 (
1614 "my-card.html",
1615 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
1616 ),
1617 ("my-card.css", ".card { color: red; }"),
1618 ("my-card.ts", "export {};"),
1619 ]);
1620 let result = build(default_options(app.path())).unwrap();
1621
1622 assert_eq!(result.css_files.len(), 1);
1623 assert_eq!(result.css_files[0].0, "my-card.css");
1624 assert!(result.css_files[0].1.contains("color: red"));
1625 assert_eq!(result.stats.css_file_count, 1);
1626 }
1627
1628 #[test]
1629 fn light_css_bytes_align_across_link_style_and_module() {
1630 let authored = " \n.ready{color:red}\n ";
1631 let expected = authored;
1632
1633 for strategy in [CssStrategy::Link, CssStrategy::Style, CssStrategy::Module] {
1634 let app = create_app_dir(&[
1635 ("index.html", "<my-card></my-card>"),
1636 ("my-card.html", "<p>content</p>"),
1637 ("my-card.css", authored),
1638 ]);
1639 let mut options = light_options(app.path());
1640 options.css = strategy;
1641 if strategy == CssStrategy::Link {
1642 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
1643 }
1644 let result = build(options).unwrap();
1645
1646 let actual = match strategy {
1647 CssStrategy::Link => {
1648 let expected_name = webui_parser::CssLinkOptions::try_new(
1649 "[name]-[hash].[ext]".to_string(),
1650 None,
1651 )
1652 .unwrap()
1653 .resolve("my-card", expected)
1654 .filename;
1655 assert_eq!(result.css_files[0].0, expected_name);
1656 result.css_files[0].1.clone()
1657 }
1658 CssStrategy::Module => result.protocol.components["my-card"].css.clone(),
1659 CssStrategy::Style => {
1660 assert_eq!(result.protocol.components["my-card"].css, expected);
1661 result.protocol.components["my-card"].css.clone()
1662 }
1663 };
1664 assert_eq!(actual, expected, "mismatch for {strategy:?}");
1665 if strategy != CssStrategy::Link {
1666 assert_eq!(
1667 result.protocol.component_style_resource("my-card"),
1668 Some(expected)
1669 );
1670 }
1671 assert_eq!(
1672 result
1673 .protocol
1674 .style_closure("index.html")
1675 .expect("entry closure"),
1676 ["my-card"],
1677 "Light CSS must be installed by the Document closure"
1678 );
1679
1680 let protocol = Protocol::new(result.protocol.clone());
1681 let mut writer = StringWriter { buf: String::new() };
1682 WebUIHandler::new()
1683 .render(
1684 &protocol,
1685 &serde_json::json!({}),
1686 &RenderOptions::new("index.html", "/"),
1687 &mut writer,
1688 )
1689 .unwrap();
1690 match strategy {
1691 CssStrategy::Link => {
1692 let href = &result.protocol.components["my-card"].css_href;
1693 assert!(writer.buf.contains(&format!(
1694 r#"<link rel="stylesheet" href="{href}" data-webui-resource="my-card" data-webui-strategy="link">"#
1695 )));
1696 }
1697 CssStrategy::Style | CssStrategy::Module => {
1698 let kind = if strategy == CssStrategy::Module {
1699 "module"
1700 } else {
1701 "style"
1702 };
1703 assert!(writer.buf.contains(&format!(
1704 r#"<style data-webui-resource="my-card" data-webui-strategy="{kind}">{expected}</style>"#
1705 )));
1706 }
1707 }
1708 }
1709 }
1710
1711 #[test]
1712 fn build_precomputes_style_closures_with_authored_shadow_cut() {
1713 let app = create_app_dir(&[
1714 (
1715 "index.html",
1716 "<light-card></light-card><shadow-card><after-card></after-card></shadow-card>",
1717 ),
1718 ("light-card.html", "<nested-card></nested-card>"),
1719 ("light-card.css", ".light{}"),
1720 (
1721 "shadow-card.html",
1722 r#"<template shadowrootmode="open"><nested-card></nested-card></template>"#,
1723 ),
1724 ("shadow-card.css", ".shadow{}"),
1725 ("nested-card.html", "<span>nested</span>"),
1726 ("nested-card.css", ".nested{}"),
1727 ("after-card.html", "<span>after</span>"),
1728 ("after-card.css", ".after{}"),
1729 ]);
1730 let mut options = light_options(app.path());
1731 options.css = CssStrategy::Style;
1732 let result = build(options).unwrap();
1733
1734 assert_eq!(
1735 result
1736 .protocol
1737 .style_closure("index.html")
1738 .expect("entry closure"),
1739 ["light-card", "nested-card", "after-card"]
1740 );
1741 assert_eq!(
1742 result
1743 .protocol
1744 .style_closure("shadow-card")
1745 .expect("Shadow closure"),
1746 ["shadow-card", "nested-card"]
1747 );
1748 }
1749
1750 fn bundling_app() -> tempfile::TempDir {
1754 create_app_dir(&[
1755 (
1756 "index.html",
1757 "<a-card></a-card><b-card></b-card><shared-card></shared-card>\
1758 <shadow-card></shadow-card>",
1759 ),
1760 ("a-card.html", "<span>a</span>"),
1761 ("a-card.css", ".a{color:red}"),
1762 ("b-card.html", "<span>b</span>"),
1763 ("b-card.css", ".b{color:green}"),
1764 ("shared-card.html", "<span>shared</span>"),
1765 ("shared-card.css", ".shared{color:blue}"),
1766 (
1767 "shadow-card.html",
1768 r#"<template shadowrootmode="open"><shared-card></shared-card></template>"#,
1769 ),
1770 ("shadow-card.css", ".shadow{color:black}"),
1771 ])
1772 }
1773
1774 fn closure_css(protocol: &WebUIProtocol, root: &str) -> (String, String) {
1777 let closure = protocol.style_closures.get(root).expect("closure");
1778 let unbundled = closure
1779 .component_tags
1780 .iter()
1781 .filter_map(|tag| protocol.components.get(tag))
1782 .map(|component| component.css.as_str())
1783 .collect::<Vec<_>>()
1784 .join("\n");
1785 let bundled = closure
1786 .style_chunks
1787 .iter()
1788 .filter_map(|index| protocol.style_chunk_resource(*index))
1789 .map(|(_, css)| css)
1790 .collect::<Vec<_>>()
1791 .join("\n");
1792 (unbundled, bundled)
1793 }
1794
1795 #[test]
1796 fn bundling_preserves_the_cascade_of_every_closure() {
1797 let app = bundling_app();
1798 let mut options = light_options(app.path());
1799 options.css = CssStrategy::Style;
1800 options.css_bundle = true;
1801 let result = build(options).unwrap();
1802
1803 let roots: Vec<String> = result.protocol.style_closures.keys().cloned().collect();
1804 assert!(roots.len() >= 2, "fixture must produce several CSS trees");
1805 let mut chunked = 0usize;
1806 for root in roots {
1807 let (unbundled, bundled) = closure_css(&result.protocol, &root);
1808 if bundled.is_empty() && !unbundled.is_empty() {
1811 continue;
1812 }
1813 chunked += 1;
1814 assert_eq!(
1815 bundled, unbundled,
1816 "chunked cascade diverged from the per-component cascade for `{root}`"
1817 );
1818 }
1819 assert!(chunked >= 2, "fixture must chunk several CSS trees");
1820 }
1821
1822 #[test]
1823 fn bundling_merges_entry_local_styles_and_splits_shared_ones() {
1824 let app = bundling_app();
1825 let mut options = light_options(app.path());
1826 options.css = CssStrategy::Style;
1827 options.css_bundle = true;
1828 let result = build(options).unwrap();
1829
1830 let members: Vec<&[String]> = result
1831 .protocol
1832 .style_chunks
1833 .iter()
1834 .map(|chunk| chunk.component_tags.as_slice())
1835 .collect();
1836 assert!(
1837 members.contains(&["a-card".to_string(), "b-card".to_string()].as_slice()),
1838 "entry-local components should merge: {members:?}"
1839 );
1840 assert!(
1841 members.contains(&["shared-card".to_string()].as_slice()),
1842 "a component reached from two CSS trees must stay alone: {members:?}"
1843 );
1844
1845 let entry = result.protocol.style_closure_chunks("index.html").unwrap();
1848 let shadow = result.protocol.style_closure_chunks("shadow-card").unwrap();
1849 let shared = entry
1850 .iter()
1851 .find(|index| shadow.contains(index))
1852 .expect("both trees should share a chunk");
1853 assert_eq!(
1854 result.protocol.style_chunk_members(*shared).unwrap(),
1855 ["shared-card"]
1856 );
1857 }
1858
1859 #[test]
1860 fn bundling_is_off_by_default_and_leaves_the_protocol_unchanged() {
1861 let app = bundling_app();
1862 let mut options = default_options(app.path());
1863 options.css = CssStrategy::Style;
1864 let result = build(options).unwrap();
1865
1866 assert!(result.protocol.style_chunks.is_empty());
1867 for closure in result.protocol.style_closures.values() {
1868 assert!(closure.style_chunks.is_empty());
1869 }
1870 }
1871
1872 #[test]
1873 fn bundling_link_emits_chunks_and_component_fallback_files() {
1874 let app = bundling_app();
1875 let mut options = light_options(app.path());
1876 options.css_bundle = true;
1877 let result = build(options).unwrap();
1878
1879 let names: Vec<&str> = result
1880 .css_files
1881 .iter()
1882 .map(|(name, _)| name.as_str())
1883 .collect();
1884 for (tag, component) in &result.protocol.components {
1885 if component.css_href.is_empty() {
1886 continue;
1887 }
1888 assert!(
1889 names.contains(&component.css_href.as_str()),
1890 "`{tag}` needs its independently loaded fallback file: {names:?}"
1891 );
1892 }
1893 for chunk in &result.protocol.style_chunks {
1894 assert!(!chunk.css_href.is_empty(), "Link chunks need an href");
1895 assert!(
1896 names.contains(&chunk.css_href.as_str()),
1897 "every rendered chunk needs an emitted file: {names:?}"
1898 );
1899 assert!(
1900 chunk.css.is_empty(),
1901 "Link chunks ship their CSS as a file, not inline"
1902 );
1903 }
1904 }
1905
1906 #[test]
1907 fn bundling_keeps_component_files_when_component_assets_need_them() {
1908 let app = create_app_dir(&[
1909 (
1910 "index.html",
1911 "<a-card></a-card><b-card></b-card><shared-card></shared-card>",
1912 ),
1913 ("a-card.html", "<span>a</span>"),
1914 ("a-card.css", ".a{color:red}"),
1915 ("b-card.html", "<span>b</span>"),
1916 ("b-card.css", ".b{color:green}"),
1917 ("shared-card.html", "<span>shared</span>"),
1918 ("shared-card.css", ".shared{color:blue}"),
1919 ("lazy-panel.html", "<a-card></a-card><p>lazy</p>"),
1920 ("lazy-panel.css", ".lazy{color:gray}"),
1921 ("lazy-panel.ts", "export {};"),
1922 ]);
1923 let mut options = light_options(app.path());
1924 options.plugin = Some(Plugin::WebUI);
1925 options.css_bundle = true;
1926 options.component_asset_roots = vec!["lazy-panel".to_string()];
1927 let result = build(options).unwrap();
1928
1929 let names: Vec<&str> = result
1930 .css_files
1931 .iter()
1932 .map(|(name, _)| name.as_str())
1933 .collect();
1934 assert!(
1935 names.iter().any(|name| name.starts_with("lazy-panel")),
1936 "static assets fetch a component's own stylesheet: {names:?}"
1937 );
1938 assert!(
1939 names.iter().any(|name| name.starts_with("_chunk-")),
1940 "bundled chunks are still emitted: {names:?}"
1941 );
1942 assert!(
1943 result.component_asset_files[0]
1944 .content
1945 .contains(r#""a-card":{"kind":"link","href":"a-card.css"}"#),
1946 "a deferred CSS tree needs the exact external component resource even when the entry Document uses a bundle: {}",
1947 result.component_asset_files[0].content
1948 );
1949 }
1950
1951 fn render_html(protocol: &WebUIProtocol, route: &str) -> String {
1953 let protocol = Protocol::new(protocol.clone());
1954 let mut writer = StringWriter { buf: String::new() };
1955 WebUIHandler::new()
1956 .render(
1957 &protocol,
1958 &serde_json::json!({}),
1959 &RenderOptions::new("index.html", route),
1960 &mut writer,
1961 )
1962 .unwrap();
1963 writer.buf
1964 }
1965
1966 fn linked_stylesheets(html: &str) -> Vec<&str> {
1968 let mut hrefs = Vec::new();
1969 let mut rest = html;
1970 while let Some(start) = rest.find("href=\"") {
1971 rest = &rest[start + "href=\"".len()..];
1972 let Some(end) = rest.find('"') else { break };
1973 let href = &rest[..end];
1974 if href.ends_with(".css") {
1975 hrefs.push(href);
1976 }
1977 rest = &rest[end..];
1978 }
1979 hrefs
1980 }
1981
1982 fn emitted_resources(html: &str) -> Vec<&str> {
1984 let mut resources = Vec::new();
1985 let mut rest = html;
1986 while let Some(start) = rest.find("data-webui-resource=\"") {
1987 rest = &rest[start + "data-webui-resource=\"".len()..];
1988 match rest.find('"') {
1989 Some(end) => {
1990 resources.push(&rest[..end]);
1991 rest = &rest[end..];
1992 }
1993 None => break,
1994 }
1995 }
1996 resources
1997 }
1998
1999 #[test]
2000 fn bundled_rendering_emits_one_resource_per_chunk() {
2001 for strategy in [CssStrategy::Link, CssStrategy::Style] {
2002 let app = bundling_app();
2003 let mut options = light_options(app.path());
2004 options.css = strategy;
2005 options.css_bundle = true;
2006 let result = build(options).unwrap();
2007
2008 let html = render_html(&result.protocol, "/");
2009 let resources = emitted_resources(&html);
2010 assert!(
2011 resources.contains(&"_chunk-a-card-2"),
2012 "{strategy:?} should deliver the merged chunk once: {resources:?}"
2013 );
2014 assert!(
2015 !resources.contains(&"a-card") && !resources.contains(&"b-card"),
2016 "{strategy:?} must not also deliver merged members: {resources:?}"
2017 );
2018 }
2019 }
2020
2021 #[test]
2022 fn bundling_never_merges_an_authored_shadow_stylesheet() {
2023 let app = create_app_dir(&[
2026 (
2027 "index.html",
2028 "<a-card></a-card><shadow-card></shadow-card><b-card></b-card>",
2029 ),
2030 ("a-card.html", "<span>a</span>"),
2031 ("a-card.css", ".a{color:red}"),
2032 ("b-card.html", "<span>b</span>"),
2033 ("b-card.css", ".b{color:green}"),
2034 (
2035 "shadow-card.html",
2036 r#"<template shadowrootmode="open"><span>s</span></template>"#,
2037 ),
2038 ("shadow-card.css", ".shadow{color:black}"),
2039 ]);
2040 let mut options = light_options(app.path());
2041 options.css = CssStrategy::Style;
2042 options.css_bundle = true;
2043 let result = build(options).unwrap();
2044
2045 let chunk = result
2046 .protocol
2047 .style_chunks
2048 .iter()
2049 .find(|chunk| chunk.component_tags.iter().any(|tag| tag == "shadow-card"))
2050 .expect("shadow-card is styled, so it belongs to a chunk");
2051 assert_eq!(
2055 chunk.component_tags,
2056 vec!["shadow-card".to_string()],
2057 "an authored-Shadow stylesheet must stay in a chunk of its own"
2058 );
2059 assert_eq!(chunk.name, "shadow-card");
2060 assert_eq!(
2061 chunk.css, result.protocol.components["shadow-card"].css,
2062 "its chunk must carry the component's own bytes unchanged"
2063 );
2064 }
2065
2066 #[test]
2067 fn bundling_never_links_a_stylesheet_the_build_did_not_write() {
2068 let app = bundling_app();
2072 let mut options = light_options(app.path());
2073 options.css = CssStrategy::Link;
2074 options.css_bundle = true;
2075 let result = build(options).unwrap();
2076
2077 let emitted: HashSet<&str> = result
2078 .css_files
2079 .iter()
2080 .map(|(name, _)| name.as_str())
2081 .collect();
2082 let html = render_html(&result.protocol, "/");
2083 let linked = linked_stylesheets(&html);
2084 assert!(
2085 !linked.is_empty(),
2086 "the page should link at least one stylesheet: {html}"
2087 );
2088 for href in &linked {
2089 let name = href.rsplit('/').next().unwrap_or(href);
2090 assert!(
2091 emitted.contains(name),
2092 "page links `{href}`, which the bundled build did not write: {emitted:?}"
2093 );
2094 }
2095 }
2096
2097 #[test]
2098 fn bundling_is_rejected_for_the_module_strategy() {
2099 let app = bundling_app();
2100 let mut options = light_options(app.path());
2101 options.css = CssStrategy::Module;
2102 options.css_bundle = true;
2103
2104 let Err(WebUIError::InvalidBuildOptions(message)) = build(options) else {
2105 panic!("Module builds resolve import-map specifiers per component");
2106 };
2107 assert!(
2108 message.contains("--css link"),
2109 "the error should name a strategy that works: {message}"
2110 );
2111 }
2112
2113 #[test]
2114 fn bundled_rendering_delivers_the_same_css_in_the_same_order() {
2115 let extract = |html: &str| -> Vec<String> {
2118 let mut blocks = Vec::new();
2119 let mut rest = html;
2120 while let Some(start) = rest.find("data-webui-strategy=\"style\">") {
2121 rest = &rest[start + "data-webui-strategy=\"style\">".len()..];
2122 match rest.find("</style>") {
2123 Some(end) => {
2124 blocks.push(rest[..end].to_string());
2125 rest = &rest[end..];
2126 }
2127 None => break,
2128 }
2129 }
2130 blocks
2131 };
2132
2133 let mut plan = Vec::new();
2134 for bundle in [false, true] {
2135 let app = bundling_app();
2136 let mut options = light_options(app.path());
2137 options.css = CssStrategy::Style;
2138 options.css_bundle = bundle;
2139 let result = build(options).unwrap();
2140 let html = render_html(&result.protocol, "/");
2141 plan.push(extract(&html).join("\n"));
2142 }
2143
2144 assert_eq!(
2145 plan[1], plan[0],
2146 "bundling must not change the delivered cascade"
2147 );
2148 assert!(!plan[0].is_empty(), "fixture must deliver CSS");
2149 }
2150
2151 #[test]
2152 fn bundled_rendering_never_repeats_a_chunk_across_css_trees() {
2153 let app = bundling_app();
2154 let mut options = light_options(app.path());
2155 options.css = CssStrategy::Style;
2156 options.css_bundle = true;
2157 let result = build(options).unwrap();
2158
2159 let html = render_html(&result.protocol, "/");
2160 let resources = emitted_resources(&html);
2161 let mut counts: HashMap<&str, usize> = HashMap::new();
2165 for resource in &resources {
2166 *counts.entry(*resource).or_insert(0) += 1;
2167 }
2168 let shadow_start = html
2169 .find(r#"<template shadowrootmode="open""#)
2170 .expect("declarative Shadow root");
2171 let document_resources = emitted_resources(&html[..shadow_start]);
2172 let mut document_counts: HashMap<&str, usize> = HashMap::new();
2173 for resource in &document_resources {
2174 *document_counts.entry(*resource).or_insert(0) += 1;
2175 }
2176 for (resource, count) in &document_counts {
2177 assert_eq!(
2178 *count, 1,
2179 "`{resource}` was delivered {count} times in the document tree: {resources:?}"
2180 );
2181 }
2182 }
2183
2184 #[test]
2185 fn authored_shadow_css_bytes_are_not_lowered_or_trimmed() {
2186 let authored =
2187 " \n:host(.ready){color:red}:host-context(body){color:blue}::slotted(*){color:green}\n ";
2188
2189 for strategy in [CssStrategy::Link, CssStrategy::Style, CssStrategy::Module] {
2190 let app = create_app_dir(&[
2191 ("index.html", "<my-card></my-card>"),
2192 (
2193 "my-card.html",
2194 r#"<template shadowrootmode="open"><p>content</p></template>"#,
2195 ),
2196 ("my-card.css", authored),
2197 ]);
2198 let mut options = default_options(app.path());
2199 options.css = strategy;
2200 let result = build(options).unwrap();
2201
2202 match strategy {
2203 CssStrategy::Link => assert_eq!(result.css_files[0].1, authored),
2204 CssStrategy::Style | CssStrategy::Module => {
2205 assert_eq!(result.protocol.components["my-card"].css, authored);
2206 assert_eq!(
2207 result.protocol.component_style_resource("my-card"),
2208 Some(authored)
2209 );
2210 }
2211 }
2212 assert_eq!(
2213 result
2214 .protocol
2215 .style_closure("my-card")
2216 .expect("component closure"),
2217 ["my-card"],
2218 "the authored Shadow root owns its exact CSS resource"
2219 );
2220
2221 let protocol = Protocol::new(result.protocol.clone());
2222 let mut writer = StringWriter { buf: String::new() };
2223 WebUIHandler::new()
2224 .render(
2225 &protocol,
2226 &serde_json::json!({}),
2227 &RenderOptions::new("index.html", "/"),
2228 &mut writer,
2229 )
2230 .unwrap();
2231 match strategy {
2232 CssStrategy::Link => {
2233 let href = &result.protocol.components["my-card"].css_href;
2234 assert!(writer.buf.contains(&format!(
2235 r#"<template shadowrootmode="open"><link rel="stylesheet" href="{href}" data-webui-resource="my-card" data-webui-strategy="link">"#
2236 )));
2237 }
2238 CssStrategy::Style | CssStrategy::Module => {
2239 let kind = if strategy == CssStrategy::Module {
2240 "module"
2241 } else {
2242 "style"
2243 };
2244 assert!(writer.buf.contains(&format!(
2245 r#"<style data-webui-resource="my-card" data-webui-strategy="{kind}">{authored}</style>"#
2246 )));
2247 let template_start = writer
2248 .buf
2249 .find(r#"<template shadowrootmode="open""#)
2250 .expect("declarative Shadow root");
2251 let style_start = writer
2252 .buf
2253 .find(&format!(
2254 r#"<style data-webui-resource="my-card" data-webui-strategy="{kind}">"#
2255 ))
2256 .expect("tree-local style");
2257 let content_start = writer.buf.find("<p>content</p>").expect("component body");
2258 assert!(template_start < style_start && style_start < content_start);
2259 }
2260 }
2261 }
2262 }
2263
2264 #[test]
2265 fn test_build_strips_non_legal_css_comments_from_output_file() {
2266 let app = create_app_dir(&[
2267 ("index.html", "<my-card>Hello</my-card>"),
2268 (
2269 "my-card.html",
2270 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2271 ),
2272 (
2273 "my-card.css",
2274 "/* remove var(--ignored) */ .card { color: var(--textColor); }",
2275 ),
2276 ]);
2277 let result = build(default_options(app.path())).unwrap();
2278
2279 assert_eq!(result.css_files[0].1, " .card { color: var(--textColor); }");
2280 assert_eq!(result.protocol.tokens, vec!["textColor"]);
2281 }
2282
2283 #[test]
2284 fn test_build_preserves_legal_css_comments_by_default() {
2285 let app = create_app_dir(&[
2286 ("index.html", "<my-card>Hello</my-card>"),
2287 (
2288 "my-card.html",
2289 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2290 ),
2291 (
2292 "my-card.css",
2293 "/*! @license MIT */ .card { color: red; } /* remove */",
2294 ),
2295 ]);
2296 let result = build(default_options(app.path())).unwrap();
2297
2298 assert_eq!(
2299 result.css_files[0].1,
2300 "/*! @license MIT */ .card { color: red; } "
2301 );
2302 }
2303
2304 #[test]
2305 fn test_build_legal_comments_none_strips_legal_css_comments() {
2306 let app = create_app_dir(&[
2307 ("index.html", "<my-card>Hello</my-card>"),
2308 (
2309 "my-card.html",
2310 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2311 ),
2312 ("my-card.css", "/*! @license MIT */ .card { color: red; }"),
2313 ]);
2314 let mut options = default_options(app.path());
2315 options.legal_comments = LegalComments::None;
2316 let result = build(options).unwrap();
2317
2318 assert_eq!(result.css_files[0].1, " .card { color: red; }");
2319 }
2320
2321 #[test]
2322 fn test_build_with_css_hashed_filename_template() {
2323 let app = create_app_dir(&[
2324 ("index.html", "<my-card>Hello</my-card>"),
2325 (
2326 "my-card.html",
2327 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2328 ),
2329 ("my-card.css", ".card { color: red; }"),
2330 ]);
2331 let mut options = default_options(app.path());
2332 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
2333 let result = build(options).unwrap();
2334
2335 assert_eq!(result.css_files.len(), 1);
2336 let generated = &result.css_files[0].0;
2337 assert!(generated.starts_with("my-card-"));
2338 assert!(generated.ends_with(".css"));
2339 assert_eq!(generated.len(), "my-card-".len() + 8 + ".css".len());
2340 assert_eq!(
2341 result
2342 .protocol
2343 .components
2344 .get("my-card")
2345 .map(|c| c.css_href.as_str()),
2346 Some(generated.as_str())
2347 );
2348 }
2349
2350 #[test]
2351 fn test_css_public_base_prefixes_css_href_only() {
2352 let app = create_app_dir(&[
2353 ("index.html", "<my-card>Hello</my-card>"),
2354 (
2355 "my-card.html",
2356 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2357 ),
2358 ("my-card.css", ".card { color: red; }"),
2359 ]);
2360 let mut options = default_options(app.path());
2361 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
2362 options.css_public_base = Some("https://cdn.example.com/assets".to_string());
2363 let result = build(options).unwrap();
2364
2365 let filename = &result.css_files[0].0;
2366 assert!(filename.starts_with("my-card-"));
2367 let expected_href = format!("https://cdn.example.com/assets/{filename}");
2368 assert_eq!(
2369 result
2370 .protocol
2371 .components
2372 .get("my-card")
2373 .map(|c| c.css_href.as_str()),
2374 Some(expected_href.as_str())
2375 );
2376 }
2377
2378 #[test]
2379 fn test_css_public_base_emits_protocol_handler_and_payload_resources() {
2380 let app = create_app_dir(&[
2381 (
2382 "index.html",
2383 "<html><head></head><body><my-card>Hello</my-card></body></html>",
2384 ),
2385 (
2386 "my-card.html",
2387 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2388 ),
2389 ("my-card.css", ".card { color: red; }"),
2390 ]);
2391 let mut options = default_options(app.path());
2392 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
2393 options.css_public_base = Some("https://cdn.example.com/assets".to_string());
2394 let result = build(options).unwrap();
2395
2396 let filename = &result.css_files[0].0;
2397 let expected_href = format!("https://cdn.example.com/assets/{filename}");
2398 assert_eq!(
2399 result.protocol.component_style_resource("my-card"),
2400 Some(expected_href.as_str())
2401 );
2402 assert_eq!(
2403 result
2404 .protocol
2405 .style_closure("my-card")
2406 .expect("component closure"),
2407 ["my-card"]
2408 );
2409
2410 let handler = WebUIHandler::new();
2411 let protocol = Protocol::new(result.protocol.clone());
2412 let mut writer = StringWriter { buf: String::new() };
2413 handler
2414 .render(
2415 &protocol,
2416 &serde_json::json!({}),
2417 &RenderOptions::new("index.html", "/"),
2418 &mut writer,
2419 )
2420 .unwrap();
2421 assert!(
2422 writer.buf.contains(&format!(
2423 r#"<link rel="stylesheet" href="{}" data-webui-resource="my-card" data-webui-strategy="link">"#,
2424 expected_href.replace('/', "/")
2425 )),
2426 "handler Shadow-root resource should use CDN href: {}",
2427 writer.buf
2428 );
2429 let payload = protocol
2430 .render_component_templates(&["my-card"], "")
2431 .expect("component resource payload");
2432 assert_eq!(
2433 payload["componentStyles"]["resources"]["my-card"]["href"],
2434 expected_href
2435 );
2436 }
2437
2438 #[test]
2439 fn test_css_public_base_keeps_webui_template_style_free() {
2440 let app = create_app_dir(&[
2441 ("index.html", "<my-card>Hello</my-card>"),
2442 (
2443 "my-card.html",
2444 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2445 ),
2446 ("my-card.css", ".card { color: red; }"),
2447 ("my-card.ts", "export {};"),
2448 ]);
2449 let mut options = default_options(app.path());
2450 options.plugin = Some(Plugin::WebUI);
2451 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
2452 options.css_public_base = Some("https://cdn.example.com/assets".to_string());
2453 let result = build(options).unwrap();
2454
2455 let filename = &result.css_files[0].0;
2456 let expected_href = format!("https://cdn.example.com/assets/{filename}");
2457 let template = &result.protocol.components["my-card"].template_json;
2458 assert_eq!(
2459 result.protocol.component_style_resource("my-card"),
2460 Some(expected_href.as_str())
2461 );
2462 assert!(
2463 !template.contains(&expected_href),
2464 "WebUI templates must not duplicate handler-owned CSS links: {template}"
2465 );
2466 }
2467
2468 #[test]
2469 fn test_invalid_css_template_is_rejected() {
2470 let app = create_app_dir(&[
2471 ("index.html", "<my-card>Hello</my-card>"),
2472 (
2473 "my-card.html",
2474 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2475 ),
2476 ("my-card.css", ".card { color: red; }"),
2477 ]);
2478 let mut options = default_options(app.path());
2479 options.css_file_name_template = "[name]-[bogus].[ext]".to_string();
2480 let result = build(options);
2481
2482 assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
2483 }
2484
2485 #[test]
2486 fn test_css_filename_collision_is_rejected() {
2487 let app = create_app_dir(&[
2488 ("index.html", "<card-a>A</card-a><card-b>B</card-b>"),
2489 (
2490 "card-a.html",
2491 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2492 ),
2493 ("card-a.css", ".x { color: red; }"),
2494 (
2495 "card-b.html",
2496 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2497 ),
2498 ("card-b.css", ".x { color: red; }"),
2499 ]);
2500 let mut options = default_options(app.path());
2501 options.css_file_name_template = "[hash].[ext]".to_string();
2502 let result = build(options);
2503
2504 assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
2505 }
2506
2507 #[test]
2508 fn test_build_returns_static_component_asset_files() {
2509 let app = create_app_dir(&[
2510 ("index.html", "<app-shell></app-shell>"),
2511 ("app-shell.html", "<div></div>"),
2512 (
2513 "lazy-panel.html",
2514 r#"<if condition="ready"><p>{{title}}</p></if>"#,
2515 ),
2516 ("lazy-panel.ts", "export {};"),
2517 ]);
2518 let mut options = default_options(app.path());
2519 options.plugin = Some(Plugin::WebUI);
2520 options.component_asset_roots = vec!["lazy-panel".to_string()];
2521
2522 let result = build(options).unwrap();
2523
2524 assert_eq!(result.component_asset_files.len(), 1);
2525 assert_eq!(result.component_asset_files[0].name, "lazy-panel.webui.js");
2526 assert!(result.component_asset_files[0]
2527 .content
2528 .contains(r#""type":"webui-component-asset""#));
2529 assert!(result.component_asset_files[0]
2530 .content
2531 .contains(r#""version":3"#));
2532 assert!(result.component_asset_files[0]
2533 .content
2534 .contains(r#""kind":"root""#));
2535 assert!(result.component_asset_files[0]
2536 .content
2537 .contains(r#""templateFunctions":{"lazy-panel":"#));
2538 assert!(!result.protocol.fragments.contains_key("lazy-panel"));
2539 assert!(
2540 !result
2541 .protocol
2542 .fragments
2543 .keys()
2544 .any(|key| key.starts_with("__webui_asset_root_")),
2545 "synthetic asset root fragments must not be serialized"
2546 );
2547 }
2548
2549 #[test]
2550 fn component_assets_emit_enhanced_style_catalog_for_all_strategies() {
2551 for (strategy, kind) in [
2552 (CssStrategy::Link, "link"),
2553 (CssStrategy::Style, "style"),
2554 (CssStrategy::Module, "module"),
2555 ] {
2556 let app = create_app_dir(&[
2557 ("index.html", "<app-shell></app-shell>"),
2558 ("app-shell.html", "<p>Entry</p>"),
2559 ("lazy-panel.html", "<p>Lazy</p>"),
2560 ("lazy-panel.css", ".lazy{color:red}"),
2561 ]);
2562 let mut options = default_options(app.path());
2563 options.plugin = Some(Plugin::WebUI);
2564 options.css = strategy;
2565 options.component_asset_roots = vec!["lazy-panel".to_string()];
2566
2567 let result = build(options).unwrap();
2568 let asset = &result.component_asset_files[0].content;
2569 assert!(asset.contains(r#""version":3"#));
2570 assert!(asset.contains(&format!(
2571 r#""componentStyles":{{"version":1,"strategy":"{kind}""#
2572 )));
2573 assert!(asset.contains(&format!(r#""lazy-panel":{{"kind":"{kind}""#)));
2574 assert!(asset.contains(r#""closures":{"lazy-panel":["lazy-panel"]}"#));
2575 if strategy == CssStrategy::Module {
2576 assert!(asset
2577 .contains(r#""lazy-panel":{"kind":"module","specifier":"lazy-panel","css":"#,));
2578 }
2579 if strategy == CssStrategy::Link {
2580 assert!(asset.contains(r#""href":new URL("lazy-panel.css",import.meta.url).href"#,));
2581 }
2582 }
2583 }
2584
2585 #[test]
2586 fn test_build_splits_shared_assets_and_keeps_them_out_of_protocol() {
2587 let app = create_app_dir(&[
2588 ("index.html", "<app-shell></app-shell>"),
2589 ("app-shell.html", "<entry-badge></entry-badge>"),
2590 ("entry-badge.html", "<span>Entry</span>"),
2591 ("entry-badge.css", ":host { display: inline-block; }"),
2592 (
2593 "lazy-panel.html",
2594 "<entry-badge></entry-badge><shared-detail></shared-detail><panel-only></panel-only>",
2595 ),
2596 (
2597 "secondary-panel.html",
2598 "<entry-badge></entry-badge><shared-detail></shared-detail><secondary-only></secondary-only>",
2599 ),
2600 ("shared-detail.html", "<p>Shared</p>"),
2601 ("shared-detail.css", ":host { display: block; }"),
2602 ("lazy-panel.css", ":host { color: green; }"),
2603 ("secondary-panel.css", ":host { color: blue; }"),
2604 ("panel-only.html", "<p>Panel</p>"),
2605 ("secondary-only.html", "<p>Secondary</p>"),
2606 ]);
2607 let mut options = default_options(app.path());
2608 options.plugin = Some(Plugin::WebUI);
2609 options.component_asset_roots =
2610 vec!["lazy-panel".to_string(), "secondary-panel".to_string()];
2611
2612 let result = build(options).unwrap();
2613
2614 for tag in [
2615 "lazy-panel",
2616 "secondary-panel",
2617 "shared-detail",
2618 "panel-only",
2619 "secondary-only",
2620 ] {
2621 assert!(
2622 !result.protocol.fragments.contains_key(tag),
2623 "asset-only fragment <{tag}> must not be serialized"
2624 );
2625 assert!(
2626 !result.protocol.components.contains_key(tag),
2627 "asset-only component <{tag}> must not be serialized"
2628 );
2629 }
2630 assert!(result.protocol.fragments.contains_key("app-shell"));
2631 assert!(result.protocol.components.contains_key("entry-badge"));
2632 assert_eq!(
2633 result.protocol.component_asset_style_preloads,
2634 vec![
2635 webui_protocol::ComponentAssetStylePreload {
2636 root: "lazy-panel".to_string(),
2637 style_hrefs: vec![
2638 "lazy-panel.css".to_string(),
2639 "shared-detail.css".to_string(),
2640 ],
2641 },
2642 webui_protocol::ComponentAssetStylePreload {
2643 root: "secondary-panel".to_string(),
2644 style_hrefs: vec![
2645 "secondary-panel.css".to_string(),
2646 "shared-detail.css".to_string(),
2647 ],
2648 },
2649 ]
2650 );
2651
2652 let names: Vec<&str> = result
2653 .component_asset_files
2654 .iter()
2655 .map(|file| file.name.as_str())
2656 .collect();
2657 assert_eq!(
2658 names,
2659 vec![
2660 "lazy-panel.webui.js",
2661 "secondary-panel.webui.js",
2662 "chunk-shared-detail.webui.js",
2663 ]
2664 );
2665 let lazy = &result.component_asset_files[0].content;
2666 assert!(lazy.contains(r#""version":3"#));
2667 assert!(lazy.contains(r#""kind":"root""#));
2668 assert!(lazy.contains(r#""externalComponents":["entry-badge"]"#));
2669 assert!(lazy.contains(r#""entry-badge":{"kind":"link","href":"entry-badge.css"}"#));
2670 assert!(!lazy.contains(
2671 r#""entry-badge":{"kind":"link","href":new URL("entry-badge.css",import.meta.url).href}"#
2672 ));
2673 assert!(lazy
2674 .contains(r#""href":new URL("./chunk-shared-detail.webui.js",import.meta.url).href"#));
2675 assert!(lazy.contains(r#"import("./chunk-shared-detail.webui.js")"#));
2676 assert!(!lazy.contains(r#""templates":{"entry-badge":"#));
2677 assert!(!lazy.contains(r#""templates":{"shared-detail":"#));
2678
2679 let chunk = &result.component_asset_files[2].content;
2680 assert!(chunk.contains(r#""kind":"chunk""#));
2681 assert!(chunk.contains(r#""components":["shared-detail"]"#));
2682 assert!(chunk.contains(r#""templates":{"shared-detail":"#));
2683 }
2684
2685 #[test]
2686 fn test_component_asset_graph_groups_each_consumer_set() {
2687 let app = create_app_dir(&[
2688 ("index.html", "<app-shell></app-shell>"),
2689 ("app-shell.html", "<p>Entry</p>"),
2690 (
2691 "root-a.html",
2692 "<shared-ab></shared-ab><shared-all></shared-all><only-a></only-a>",
2693 ),
2694 (
2695 "root-b.html",
2696 "<shared-ab></shared-ab><shared-all></shared-all><only-b></only-b>",
2697 ),
2698 ("root-c.html", "<shared-all></shared-all><only-c></only-c>"),
2699 ("shared-ab.html", "<p>AB</p>"),
2700 ("shared-all.html", "<p>All</p>"),
2701 ("only-a.html", "<p>A</p>"),
2702 ("only-b.html", "<p>B</p>"),
2703 ("only-c.html", "<p>C</p>"),
2704 ]);
2705 let mut options = default_options(app.path());
2706 options.plugin = Some(Plugin::WebUI);
2707 options.component_asset_roots = vec![
2708 "root-a".to_string(),
2709 "root-b".to_string(),
2710 "root-c".to_string(),
2711 ];
2712
2713 let result = build(options).unwrap();
2714 let names: Vec<&str> = result
2715 .component_asset_files
2716 .iter()
2717 .map(|file| file.name.as_str())
2718 .collect();
2719 assert_eq!(
2720 names,
2721 [
2722 "root-a.webui.js",
2723 "root-b.webui.js",
2724 "root-c.webui.js",
2725 "chunk-shared-ab.webui.js",
2726 "chunk-shared-all.webui.js",
2727 ]
2728 );
2729 assert!(result.component_asset_files[0]
2730 .content
2731 .contains(r#""components":["only-a","root-a"]"#));
2732 assert!(result.component_asset_files[3]
2733 .content
2734 .contains(r#""components":["shared-ab"]"#));
2735 assert!(result.component_asset_files[4]
2736 .content
2737 .contains(r#""components":["shared-all"]"#));
2738 }
2739
2740 #[test]
2741 fn test_component_asset_graph_is_root_order_independent_with_hashed_imports() {
2742 let app = create_app_dir(&[
2743 ("index.html", "<app-shell></app-shell>"),
2744 ("app-shell.html", "<p>Entry</p>"),
2745 ("root-a.html", "<shared-detail></shared-detail>"),
2746 ("root-b.html", "<shared-detail></shared-detail>"),
2747 ("shared-detail.html", "<p>Shared</p>"),
2748 ]);
2749 let build_files = |roots: Vec<String>| {
2750 let mut options = default_options(app.path());
2751 options.plugin = Some(Plugin::WebUI);
2752 options.component_asset_roots = roots;
2753 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
2754 build(options).unwrap().component_asset_files
2755 };
2756
2757 let forward = build_files(vec!["root-a".to_string(), "root-b".to_string()]);
2758 let reverse = build_files(vec!["root-b".to_string(), "root-a".to_string()]);
2759
2760 assert_eq!(forward, reverse);
2761 let chunk_name = &forward[2].name;
2762 assert!(chunk_name.starts_with("chunk-shared-detail-"));
2763 assert!(forward[0].content.contains(chunk_name));
2764 assert!(forward[1].content.contains(chunk_name));
2765 }
2766
2767 #[test]
2768 fn test_component_asset_style_preloads_use_final_css_hrefs() {
2769 let app = create_app_dir(&[
2770 ("index.html", "<app-shell></app-shell>"),
2771 ("app-shell.html", "<p>Entry</p>"),
2772 ("lazy-panel.html", "<p>Lazy</p>"),
2773 ("lazy-panel.css", ":host { display: block; }"),
2774 ]);
2775 let mut options = default_options(app.path());
2776 options.plugin = Some(Plugin::WebUI);
2777 options.component_asset_roots = vec!["lazy-panel".to_string()];
2778 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
2779 options.css_public_base = Some("https://cdn.example.com/webui".to_string());
2780
2781 let result = build(options).unwrap();
2782 let preload = &result.protocol.component_asset_style_preloads[0];
2783
2784 assert_eq!(preload.root, "lazy-panel");
2785 assert_eq!(preload.style_hrefs.len(), 1);
2786 assert!(preload.style_hrefs[0].starts_with("https://cdn.example.com/webui/lazy-panel-"));
2787 assert!(preload.style_hrefs[0].ends_with(".css"));
2788 }
2789
2790 #[test]
2791 fn test_light_component_asset_styles_are_retained_for_document_links() {
2792 let app = create_app_dir(&[
2793 ("index.html", "<app-shell></app-shell>"),
2794 ("app-shell.html", "<p>Entry</p>"),
2795 ("z-lazy-panel.html", "<a-lazy-child></a-lazy-child>"),
2796 ("z-lazy-panel.css", ".z-lazy-panel { display: block; }"),
2797 ("a-lazy-child.html", "<p>Child</p>"),
2798 ("a-lazy-child.css", ".a-lazy-child { color: green; }"),
2799 ]);
2800 let mut options = default_options(app.path());
2801 options.plugin = Some(Plugin::WebUI);
2802 options.dom = DomStrategy::Light;
2803 options.component_asset_roots = vec!["z-lazy-panel".to_string()];
2804
2805 let result = build(options).unwrap();
2806
2807 assert_eq!(
2808 result.protocol.component_asset_style_preloads,
2809 vec![webui_protocol::ComponentAssetStylePreload {
2810 root: "z-lazy-panel".to_string(),
2811 style_hrefs: vec![
2812 "z-lazy-panel.css".to_string(),
2813 "a-lazy-child.css".to_string(),
2814 ],
2815 }]
2816 );
2817 }
2818
2819 #[test]
2820 fn test_metafile_describes_dynamic_import_graph() {
2821 let app = create_app_dir(&[
2822 ("index.html", "<app-shell></app-shell>"),
2823 ("app-shell.html", "<p>Entry</p>"),
2824 ("root-a.html", "<shared-detail></shared-detail>"),
2825 ("root-b.html", "<shared-detail></shared-detail>"),
2826 ("shared-detail.html", "<p>Shared</p>"),
2827 ]);
2828 let mut options = default_options(app.path());
2829 options.plugin = Some(Plugin::WebUI);
2830 options.component_asset_roots = vec!["root-a".to_string(), "root-b".to_string()];
2831 options.metafile = true;
2832
2833 let result = build(options).unwrap();
2834 let metafile = result.metafile.as_deref().unwrap();
2835 let value: serde_json::Value = serde_json::from_str(metafile).unwrap();
2836 assert!(value["inputs"]
2837 .get("webui:component/shared-detail")
2838 .is_some());
2839 assert_eq!(
2840 value["outputs"]["root-a.webui.js"]["entryPoint"],
2841 "webui:component/root-a"
2842 );
2843 assert_eq!(
2844 value["outputs"]["root-a.webui.js"]["imports"][0]["path"],
2845 "chunk-shared-detail.webui.js"
2846 );
2847 assert_eq!(
2848 value["outputs"]["root-a.webui.js"]["imports"][0]["kind"],
2849 "dynamic-import"
2850 );
2851 assert_eq!(
2852 value["inputs"]["webui:component/root-a"]["imports"][0]["kind"],
2853 "dynamic-import"
2854 );
2855 assert_eq!(
2856 value["outputs"]["chunk-shared-detail.webui.js"]["bytes"],
2857 result.component_asset_files[2].content.len()
2858 );
2859 }
2860
2861 #[test]
2862 fn test_component_assets_reject_routes_with_stable_diagnostic() {
2863 let app = create_app_dir(&[
2864 (
2865 "index.html",
2866 r#"<route path="/" component="app-shell" exact />"#,
2867 ),
2868 ("app-shell.html", "<p>Entry</p>"),
2869 ("lazy-panel.html", "<p>Lazy</p>"),
2870 ]);
2871 let mut options = default_options(app.path());
2872 options.plugin = Some(Plugin::WebUI);
2873 options.component_asset_roots = vec!["lazy-panel".to_string()];
2874
2875 let error = build(options).unwrap_err();
2876 match error {
2877 WebUIError::ComponentAssets(diagnostic) => {
2878 assert_eq!(
2879 diagnostic.error_code(),
2880 Some(webui_parser::codes::COMPONENT_ASSETS_WITH_ROUTES)
2881 );
2882 assert!(diagnostic.help_text().is_some());
2883 }
2884 other => panic!("expected route diagnostic, received {other}"),
2885 }
2886 }
2887
2888 #[test]
2889 fn test_component_asset_filename_collision_with_css_is_rejected() {
2890 let app = create_app_dir(&[
2891 ("index.html", "<app-shell></app-shell>"),
2892 ("app-shell.html", "<div></div>"),
2893 ("lazy-panel.html", "<p>Lazy</p>"),
2894 ("lazy-panel.css", ".panel { color: red; }"),
2895 ]);
2896 let mut options = default_options(app.path());
2897 options.plugin = Some(Plugin::WebUI);
2898 options.component_asset_roots = vec!["lazy-panel".to_string()];
2899 options.css_file_name_template = "[name]".to_string();
2900
2901 let result = build(options);
2902
2903 assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
2904 }
2905
2906 #[test]
2907 fn test_build_to_disk_rejects_protocol_name_collision_before_writing() {
2908 let app = create_app_dir(&[
2909 ("index.html", "<app-shell></app-shell>"),
2910 ("app-shell.html", "<div></div>"),
2911 ("lazy-panel.html", "<p>Lazy</p>"),
2912 ]);
2913 let out = TempDir::new().unwrap();
2914 let mut options = default_options(app.path());
2915 options.plugin = Some(Plugin::WebUI);
2916 options.component_asset_roots = vec!["lazy-panel".to_string()];
2917 options.css_file_name_template = "protocol.bin".to_string();
2918
2919 let result = build_to_disk(options, out.path());
2920
2921 assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
2922 assert!(!out.path().join("protocol.bin").exists());
2923 }
2924
2925 #[test]
2926 fn test_css_href_set_for_light_dom_link_strategy() {
2927 let app = create_app_dir(&[
2928 ("index.html", "<has-css>A</has-css><no-css>B</no-css>"),
2929 ("has-css.html", "<p>styled</p>"),
2930 ("has-css.css", ".yes { color: green; }"),
2931 ("no-css.html", "<p>plain</p>"),
2932 ]);
2933 let options = default_options(app.path());
2934 let result = build(options).unwrap();
2935
2936 let href = result
2937 .protocol
2938 .components
2939 .get("has-css")
2940 .map(|c| c.css_href.as_str())
2941 .unwrap_or("");
2942 assert_eq!(
2943 href, "has-css.css",
2944 "Light×Link component with CSS should have css_href"
2945 );
2946
2947 let no_href = result
2948 .protocol
2949 .components
2950 .get("no-css")
2951 .map(|c| c.css_href.as_str())
2952 .unwrap_or("");
2953 assert!(
2954 no_href.is_empty(),
2955 "Component without CSS should have empty css_href"
2956 );
2957 }
2958
2959 #[test]
2960 fn test_plugin_free_build_persists_component_shadow_usage() {
2961 let app = create_app_dir(&[
2962 (
2963 "index.html",
2964 "<light-card></light-card><shadow-card></shadow-card>",
2965 ),
2966 ("light-card.html", "<p>light</p>"),
2967 (
2968 "shadow-card.html",
2969 "<template shadowrootmode=\"open\"><slot></slot></template>",
2970 ),
2971 ]);
2972 let mut options = default_options(app.path());
2973 options.dom = DomStrategy::Light;
2974 options.plugin = None;
2975 let result = build(options).unwrap();
2976
2977 assert!(!result.protocol.component_uses_shadow_dom("light-card"));
2978 assert!(result.protocol.component_uses_shadow_dom("shadow-card"));
2979 assert!(!result.protocol.components["light-card"].uses_shadow_dom);
2980 assert!(result.protocol.components["shadow-card"].uses_shadow_dom);
2981
2982 let entry_html: String = result.protocol.fragments["index.html"]
2983 .fragments
2984 .iter()
2985 .filter_map(|fragment| match fragment.fragment.as_ref() {
2986 Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
2987 _ => None,
2988 })
2989 .collect();
2990 assert!(entry_html.contains("<light-card>"));
2991 assert!(entry_html.contains("<shadow-card>"));
2992 assert!(!entry_html.contains("<shadow-card data-wl"));
2993 }
2994
2995 #[test]
2996 fn test_authored_shadow_under_light_matches_ssr_protocol_and_webui_metadata() {
2997 let app = create_app_dir(&[
2998 ("index.html", "<shadow-card>projected</shadow-card>"),
2999 (
3000 "shadow-card.html",
3001 "<!-- lead --><template shadowrootmode=\"open\"><slot></slot></template><!-- tail -->",
3002 ),
3003 ]);
3004 let mut options = default_options(app.path());
3005 options.plugin = Some(Plugin::WebUI);
3006 let result = build(options).unwrap();
3007
3008 assert!(result.protocol.component_uses_shadow_dom("shadow-card"));
3009 assert!(result.protocol.components["shadow-card"]
3010 .template_json
3011 .contains("\"sd\":1"));
3012
3013 let component_html: String = result.protocol.fragments["shadow-card"]
3014 .fragments
3015 .iter()
3016 .filter_map(|fragment| match fragment.fragment.as_ref() {
3017 Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
3018 _ => None,
3019 })
3020 .collect();
3021 assert_eq!(
3022 component_html,
3023 "<template shadowrootmode=\"open\"><slot></slot></template>"
3024 );
3025 let entry_html: String = result.protocol.fragments["index.html"]
3026 .fragments
3027 .iter()
3028 .filter_map(|fragment| match fragment.fragment.as_ref() {
3029 Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
3030 _ => None,
3031 })
3032 .collect();
3033 assert!(!entry_html.contains("data-wl"));
3034 }
3035
3036 #[test]
3037 fn test_shadow_dom_link_strategy_sets_css_href() {
3038 let app = create_app_dir(&[
3042 ("index.html", "<my-card>A</my-card>"),
3043 (
3044 "my-card.html",
3045 r#"<template shadowrootmode="open"><p><slot></slot></p></template>"#,
3046 ),
3047 ("my-card.css", ".card { color: red; }"),
3048 ]);
3049 let result = build(default_options(app.path())).unwrap();
3050
3051 let comp = result.protocol.components.get("my-card").unwrap();
3052 assert_eq!(
3053 comp.css_href, "my-card.css",
3054 "Shadow×Link should set css_href"
3055 );
3056
3057 assert_eq!(
3059 result.protocol.css_strategy(),
3060 webui_protocol::CssStrategy::Link,
3061 );
3062
3063 assert_eq!(
3065 result.css_files.len(),
3066 1,
3067 "CSS file should still be produced"
3068 );
3069 }
3070
3071 #[test]
3072 fn test_css_href_empty_for_style_strategy() {
3073 let app = create_app_dir(&[
3074 ("index.html", "<my-card>A</my-card>"),
3075 (
3076 "my-card.html",
3077 r#"<template shadowrootmode="open"><p><slot></slot></p></template>"#,
3078 ),
3079 ("my-card.css", ".card { color: red; }"),
3080 ]);
3081 let mut options = default_options(app.path());
3082 options.css = CssStrategy::Style;
3083 let result = build(options).unwrap();
3084
3085 let href = result
3086 .protocol
3087 .components
3088 .get("my-card")
3089 .map(|c| c.css_href.as_str())
3090 .unwrap_or("");
3091 assert!(href.is_empty(), "Style-strategy should not set css_href");
3092 }
3093
3094 #[test]
3095 fn test_css_href_empty_for_module_strategy() {
3096 let app = create_app_dir(&[
3097 ("index.html", "<my-card>A</my-card>"),
3098 (
3099 "my-card.html",
3100 r#"<template shadowrootmode="open"><p><slot></slot></p></template>"#,
3101 ),
3102 ("my-card.css", ".card { color: red; }"),
3103 ]);
3104 let mut options = default_options(app.path());
3105 options.css = CssStrategy::Module;
3106 let result = build(options).unwrap();
3107
3108 let href = result
3109 .protocol
3110 .components
3111 .get("my-card")
3112 .map(|c| c.css_href.as_str())
3113 .unwrap_or("");
3114 assert!(href.is_empty(), "Module-strategy should not set css_href");
3115 assert!(
3116 !result.protocol.components["my-card"].css.is_empty(),
3117 "Module-strategy should still populate css content"
3118 );
3119 }
3120
3121 #[test]
3122 fn test_build_to_disk_writes_files() {
3123 let app = create_app_dir(&[
3124 ("index.html", "<my-card>Hello</my-card>"),
3125 (
3126 "my-card.html",
3127 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
3128 ),
3129 ("my-card.css", ".card { color: red; }"),
3130 ("my-card.ts", "export {};"),
3131 ]);
3132 let out = TempDir::new().unwrap();
3133
3134 let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
3135
3136 assert!(out.path().join("protocol.bin").exists());
3137 assert!(out.path().join("my-card.css").exists());
3138 assert_eq!(stats.css_file_count, 1);
3139 assert!(stats.fragment_count > 0);
3140 }
3141
3142 #[test]
3143 fn test_build_to_disk_creates_nested_output_dir() {
3144 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
3145 let out = TempDir::new().unwrap();
3146 let nested = out.path().join("nested").join("output");
3147
3148 let stats = build_to_disk(default_options(app.path()), &nested).unwrap();
3149 assert!(nested.join("protocol.bin").exists());
3150 assert!(stats.fragment_count > 0);
3151 }
3152
3153 #[test]
3154 fn test_build_missing_entry() {
3155 let app = create_app_dir(&[("other.html", "<h1>Not index</h1>")]);
3156 let result = build(default_options(app.path()));
3157
3158 assert!(result.is_err());
3159 let err = result.unwrap_err();
3160 assert!(matches!(err, WebUIError::Io { .. }));
3161 }
3162
3163 #[test]
3164 fn test_build_stats_populated() {
3165 let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
3166 let result = build(default_options(app.path())).unwrap();
3167
3168 assert!(result.stats.fragment_count > 0);
3169 assert!(result.stats.protocol_size_bytes > 0);
3170 assert_eq!(result.stats.css_file_count, 0);
3171 }
3172
3173 #[test]
3174 fn test_build_inline_css() {
3175 let app = create_app_dir(&[
3176 ("index.html", "<my-card>Hello</my-card>"),
3177 (
3178 "my-card.html",
3179 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
3180 ),
3181 ("my-card.css", ".card { color: red; }"),
3182 ]);
3183 let mut options = default_options(app.path());
3184 options.css = CssStrategy::Style;
3185
3186 let result = build(options).unwrap();
3187 assert!(result.css_files.is_empty());
3189 assert_eq!(result.stats.css_file_count, 0);
3190 assert!(result.stats.fragment_count > 0);
3191 }
3192
3193 #[test]
3194 fn test_build_module_css() {
3195 let app = create_app_dir(&[
3196 ("index.html", "<my-card>Hello</my-card>"),
3197 (
3198 "my-card.html",
3199 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
3200 ),
3201 ("my-card.css", ".card { color: red; }"),
3202 ]);
3203 let mut options = default_options(app.path());
3204 options.css = CssStrategy::Module;
3205
3206 let result = build(options).unwrap();
3207 assert!(result.css_files.is_empty());
3209 assert_eq!(result.stats.css_file_count, 0);
3210 assert!(result.stats.fragment_count > 0);
3211 }
3212
3213 #[test]
3214 fn test_build_custom_entry() {
3215 let app = create_app_dir(&[("page.html", "<h1>Custom</h1>")]);
3216 let mut options = default_options(app.path());
3217 options.entry = "page.html".to_string();
3218
3219 let result = build(options).unwrap();
3220 assert!(result.protocol.fragments.contains_key("page.html"));
3221 assert!(!result.protocol.fragments.contains_key("index.html"));
3222 }
3223
3224 #[test]
3225 fn test_build_protocol_roundtrip() {
3226 let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
3227 let result = build(default_options(app.path())).unwrap();
3228
3229 let restored = WebUIProtocol::from_protobuf(&result.protocol_bytes).unwrap();
3230 assert!(restored.fragments.contains_key("index.html"));
3231 }
3232
3233 #[test]
3234 fn protocol_is_available_from_facade() {
3235 let protocol = Protocol::new(WebUIProtocol::new(HashMap::new()));
3236 assert!(protocol.tokens().is_empty());
3237 }
3238
3239 #[test]
3240 fn test_inspect_bytes_valid() {
3241 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
3242 let result = build(default_options(app.path())).unwrap();
3243
3244 let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
3245 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
3246 assert!(parsed.get("fragments").is_some());
3247 }
3248
3249 #[test]
3250 fn test_inspect_bytes_invalid() {
3251 let result = inspect_bytes(b"not a protobuf");
3252 assert!(result.is_err());
3253 assert!(matches!(result.unwrap_err(), WebUIError::Protocol(_)));
3254 }
3255
3256 #[test]
3257 fn test_inspect_file() {
3258 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
3259 let out = TempDir::new().unwrap();
3260 build_to_disk(default_options(app.path()), out.path()).unwrap();
3261
3262 let json_str = inspect(&out.path().join("protocol.bin")).unwrap();
3263 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
3264 assert!(parsed["fragments"]["index.html"]["fragments"].is_array());
3265 }
3266
3267 #[test]
3268 fn test_inspect_missing_file() {
3269 let result = inspect(Path::new("/nonexistent/protocol.bin"));
3270 assert!(result.is_err());
3271 assert!(matches!(result.unwrap_err(), WebUIError::Io { .. }));
3272 }
3273
3274 #[test]
3275 fn test_build_with_components_local_path() {
3276 let app = create_app_dir(&[("index.html", "<ext-card>Hello</ext-card>")]);
3277 let ext_dir = TempDir::new().unwrap();
3278 fs::write(
3279 ext_dir.path().join("ext-card.html"),
3280 r#"<template shadowrootmode="open"><div class="card"><slot></slot></div></template>"#,
3281 )
3282 .unwrap();
3283 fs::write(
3284 ext_dir.path().join("ext-card.css"),
3285 ".card { border: 1px solid #ccc; }",
3286 )
3287 .unwrap();
3288
3289 let mut options = default_options(app.path());
3290 options.components = vec![ext_dir.path().to_string_lossy().to_string()];
3291
3292 let result = build(options).unwrap();
3293 assert!(result.protocol.fragments.contains_key("index.html"));
3294 assert_eq!(result.css_files.len(), 1);
3295 assert!(result.css_files[0].1.contains("border"));
3296 }
3297
3298 #[test]
3299 fn test_build_with_scriptless_component_path_emits_dormant_template() {
3300 let app = create_app_dir(&[("index.html", "<ext-card></ext-card>")]);
3301 let ext_dir = TempDir::new().unwrap();
3302 fs::write(ext_dir.path().join("ext-card.html"), "<p>{{title}}</p>").unwrap();
3303
3304 let mut options = default_options(app.path());
3305 options.plugin = Some(Plugin::WebUI);
3306 options.components = vec![ext_dir.path().to_string_lossy().to_string()];
3307
3308 let result = build(options).unwrap();
3309 assert!(result
3310 .component_templates
3311 .iter()
3312 .any(|template| template.tag_name == "ext-card"));
3313 let component = result
3314 .protocol
3315 .components
3316 .get("ext-card")
3317 .expect("external scriptless component metadata should be retained");
3318 assert!(component.hydration_keys.is_empty());
3319 assert_eq!(component.navigation_keys, ["title"]);
3320 assert!(component.template_json.contains(r#""th":1"#));
3321 }
3322
3323 #[test]
3324 fn test_build_with_scripted_component_path_emits_client_artifact() {
3325 let app = create_app_dir(&[("index.html", "<ext-card></ext-card>")]);
3326 let ext_dir = TempDir::new().unwrap();
3327 fs::write(ext_dir.path().join("ext-card.html"), "<p>{{title}}</p>").unwrap();
3328 fs::write(ext_dir.path().join("ext-card.ts"), "export {};").unwrap();
3329
3330 let mut options = default_options(app.path());
3331 options.plugin = Some(Plugin::WebUI);
3332 options.components = vec![ext_dir.path().to_string_lossy().to_string()];
3333
3334 let result = build(options).unwrap();
3335 let template = &result.protocol.components["ext-card"].template_json;
3336 assert!(!template.is_empty(), "template should be emitted");
3337 }
3338
3339 #[test]
3340 fn test_build_rejects_projection_manifest_with_non_webui_plugin() {
3341 let app = create_app_dir(&[
3342 ("index.html", "<demo-card></demo-card>"),
3343 ("demo-card.html", "<p>{{name}}</p>"),
3344 ("demo-card.ts", "export {};"),
3345 ]);
3346 let manifest_json = projection::test_support::build_valid_manifest_json(
3347 app.path(),
3348 &[("demo-card.ts", "export {};")],
3349 &[("projection-bundle.js", "output")],
3350 &[(
3351 "demo-card",
3352 "demo-card.ts",
3353 &["projection-bundle.js"],
3354 &["name"],
3355 &["name"],
3356 )],
3357 );
3358 let manifest = projection::test_support::write_manifest(
3359 app.path(),
3360 "webui-projection.json",
3361 &manifest_json,
3362 );
3363 let mut options = default_options(app.path());
3364 options.projection_manifests = vec![manifest.into()];
3367 let err = build(options).expect_err("non-WebUI plugin + manifest should be rejected");
3368 assert!(
3369 err.chain_message().contains("PROJ-B002"),
3370 "msg: {}",
3371 err.chain_message()
3372 );
3373 }
3374
3375 #[test]
3376 fn test_build_rejects_missing_scripted_component_coverage() {
3377 let app = create_app_dir(&[
3378 ("index.html", "<demo-card></demo-card>"),
3379 ("demo-card.html", "<p>{{name}}</p>"),
3380 ("demo-card.ts", "export {};"),
3381 ]);
3382 let manifest_json =
3385 projection::test_support::build_valid_manifest_json(app.path(), &[], &[], &[]);
3386 let manifest = projection::test_support::write_manifest(
3387 app.path(),
3388 "webui-projection.json",
3389 &manifest_json,
3390 );
3391 let mut options = default_options(app.path());
3392 options.plugin = Some(Plugin::WebUI);
3393 options.projection_manifests = vec![manifest.into()];
3394 let err = build(options).expect_err("missing coverage should fail the build");
3395 let message = err.chain_message();
3396 assert!(message.contains("PROJ-B001"), "msg: {message}");
3397 assert!(message.contains("demo-card"), "msg: {message}");
3398 }
3399
3400 #[test]
3401 fn test_build_allows_unused_discovered_scripted_component_without_coverage() {
3402 let app = create_app_dir(&[
3403 ("index.html", "<demo-card></demo-card>"),
3404 ("demo-card.html", "<p>{{name}}</p>"),
3405 ("demo-card.ts", "export {};"),
3406 ("unused-card.html", "<p>{{title}}</p>"),
3410 ("unused-card.ts", "export {};"),
3411 ]);
3412 let manifest_json = projection::test_support::build_valid_manifest_json(
3413 app.path(),
3414 &[("demo-card.ts", "export {};")],
3415 &[("projection-bundle.js", "output")],
3416 &[(
3417 "demo-card",
3418 "demo-card.ts",
3419 &["projection-bundle.js"],
3420 &["name"],
3421 &["name"],
3422 )],
3423 );
3424 let manifest = projection::test_support::write_manifest(
3425 app.path(),
3426 "webui-projection.json",
3427 &manifest_json,
3428 );
3429 let mut options = default_options(app.path());
3430 options.plugin = Some(Plugin::WebUI);
3431 options.projection_manifests = vec![manifest.into()];
3432 let result = build(options).unwrap();
3433
3434 assert!(!result.protocol.fragments.contains_key("unused-card"));
3435 let component = result.protocol.components.get("demo-card").unwrap();
3436 assert_eq!(component.hydration_keys, vec!["name"]);
3437 }
3438
3439 #[test]
3440 fn test_build_allows_asset_only_scripted_component_without_projection_coverage() {
3441 let app = create_app_dir(&[
3442 ("index.html", "<app-shell></app-shell>"),
3443 ("app-shell.html", "<p>Entry</p>"),
3444 ("lazy-panel.html", "<p>{{title}}</p>"),
3445 ("lazy-panel.ts", "export {};"),
3446 ]);
3447 let manifest_json =
3448 projection::test_support::build_valid_manifest_json(app.path(), &[], &[], &[]);
3449 let manifest = projection::test_support::write_manifest(
3450 app.path(),
3451 "webui-projection.json",
3452 &manifest_json,
3453 );
3454 let mut options = default_options(app.path());
3455 options.plugin = Some(Plugin::WebUI);
3456 options.component_asset_roots = vec!["lazy-panel".to_string()];
3457 options.projection_manifests = vec![manifest.into()];
3458
3459 let result = build(options).unwrap();
3460
3461 assert_eq!(result.component_asset_files.len(), 1);
3462 assert!(!result.protocol.fragments.contains_key("lazy-panel"));
3463 assert!(!result.protocol.components.contains_key("lazy-panel"));
3464 }
3465
3466 #[test]
3467 fn test_build_merges_multiple_projection_manifest_fragments() {
3468 let app = create_app_dir(&[
3469 (
3470 "index.html",
3471 "<html><body><card-a></card-a><card-b></card-b></body></html>",
3472 ),
3473 ("card-a.html", "<p>{{alpha}}</p>"),
3474 ("card-a.ts", "export {};"),
3475 ("card-b.html", "<p>{{beta}}</p>"),
3476 ("card-b.ts", "export {};"),
3477 ]);
3478 let json_a = projection::test_support::build_valid_manifest_json(
3479 app.path(),
3480 &[("card-a.ts", "export {};")],
3481 &[("card-a.js", "output-a")],
3482 &[(
3483 "card-a",
3484 "card-a.ts",
3485 &["card-a.js"],
3486 &["alpha"],
3487 &["alpha"],
3488 )],
3489 );
3490 let manifest_a =
3491 projection::test_support::write_manifest(app.path(), "fragment-a.json", &json_a);
3492 let json_b = projection::test_support::build_valid_manifest_json(
3493 app.path(),
3494 &[("card-b.ts", "export {};")],
3495 &[("card-b.js", "output-b")],
3496 &[("card-b", "card-b.ts", &["card-b.js"], &["beta"], &["beta"])],
3497 );
3498 let manifest_b =
3499 projection::test_support::write_manifest(app.path(), "fragment-b.json", &json_b);
3500
3501 let mut options = default_options(app.path());
3502 options.plugin = Some(Plugin::WebUI);
3503 options.projection_manifests = vec![manifest_a.into(), manifest_b.into()];
3504 let result = build(options).unwrap();
3505
3506 assert_eq!(
3507 result.protocol.initial_state_strategy,
3508 webui_protocol::InitialStateStrategy::Components as i32
3509 );
3510 let card_a = result.protocol.components.get("card-a").unwrap();
3511 assert_eq!(card_a.hydration_keys, vec!["alpha"]);
3512 let card_b = result.protocol.components.get("card-b").unwrap();
3513 assert_eq!(card_b.hydration_keys, vec!["beta"]);
3514 }
3515
3516 #[test]
3517 fn test_build_hello_world_example() {
3518 let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
3519 let app_dir = manifest_dir.join("../../examples/app/hello-world/src");
3520
3521 let result = build(BuildOptions {
3522 app_dir,
3523 entry: "index.html".to_string(),
3524 ..BuildOptions::default()
3525 })
3526 .unwrap();
3527
3528 let index = &result.protocol.fragments["index.html"].fragments;
3529 assert!(index.iter().any(
3530 |f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(fl)) if fl.collection == "people")
3531 ));
3532 assert!(index
3533 .iter()
3534 .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
3535 }
3536
3537 #[test]
3540 fn test_css_filename_sanitizes_path_separators() {
3541 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
3544 let result = build(default_options(app.path())).unwrap();
3545
3546 for (filename, _) in &result.css_files {
3548 assert!(
3549 !filename.contains('/') && !filename.contains('\\'),
3550 "CSS filename contains path separator: {filename}"
3551 );
3552 }
3553 }
3554
3555 #[test]
3556 fn test_build_to_disk_css_stays_in_output_dir() {
3557 let app = create_app_dir(&[
3558 ("index.html", "<my-card>Hello</my-card>"),
3559 (
3560 "my-card.html",
3561 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
3562 ),
3563 ("my-card.css", ".card { color: red; }"),
3564 ]);
3565 let out = TempDir::new().unwrap();
3566
3567 build_to_disk(default_options(app.path()), out.path()).unwrap();
3568
3569 let css_path = out.path().join("my-card.css");
3571 assert!(css_path.exists());
3572 let canonical = css_path.canonicalize().unwrap();
3573 let out_canonical = out.path().canonicalize().unwrap();
3574 assert!(
3575 canonical.starts_with(&out_canonical),
3576 "CSS file escaped output directory"
3577 );
3578 }
3579
3580 #[test]
3583 fn test_build_empty_html() {
3584 let app = create_app_dir(&[("index.html", "")]);
3585 let result = build(default_options(app.path())).unwrap();
3586
3587 assert!(result.protocol.fragments.contains_key("index.html"));
3588 assert_eq!(result.stats.fragment_count, 0);
3589 assert!(result.stats.protocol_size_bytes > 0);
3590 }
3591
3592 #[test]
3593 fn test_build_large_fragment_count() {
3594 let mut html = String::with_capacity(2000);
3596 for i in 0..50 {
3597 html.push_str(&format!("<p>Item {i}</p>\n"));
3598 }
3599 let app = create_app_dir(&[("index.html", &html)]);
3600 let result = build(default_options(app.path())).unwrap();
3601
3602 assert!(result.stats.fragment_count > 0);
3603 assert_eq!(result.stats.css_file_count, 0);
3604 }
3605
3606 #[test]
3607 fn test_build_to_disk_inline_mode_no_css_files() {
3608 let app = create_app_dir(&[
3609 ("index.html", "<my-card>Hello</my-card>"),
3610 (
3611 "my-card.html",
3612 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
3613 ),
3614 ("my-card.css", ".card { color: red; }"),
3615 ]);
3616 let out = TempDir::new().unwrap();
3617 let mut options = default_options(app.path());
3618 options.css = CssStrategy::Style;
3619
3620 let stats = build_to_disk(options, out.path()).unwrap();
3621
3622 assert!(out.path().join("protocol.bin").exists());
3623 assert!(!out.path().join("my-card.css").exists());
3624 assert_eq!(stats.css_file_count, 0);
3625 }
3626
3627 #[test]
3628 fn test_build_stats_duration_is_nonzero() {
3629 let app = create_app_dir(&[("index.html", "<h1>Hello {{name}}</h1>")]);
3630 let result = build(default_options(app.path())).unwrap();
3631
3632 assert!(!result.stats.duration.is_zero());
3633 }
3634
3635 #[test]
3636 fn test_build_multiple_components_css() {
3637 let app = create_app_dir(&[
3638 ("index.html", "<card-a>A</card-a><card-b>B</card-b>"),
3639 (
3640 "card-a.html",
3641 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
3642 ),
3643 ("card-a.css", ".a { color: red; }"),
3644 (
3645 "card-b.html",
3646 r#"<template shadowrootmode="open"><span><slot></slot></span></template>"#,
3647 ),
3648 ("card-b.css", ".b { color: blue; }"),
3649 ]);
3650 let result = build(default_options(app.path())).unwrap();
3651
3652 assert_eq!(result.css_files.len(), 2);
3653 assert_eq!(result.stats.css_file_count, 2);
3654 let filenames: Vec<&str> = result.css_files.iter().map(|(n, _)| n.as_str()).collect();
3655 assert!(filenames.contains(&"card-a.css"));
3656 assert!(filenames.contains(&"card-b.css"));
3657 }
3658
3659 #[test]
3660 fn test_build_unused_component_css_not_emitted() {
3661 let app = create_app_dir(&[
3663 ("index.html", "<card-a>A</card-a>"),
3664 (
3665 "card-a.html",
3666 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
3667 ),
3668 ("card-a.css", ".a { color: red; }"),
3669 (
3670 "card-b.html",
3671 r#"<template shadowrootmode="open"><span><slot></slot></span></template>"#,
3672 ),
3673 ("card-b.css", ".b { color: blue; }"),
3674 ]);
3675 let result = build(default_options(app.path())).unwrap();
3676
3677 assert_eq!(result.css_files.len(), 1);
3678 assert_eq!(result.css_files[0].0, "card-a.css");
3679 }
3680
3681 #[test]
3682 fn test_build_unused_component_render_policy_not_emitted() {
3683 let app = create_app_dir(&[
3684 ("index.html", "<card-a>A</card-a>"),
3685 (
3686 "card-a.html",
3687 r#"<template w-render="lazy" w-reserve-block-size="10px"><div>a</div></template>"#,
3688 ),
3689 (
3690 "card-b.html",
3691 r#"<template w-render="lazy" w-reserve-block-size="20px"><div>b</div></template>"#,
3692 ),
3693 ]);
3694 let result = build(default_options(app.path())).unwrap();
3695
3696 assert!(result.protocol.component_render_css.contains("card-a"));
3697 assert!(!result.protocol.component_render_css.contains("card-b"));
3698 }
3699
3700 #[test]
3701 fn test_build_persists_interaction_work_policy() {
3702 let app = create_app_dir(&[
3703 ("index.html", "<interaction-shell></interaction-shell>"),
3704 (
3705 "interaction-shell.html",
3706 r#"<template w-hydrate="interaction"><button>Open</button></template>"#,
3707 ),
3708 (
3709 "interaction-shell.ts",
3710 "customElements.define('interaction-shell', class extends HTMLElement {});",
3711 ),
3712 ]);
3713 for plugin in [Plugin::WebUI, Plugin::FastV3] {
3714 let is_webui = matches!(plugin, Plugin::WebUI);
3715 let mut options = default_options(app.path());
3716 options.plugin = Some(plugin);
3717 let result = build(options).expect("interaction policy build");
3718 let component = &result.protocol.components["interaction-shell"];
3719 assert_eq!(
3720 component.work_policy,
3721 webui_protocol::ComponentWorkPolicy::Interaction as i32
3722 );
3723 if is_webui {
3724 assert!(component.template_json.contains(r#""wp":3"#));
3725 }
3726 }
3727 }
3728
3729 #[test]
3730 fn test_build_persists_combined_render_interaction_policy() {
3731 let app = create_app_dir(&[
3732 ("index.html", "<interaction-panel></interaction-panel>"),
3733 (
3734 "interaction-panel.html",
3735 concat!(
3736 r#"<template w-render="lazy" w-reserve-block-size="18rem" "#,
3737 r#"w-hydrate="interaction"><button>Open</button></template>"#,
3738 ),
3739 ),
3740 (
3741 "interaction-panel.ts",
3742 "customElements.define('interaction-panel', class extends HTMLElement {});",
3743 ),
3744 ]);
3745 for plugin in [Plugin::WebUI, Plugin::FastV3] {
3746 let is_webui = matches!(plugin, Plugin::WebUI);
3747 let mut options = default_options(app.path());
3748 options.plugin = Some(plugin);
3749 let result = build(options).expect("combined interaction policy build");
3750 let component = &result.protocol.components["interaction-panel"];
3751 assert_eq!(
3752 component.work_policy,
3753 webui_protocol::ComponentWorkPolicy::LazyRenderInteraction as i32
3754 );
3755 assert!(result
3756 .protocol
3757 .component_render_css
3758 .contains("interaction-panel"));
3759 if is_webui {
3760 assert!(component.template_json.contains(r#""wp":4"#));
3761 }
3762 }
3763 }
3764
3765 #[test]
3766 fn test_inspect_roundtrip_preserves_content() {
3767 let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
3768 let result = build(default_options(app.path())).unwrap();
3769
3770 let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
3771 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
3772
3773 let fragments = &parsed["fragments"]["index.html"]["fragments"];
3774 assert!(fragments.is_array());
3775 assert!(!fragments.as_array().unwrap().is_empty());
3776 }
3777
3778 #[test]
3779 fn test_build_nonexistent_app_dir() {
3780 let options = BuildOptions {
3781 app_dir: PathBuf::from("/nonexistent/path/that/does/not/exist"),
3782 ..BuildOptions::default()
3783 };
3784 let result = build(options);
3785 assert!(result.is_err());
3786 }
3787
3788 #[test]
3789 fn test_build_allows_fallback_chain_when_all_theme_tokens_exist() {
3790 let app = create_app_dir(&[
3791 ("index.html", "<my-card></my-card>"),
3792 ("my-card.html", "<div>Card</div>"),
3793 (
3794 "my-card.css",
3795 ":host { color: var(--token-a, var(--token-b, var(--token-c))); }",
3796 ),
3797 ]);
3798 let mut options = default_options(app.path());
3799 options.theme = Some(TokenFile {
3800 themes: HashMap::from([(
3801 "light".to_string(),
3802 HashMap::from([
3803 ("token-a".to_string(), "red".to_string()),
3804 ("token-b".to_string(), "blue".to_string()),
3805 ("token-c".to_string(), "green".to_string()),
3806 ]),
3807 )]),
3808 });
3809
3810 let result = build(options).expect("all fallback tokens are defined");
3811 assert!(result.protocol.tokens.contains(&"token-a".to_string()));
3812 assert!(result.protocol.tokens.contains(&"token-b".to_string()));
3813 assert!(result.protocol.tokens.contains(&"token-c".to_string()));
3814 }
3815
3816 #[test]
3817 fn test_build_allows_literal_fallback_token_absent_from_theme() {
3818 let app = create_app_dir(&[
3819 ("index.html", "<my-card></my-card>"),
3820 ("my-card.html", "<div>Card</div>"),
3821 ("my-card.css", ":host { color: var(--brand, #000); }"),
3822 ]);
3823 let mut options = default_options(app.path());
3824 options.theme = Some(TokenFile {
3825 themes: HashMap::from([("light".to_string(), HashMap::new())]),
3826 });
3827
3828 let result = build(options).expect("literal fallback should not fail the build");
3832 assert!(result.protocol.tokens.contains(&"brand".to_string()));
3833 assert_eq!(result.warnings.len(), 1, "warnings: {:?}", result.warnings);
3836 assert!(result.warnings[0].body().contains("--brand"));
3837 }
3838
3839 #[test]
3840 fn test_build_ancestor_inline_style_definition_after_comment_satisfies_descendant() {
3841 let app = create_app_dir(&[
3846 (
3847 "index.html",
3848 "<html><head><style>:root {\n /*{{{tokens.light}}}*/\n --foo-bar: 100px;\n}</style></head><body><my-card></my-card></body></html>",
3849 ),
3850 ("my-card.html", "<div>Card</div>"),
3851 ("my-card.css", ":host { padding: var(--foo-bar); }"),
3852 ]);
3853 let mut options = default_options(app.path());
3854 options.theme = Some(TokenFile {
3855 themes: HashMap::from([("light".to_string(), HashMap::new())]),
3856 });
3857
3858 let result = build(options)
3859 .expect("ancestor inline-style definition (after a comment) should satisfy the child");
3860 assert!(
3861 !result.protocol.tokens.contains(&"foo-bar".to_string()),
3862 "ancestor-defined token should not be hoisted: {:?}",
3863 result.protocol.tokens
3864 );
3865 }
3866
3867 #[test]
3868 fn test_build_no_warning_when_literal_fallback_token_is_themed() {
3869 let app = create_app_dir(&[
3870 ("index.html", "<my-card></my-card>"),
3871 ("my-card.html", "<div>Card</div>"),
3872 ("my-card.css", ":host { color: var(--brand, #000); }"),
3873 ]);
3874 let mut options = default_options(app.path());
3875 options.theme = Some(TokenFile {
3876 themes: HashMap::from([(
3877 "light".to_string(),
3878 HashMap::from([("brand".to_string(), "#123456".to_string())]),
3879 )]),
3880 });
3881
3882 let result = build(options).expect("themed literal-fallback token builds");
3883 assert!(
3884 result.warnings.is_empty(),
3885 "a themed token must not warn: {:?}",
3886 result.warnings
3887 );
3888 }
3889
3890 #[test]
3891 fn test_build_without_theme_produces_no_warnings() {
3892 let app = create_app_dir(&[
3893 ("index.html", "<my-card></my-card>"),
3894 ("my-card.html", "<div>Card</div>"),
3895 ("my-card.css", ":host { color: var(--brand, #000); }"),
3896 ]);
3897 let result = build(default_options(app.path())).unwrap();
3898 assert!(result.warnings.is_empty());
3899 }
3900
3901 #[test]
3902 fn test_build_excludes_tokens_defined_by_ancestor_component_css() {
3903 let app = create_app_dir(&[
3904 ("index.html", "<component-a></component-a>"),
3905 ("component-a.html", "<component-b></component-b>"),
3906 ("component-a.css", ":host { --brand-color: red; }"),
3907 ("component-b.html", "<div>Child</div>"),
3908 ("component-b.css", ":host { color: var(--brand-color); }"),
3909 ]);
3910 let mut options = default_options(app.path());
3911 options.theme = Some(TokenFile {
3912 themes: HashMap::from([("light".to_string(), HashMap::new())]),
3913 });
3914
3915 let result =
3916 build(options).expect("ancestor component custom property should satisfy child");
3917 assert!(
3918 !result.protocol.tokens.contains(&"brand-color".to_string()),
3919 "ancestor-defined token should not be exposed: {:?}",
3920 result.protocol.tokens
3921 );
3922 }
3923
3924 #[test]
3925 fn test_build_does_not_use_sibling_component_definitions_for_theme_validation() {
3926 let app = create_app_dir(&[
3927 (
3928 "index.html",
3929 "<component-a></component-a><component-b></component-b>",
3930 ),
3931 ("component-a.html", "<div>A</div>"),
3932 ("component-a.css", ":host { --brand-color: red; }"),
3933 ("component-b.html", "<div>B</div>"),
3934 ("component-b.css", ":host { color: var(--brand-color); }"),
3935 ]);
3936 let mut options = default_options(app.path());
3937 options.theme = Some(TokenFile {
3938 themes: HashMap::from([("light".to_string(), HashMap::new())]),
3939 });
3940
3941 let Err(err) = build(options) else {
3942 panic!("sibling custom property must not satisfy component-b");
3943 };
3944 let message = err.chain_message();
3945 assert!(message.contains("missing theme token"), "msg: {message}");
3946 assert!(message.contains("missing-theme-token"), "msg: {message}");
3947 assert!(message.contains("--brand-color"), "msg: {message}");
3948 }
3949
3950 #[test]
3951 fn test_build_rejects_missing_theme_token_after_local_definition_filter() {
3952 let app = create_app_dir(&[
3953 ("index.html", "<my-card></my-card>"),
3954 ("my-card.html", "<div>Card</div>"),
3955 (
3956 "my-card.css",
3957 ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c))); }",
3958 ),
3959 ]);
3960 let mut options = default_options(app.path());
3961 options.theme = Some(TokenFile {
3962 themes: HashMap::from([(
3963 "light".to_string(),
3964 HashMap::from([("token-b".to_string(), "green".to_string())]),
3965 )]),
3966 });
3967
3968 let Err(err) = build(options) else {
3969 panic!("missing --token-c in the theme must fail the build");
3970 };
3971 let message = err.chain_message();
3972 assert!(message.contains("missing-theme-token"), "msg: {message}");
3973 assert!(message.contains("--token-c"), "msg: {message}");
3974 assert!(!message.contains("add --token-a"), "msg: {message}");
3977 }
3978
3979 #[test]
3980 fn test_build_to_disk_returns_accurate_stats() {
3981 let app = create_app_dir(&[
3982 ("index.html", "<my-card>Hello</my-card><p>{{name}}</p>"),
3983 (
3984 "my-card.html",
3985 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
3986 ),
3987 ("my-card.css", ".card { color: red; }"),
3988 ]);
3989 let out = TempDir::new().unwrap();
3990
3991 let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
3992
3993 assert!(stats.fragment_count > 0);
3994 assert_eq!(stats.css_file_count, 1);
3995 assert!(stats.component_count > 0);
3996 assert!(stats.protocol_size_bytes > 0);
3997 assert!(!stats.duration.is_zero());
3998 }
3999}