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