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