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