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};
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 css_file_name_template: String,
271 pub css_public_base: Option<String>,
274 pub legal_comments: LegalComments,
276 pub theme: Option<TokenFile>,
284 pub projection_manifests: Vec<ProjectionManifestSource>,
305}
306
307impl Default for BuildOptions {
308 fn default() -> Self {
309 Self {
310 app_dir: std::path::PathBuf::from("."),
311 entry: "index.html".to_string(),
312 css: CssStrategy::Link,
313 dom: DomStrategy::Shadow,
314 plugin: None,
315 components: Vec::new(),
316 component_asset_roots: Vec::new(),
317 css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
318 css_public_base: None,
319 legal_comments: LegalComments::default(),
320 theme: None,
321 projection_manifests: Vec::new(),
322 }
323 }
324}
325
326#[derive(Debug, Clone)]
328pub struct BuildStats {
329 pub duration: std::time::Duration,
331 pub fragment_count: usize,
333 pub component_count: usize,
335 pub css_file_count: usize,
337 pub protocol_size_bytes: usize,
339 pub token_count: usize,
341}
342
343#[derive(Debug)]
345pub struct BuildResult {
346 pub protocol: WebUIProtocol,
348 pub protocol_bytes: Vec<u8>,
350 pub css_files: Vec<(String, String)>,
352 pub component_asset_files: Vec<ComponentAssetFile>,
356 pub component_templates: Vec<ComponentTemplateArtifact>,
360 pub warnings: Vec<Diagnostic>,
367 pub stats: BuildStats,
369}
370
371#[cold]
376#[inline(never)]
377fn streaming_without_projection_warning(entry: &str, boundary_count: usize) -> Diagnostic {
378 let plural = if boundary_count == 1 { "" } else { "s" };
379 Diagnostic::warning(format!(
380 "{entry} declares {boundary_count} streaming boundary checkpoint{plural} \
381 but this build has no state-projection manifest"
382 ))
383 .code(webui_parser::codes::STREAMING_WITHOUT_PROJECTION)
384 .help(
385 "every checkpoint will serialize the whole state object instead of its own \
386 components' keys; pass the client build's `webui-projection.json` through \
387 `BuildOptions::projection_manifests` to send boundary-local state only",
388 )
389}
390
391#[must_use = "BuildResult contains the compiled protocol and statistics"]
401pub fn build(options: BuildOptions) -> Result<BuildResult, WebUIError> {
402 let started = Instant::now();
403
404 let raw = build_protocol_inner(&options)?;
405
406 let protocol_bytes = raw.protocol.to_protobuf()?;
407
408 let stats = BuildStats {
409 duration: started.elapsed(),
410 fragment_count: raw.fragment_count,
411 component_count: raw.component_count,
412 css_file_count: raw.css_files.len(),
413 protocol_size_bytes: protocol_bytes.len(),
414 token_count: raw.token_count,
415 };
416
417 Ok(BuildResult {
418 protocol: raw.protocol,
419 protocol_bytes,
420 css_files: raw.css_files,
421 component_asset_files: raw.component_asset_files,
422 component_templates: raw.component_templates,
423 warnings: raw.warnings,
424 stats,
425 })
426}
427
428pub fn build_to_disk(options: BuildOptions, out_dir: &Path) -> Result<BuildStats, WebUIError> {
438 let result = build(options)?;
439 validate_output_file_names(OsStr::new("protocol.bin"), &result)?;
440
441 fs::create_dir_all(out_dir).map_err(|source| WebUIError::Io {
442 context: format!("Failed to create {}", out_dir.display()),
443 source,
444 })?;
445
446 fs::write(out_dir.join("protocol.bin"), &result.protocol_bytes).map_err(|source| {
447 WebUIError::Io {
448 context: format!("Failed to write protocol.bin to {}", out_dir.display()),
449 source,
450 }
451 })?;
452
453 for (name, content) in &result.css_files {
454 fs::write(out_dir.join(name), content).map_err(|source| WebUIError::Io {
455 context: format!("Failed to write {name} to {}", out_dir.display()),
456 source,
457 })?;
458 }
459 for file in &result.component_asset_files {
460 fs::write(out_dir.join(&file.name), &file.content).map_err(|source| WebUIError::Io {
461 context: format!(
462 "Failed to write component asset {} to {}",
463 file.name,
464 out_dir.display()
465 ),
466 source,
467 })?;
468 }
469
470 Ok(result.stats)
471}
472
473pub fn inspect(protocol_path: &Path) -> Result<String, WebUIError> {
475 let bytes = fs::read(protocol_path).map_err(|source| WebUIError::Io {
476 context: format!("Failed to read {}", protocol_path.display()),
477 source,
478 })?;
479 inspect_bytes(&bytes)
480}
481
482pub fn inspect_bytes(protocol_bytes: &[u8]) -> Result<String, WebUIError> {
484 let protocol = WebUIProtocol::from_protobuf(protocol_bytes)?;
485 protocol
486 .to_json_pretty()
487 .map_err(|e| WebUIError::Serialization(e.to_string()))
488}
489
490struct RawBuildOutput {
492 protocol: WebUIProtocol,
493 css_files: Vec<(String, String)>,
494 component_asset_files: Vec<ComponentAssetFile>,
495 component_templates: Vec<ComponentTemplateArtifact>,
496 warnings: Vec<Diagnostic>,
497 fragment_count: usize,
498 component_count: usize,
499 token_count: usize,
500}
501
502fn build_protocol_inner(options: &BuildOptions) -> Result<RawBuildOutput, WebUIError> {
504 let projection_enabled = !options.projection_manifests.is_empty();
505 if projection_enabled && options.plugin != Some(Plugin::WebUI) {
506 return Err(projection::incompatible_plugin_error());
507 }
508 let parser_options = ParserOptions::try_new(
509 options.css,
510 options.dom,
511 &options.css_file_name_template,
512 options.css_public_base.as_deref(),
513 options.legal_comments,
514 )
515 .map_err(|e| WebUIError::InvalidBuildOptions(e.to_string()))?;
516 let css_link_options = parser_options.css_link_options.clone();
517
518 let mut parser = match options.plugin {
519 Some(Plugin::Fast | Plugin::FastV2) => {
520 HtmlParser::with_plugin_options(Box::new(FastV2ParserPlugin::new()), parser_options)
521 }
522 Some(Plugin::FastV3) => {
523 HtmlParser::with_plugin_options(Box::new(FastV3ParserPlugin::new()), parser_options)
524 }
525 Some(Plugin::WebUI) => {
526 HtmlParser::with_plugin_options(Box::new(WebUIParserPlugin::new()), parser_options)
527 }
528 None => HtmlParser::with_options(parser_options),
529 };
530
531 parser
533 .component_registry_mut()
534 .register_from_paths(&[&options.app_dir])
535 .map_err(|e| {
536 WebUIError::ComponentRegistration(format!(
537 "Failed to register components from {}: {e}",
538 options.app_dir.display()
539 ))
540 })?;
541
542 for source in &options.components {
544 let result = webui_discovery::discover_source(source, &options.app_dir).map_err(|e| {
545 WebUIError::ComponentDiscovery(format!(
546 "Failed to discover components from {source}: {e}"
547 ))
548 })?;
549 for comp in &result.components {
550 parser
553 .component_registry_mut()
554 .register_component(webui_parser::ComponentRegistration {
555 tag_name: &comp.tag_name,
556 html_content: &comp.html_content,
557 css_content: comp.css_content.as_deref(),
558 is_client_owned: comp.is_client_owned,
559 })
560 .map_err(|e| {
561 WebUIError::ComponentRegistration(format!(
562 "Failed to register component '{}' from {}: {e}",
563 comp.tag_name, comp.source
564 ))
565 })?;
566 }
567 }
568
569 let component_count = parser.component_registry().len();
570
571 let entry_path = options.app_dir.join(&options.entry);
573 let html_content = fs::read_to_string(&entry_path).map_err(|source| WebUIError::Io {
574 context: format!("Failed to read {}", entry_path.display()),
575 source,
576 })?;
577 parser
578 .parse(&options.entry, &html_content)
579 .map_err(|source| WebUIError::Parse {
580 context: format!("Failed to parse {}", options.entry),
581 source,
582 })?;
583
584 let boundary_count = parser.boundary_count();
590 let boundary_names = parser.boundary_names().to_vec();
591 let module_entry_srcs = parser.module_entry_srcs().to_vec();
592
593 let synthetic_asset_fragments =
594 parse_component_asset_roots(&mut parser, &options.component_asset_roots)?;
595
596 let css_snapshot: Vec<(String, String)> = parser
597 .component_registry()
598 .get_all()
599 .filter(|component| parser.has_fragment(&component.tag_name))
600 .filter_map(|component| {
601 component
602 .css_content
603 .as_ref()
604 .map(|css| (component.tag_name.clone(), css.clone()))
605 })
606 .collect();
607
608 let token_analysis = parser.token_analysis();
610 let mut warnings: Vec<Diagnostic> = Vec::new();
611 if let Some(theme) = options.theme.as_ref() {
612 token_analysis
613 .validate_theme_tokens(theme)
614 .map_err(|source| WebUIError::Parse {
615 context: "Failed to validate theme tokens".to_string(),
616 source,
617 })?;
618 warnings = token_analysis.theme_token_warnings(theme);
623 }
624 let token_count = token_analysis.protocol_tokens.len();
625
626 let component_templates =
627 match parser
628 .take_plugin_artifacts()
629 .map_err(|source| WebUIError::Parse {
630 context: format!(
631 "Failed to compile component templates for {}",
632 options.entry
633 ),
634 source,
635 })? {
636 ParserPluginArtifacts::None => Vec::new(),
637 ParserPluginArtifacts::ComponentTemplates(templates) => templates,
638 };
639 let mut fragment_records = parser.into_fragment_records();
641 for fragment_id in synthetic_asset_fragments {
642 fragment_records.remove(&fragment_id);
643 }
644 let fragment_count: usize = fragment_records.values().map(|v| v.fragments.len()).sum();
645
646 let merged_manifest = projection::load_and_merge(&options.projection_manifests)?;
650 let mut protocol = WebUIProtocol::with_tokens(fragment_records, token_analysis.protocol_tokens);
651 if !boundary_names.is_empty() {
652 protocol.streaming_boundaries.insert(
653 options.entry.clone(),
654 webui_protocol::StreamingBoundaryList {
655 names: boundary_names,
656 },
657 );
658 }
659 protocol.initial_state_strategy = if merged_manifest.is_some() {
660 webui_protocol::InitialStateStrategy::Components as i32
661 } else {
662 webui_protocol::InitialStateStrategy::Full as i32
663 };
664
665 if boundary_count > 0 && merged_manifest.is_none() {
670 warnings.push(streaming_without_projection_warning(
671 &options.entry,
672 boundary_count,
673 ));
674 }
675
676 if let Some(merged) = &merged_manifest {
681 let mut preloads = module_preload::resolve(&merged.entry_closures, &module_entry_srcs);
682 protocol.module_preloads = std::mem::take(&mut preloads.hrefs);
683 warnings.append(&mut preloads.warnings);
684 }
685
686 if let Some(merged) = &merged_manifest {
691 let compiled_scripted_tags: Vec<&str> = component_templates
692 .iter()
693 .filter(|artifact| {
694 artifact.is_scripted && protocol.fragments.contains_key(&artifact.tag_name)
695 })
696 .map(|artifact| artifact.tag_name.as_str())
697 .collect();
698 projection::validate_coverage(&merged.components, &compiled_scripted_tags)?;
699 }
700
701 protocol.set_css_strategy(match options.css {
703 CssStrategy::Link => webui_protocol::CssStrategy::Link,
704 CssStrategy::Style => webui_protocol::CssStrategy::Style,
705 CssStrategy::Module => webui_protocol::CssStrategy::Module,
706 });
707 protocol.set_dom_strategy(match options.dom {
708 DomStrategy::Shadow => webui_protocol::DomStrategy::Shadow,
709 DomStrategy::Light => webui_protocol::DomStrategy::Light,
710 });
711
712 let is_module = options.css == CssStrategy::Module;
715 let is_link = options.css == CssStrategy::Link;
716 let mut css_files: Vec<(String, String)> = Vec::new();
717 let mut emitted_names: HashSet<String> = HashSet::new();
718 for (tag, css) in css_snapshot {
719 if !protocol.fragments.contains_key(&tag) {
720 continue;
721 }
722 if is_module {
723 protocol.components.entry(tag).or_default().css = css.trim().to_string();
724 } else if is_link {
725 let resolved = css_link_options.resolve(&tag, &css);
726 if !emitted_names.insert(resolved.filename.clone()) {
727 return Err(WebUIError::InvalidBuildOptions(format!(
728 "CSS filename collision for Link strategy: '{}'. Adjust the asset filename template to include [name] or another unique component-specific segment.",
729 resolved.filename
730 )));
731 }
732 protocol.components.entry(tag).or_default().css_href = resolved.href;
733 css_files.push((resolved.filename, css));
734 }
735 }
738
739 for artifact in &component_templates {
743 let component = protocol
744 .components
745 .entry(artifact.tag_name.clone())
746 .or_default();
747 component.template = artifact.template.clone();
748 component.template_json = artifact.template_json.clone();
749 component.template_functions = artifact.template_functions.clone();
750 let manifest_entry = if artifact.is_scripted {
755 merged_manifest
756 .as_ref()
757 .and_then(|merged| merged.components.get(&artifact.tag_name))
758 } else {
759 None
760 };
761 let (hydration_mode, hydration_keys) = match manifest_entry {
762 Some(entry) => encode_state_surface(&StateSurface::Keys(entry.hydration_keys.clone())),
763 None => encode_state_surface(&artifact.hydration),
764 };
765 component.hydration_mode = hydration_mode;
766 component.hydration_keys = hydration_keys;
767 let (navigation_mode, navigation_keys) = match manifest_entry {
768 Some(entry) => {
769 let union =
770 projection::union_keys(&entry.navigation_keys, &artifact.template_roots);
771 encode_state_surface(&StateSurface::Keys(union))
772 }
773 None => encode_state_surface(&artifact.navigation),
774 };
775 component.navigation_mode = Some(navigation_mode);
776 component.navigation_keys = navigation_keys;
777 }
778
779 let component_asset_files = component_assets::render_component_assets(
780 &protocol,
781 &options.component_asset_roots,
782 &options.css_file_name_template,
783 )?;
784 validate_generated_file_names(&css_files, &component_asset_files)?;
785
786 Ok(RawBuildOutput {
787 protocol,
788 css_files,
789 component_asset_files,
790 component_templates,
791 warnings,
792 fragment_count,
793 component_count,
794 token_count,
795 })
796}
797
798fn encode_state_surface(surface: &StateSurface) -> (i32, Vec<String>) {
799 match surface {
800 StateSurface::None => (webui_protocol::StateProjectionMode::None as i32, Vec::new()),
801 StateSurface::Keys(keys) => (
802 webui_protocol::StateProjectionMode::Keys as i32,
803 keys.clone(),
804 ),
805 StateSurface::All => (webui_protocol::StateProjectionMode::All as i32, Vec::new()),
806 }
807}
808
809fn parse_component_asset_roots(
810 parser: &mut HtmlParser,
811 roots: &[String],
812) -> Result<Vec<String>, WebUIError> {
813 let mut synthetic_fragments = Vec::with_capacity(roots.len());
814 for (index, root) in roots.iter().enumerate() {
815 let root = root.trim();
816 if parser.has_fragment(root) {
817 continue;
818 }
819 let mut synthetic = String::with_capacity(root.len() * 2 + 5);
822 synthetic.push('<');
823 synthetic.push_str(root);
824 synthetic.push_str("></");
825 synthetic.push_str(root);
826 synthetic.push('>');
827
828 let fragment_id = format!("__webui_asset_root_{index}");
829 parser
830 .parse(&fragment_id, &synthetic)
831 .map_err(|source| WebUIError::Parse {
832 context: format!("Failed to parse component asset root <{root}>"),
833 source,
834 })?;
835 synthetic_fragments.push(fragment_id);
836 }
837 Ok(synthetic_fragments)
838}
839
840fn validate_generated_file_names(
841 css_files: &[(String, String)],
842 component_asset_files: &[ComponentAssetFile],
843) -> Result<(), WebUIError> {
844 let mut names = HashSet::with_capacity(css_files.len() + component_asset_files.len());
845 for (name, _) in css_files {
846 names.insert(name.as_str());
847 }
848 for file in component_asset_files {
849 if !names.insert(file.name.as_str()) {
850 return Err(WebUIError::InvalidBuildOptions(format!(
851 "output filename collision for '{}'. Adjust the asset filename template to include [ext] or another unique asset-type segment.",
852 file.name
853 )));
854 }
855 }
856 Ok(())
857}
858
859fn validate_output_file_names(
860 protocol_name: &OsStr,
861 result: &BuildResult,
862) -> Result<(), WebUIError> {
863 let mut names =
864 HashSet::with_capacity(1 + result.css_files.len() + result.component_asset_files.len());
865 names.insert(protocol_name.to_os_string());
866 for (name, _) in &result.css_files {
867 let file_name = OsString::from(name);
868 if !names.insert(file_name.clone()) {
869 return Err(output_file_collision_error(&file_name));
870 }
871 }
872 for file in &result.component_asset_files {
873 let file_name = OsString::from(&file.name);
874 if !names.insert(file_name.clone()) {
875 return Err(output_file_collision_error(&file_name));
876 }
877 }
878 Ok(())
879}
880
881fn output_file_collision_error(name: &OsStr) -> WebUIError {
882 WebUIError::InvalidBuildOptions(format!(
883 "output filename collision for '{}'. Adjust the asset filename template to include [ext] or another unique asset-type segment.",
884 name.to_string_lossy()
885 ))
886}
887
888#[cfg(test)]
889#[allow(clippy::disallowed_methods)]
890mod tests {
891 use super::*;
892 use std::collections::HashMap;
893 use std::path::PathBuf;
894 use tempfile::TempDir;
895 use webui_handler::plugin::webui::WebUIHydrationPlugin;
896 use webui_protocol::web_ui_fragment::Fragment;
897
898 struct StringWriter {
899 buf: String,
900 }
901
902 impl ResponseWriter for StringWriter {
903 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
904 self.buf.push_str(content);
905 Ok(())
906 }
907
908 fn end(&mut self) -> webui_handler::Result<()> {
909 Ok(())
910 }
911 }
912
913 fn create_app_dir(files: &[(&str, &str)]) -> TempDir {
914 let dir = TempDir::new().unwrap();
915 for (name, content) in files {
916 let path = dir.path().join(name);
917 if let Some(parent) = path.parent() {
918 fs::create_dir_all(parent).unwrap();
919 }
920 fs::write(&path, content).unwrap();
921 }
922 dir
923 }
924
925 fn default_options(app_dir: &Path) -> BuildOptions {
926 BuildOptions {
927 app_dir: app_dir.to_path_buf(),
928 ..BuildOptions::default()
929 }
930 }
931
932 #[test]
933 fn test_build_simple_html() {
934 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
935 let result = build(default_options(app.path())).unwrap();
936
937 assert!(result.protocol.fragments.contains_key("index.html"));
938 assert!(result.stats.fragment_count > 0);
939 assert!(result.stats.protocol_size_bytes > 0);
940 assert!(!result.stats.duration.is_zero());
941 assert_eq!(
942 result.protocol.initial_state_strategy,
943 webui_protocol::InitialStateStrategy::Full as i32
944 );
945 }
946
947 #[test]
948 fn pending_projection_unblocks_after_template_compilation() {
949 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
950 let (source, completer) = projection_manifest_barrier();
951 let options = BuildOptions {
952 app_dir: app.path().to_path_buf(),
953 plugin: Some(Plugin::WebUI),
954 projection_manifests: vec![source],
955 ..BuildOptions::default()
956 };
957 let handle = std::thread::spawn(move || build(options));
958 let prepared = prepare_projection_manifests(&[]).unwrap();
959 completer.complete(Ok(prepared));
960 let result = handle.join().unwrap().unwrap();
961 assert_eq!(
962 result.protocol.initial_state_strategy,
963 webui_protocol::InitialStateStrategy::Components as i32
964 );
965 }
966
967 #[test]
970 fn streaming_entry_without_projection_manifest_warns() {
971 let app = create_app_dir(&[(
972 "index.html",
973 concat!(
974 "<html><body>",
975 r#"<boundary name="a"><p>{{one}}</p></boundary>"#,
976 r#"<boundary name="b"><p>{{two}}</p></boundary>"#,
977 "</body></html>",
978 ),
979 )]);
980 let mut options = default_options(app.path());
981 options.plugin = Some(Plugin::WebUI);
982
983 let result = build(options).unwrap();
984
985 assert_eq!(
986 result.protocol.initial_state_strategy,
987 webui_protocol::InitialStateStrategy::Full as i32
988 );
989 assert_eq!(
990 result.protocol.streaming_boundaries["index.html"].names,
991 ["a", "b"]
992 );
993 let decoded = WebUIProtocol::from_protobuf(&result.protocol_bytes).unwrap();
994 assert_eq!(decoded.streaming_boundaries["index.html"].names, ["a", "b"]);
995 let warning = result
996 .warnings
997 .iter()
998 .find(|diag| {
999 diag.error_code() == Some(webui_parser::codes::STREAMING_WITHOUT_PROJECTION)
1000 })
1001 .expect("a streaming build without projection must warn");
1002 assert_eq!(warning.severity(), webui_parser::Severity::Warning);
1003 assert!(
1004 warning.to_string().contains("2 streaming boundary"),
1005 "warning should report the boundary count: {warning}"
1006 );
1007 assert!(
1008 warning
1009 .help_text()
1010 .is_some_and(|help| help.contains("projection_manifests")),
1011 "warning should point at the fix: {warning}"
1012 );
1013 }
1014
1015 #[test]
1016 fn streaming_entry_with_projection_manifest_does_not_warn() {
1017 let app = create_app_dir(&[(
1018 "index.html",
1019 concat!(
1020 "<html><body>",
1021 r#"<boundary name="a"><p>{{one}}</p></boundary>"#,
1022 "</body></html>",
1023 ),
1024 )]);
1025 let manifest_json =
1026 projection::test_support::build_valid_manifest_json(app.path(), &[], &[], &[]);
1027 let manifest = projection::test_support::write_manifest(
1028 app.path(),
1029 "webui-projection.json",
1030 &manifest_json,
1031 );
1032 let mut options = default_options(app.path());
1033 options.plugin = Some(Plugin::WebUI);
1034 options.projection_manifests = vec![manifest.into()];
1035
1036 let result = build(options).unwrap();
1037
1038 assert_eq!(
1039 result.protocol.initial_state_strategy,
1040 webui_protocol::InitialStateStrategy::Components as i32
1041 );
1042 assert!(
1043 !result
1044 .warnings
1045 .iter()
1046 .any(|diag| diag.error_code()
1047 == Some(webui_parser::codes::STREAMING_WITHOUT_PROJECTION)),
1048 "a projected streaming build must not warn"
1049 );
1050 }
1051
1052 #[test]
1055 fn non_streaming_entry_without_projection_manifest_does_not_warn() {
1056 let app = create_app_dir(&[("index.html", "<html><body><p>{{one}}</p></body></html>")]);
1057 let mut options = default_options(app.path());
1058 options.plugin = Some(Plugin::WebUI);
1059
1060 let result = build(options).unwrap();
1061
1062 assert!(
1063 !result
1064 .warnings
1065 .iter()
1066 .any(|diag| diag.error_code()
1067 == Some(webui_parser::codes::STREAMING_WITHOUT_PROJECTION)),
1068 "a non-streaming build must not warn about projection"
1069 );
1070 }
1071
1072 #[test]
1073 fn test_build_keeps_scriptless_component_dormant_until_client_use() {
1074 let app = create_app_dir(&[
1075 (
1076 "index.html",
1077 "<html><body><demo-card></demo-card></body></html>",
1078 ),
1079 ("demo-card.html", "<p>{{name}} {{serverTitle}}</p>"),
1080 ]);
1081 let manifest_json =
1086 projection::test_support::build_valid_manifest_json(app.path(), &[], &[], &[]);
1087 let manifest = projection::test_support::write_manifest(
1088 app.path(),
1089 "webui-projection.json",
1090 &manifest_json,
1091 );
1092 let mut options = default_options(app.path());
1093 options.plugin = Some(Plugin::WebUI);
1094 options.projection_manifests = vec![manifest.into()];
1095 let result = build(options).unwrap();
1096 assert_eq!(
1097 result.protocol.initial_state_strategy,
1098 webui_protocol::InitialStateStrategy::Components as i32
1099 );
1100
1101 assert!(result
1102 .component_templates
1103 .iter()
1104 .any(|template| template.tag_name == "demo-card"));
1105 let component = result
1106 .protocol
1107 .components
1108 .get("demo-card")
1109 .expect("scriptless component metadata should be retained");
1110 assert!(component.hydration_keys.is_empty());
1111 assert_eq!(component.navigation_keys, ["name", "serverTitle"]);
1112 assert!(component.template_json.contains(r#""th":1"#));
1113
1114 let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
1115 let state = serde_json::json!({ "name": "Server rendered" });
1116 let protocol = Protocol::new(result.protocol.clone());
1117 let mut writer = StringWriter { buf: String::new() };
1118 handler
1119 .render(
1120 &protocol,
1121 &state,
1122 &RenderOptions::new("index.html", "/"),
1123 &mut writer,
1124 )
1125 .unwrap();
1126 assert!(writer.buf.contains("Server rendered"));
1127 assert!(writer.buf.contains(r#""state":{}"#));
1128 assert!(writer.buf.contains(r#""templates":{"demo-card":"#));
1129 }
1130
1131 #[test]
1132 fn test_build_treats_sibling_component_script_as_scripted() {
1133 let app = create_app_dir(&[
1134 ("index.html", "<demo-card></demo-card>"),
1135 (
1136 "demo-card.html",
1137 r#"<button @click="{onClick()}">{{name}}</button>"#,
1138 ),
1139 (
1140 "demo-card.ts",
1141 "import { WebUIElement } from '@microsoft/webui-framework';\n\
1142 class DemoCard extends WebUIElement { onClick() {} }\n\
1143 DemoCard.define('demo-card');",
1144 ),
1145 ]);
1146 let mut options = default_options(app.path());
1147 options.plugin = Some(Plugin::WebUI);
1148 let result = build(options).unwrap();
1149
1150 let template = result
1151 .component_templates
1152 .iter()
1153 .find(|template| template.tag_name == "demo-card")
1154 .unwrap();
1155 assert!(template.template_json.contains(r#""eg":[["click""#));
1156 }
1157
1158 #[test]
1159 fn test_build_populates_component_hydration_keys_from_script() {
1160 let app = create_app_dir(&[
1161 ("index.html", "<demo-card></demo-card>"),
1162 ("demo-card.html", "<p>{{name}}</p>"),
1163 (
1164 "demo-card.ts",
1165 "import { WebUIElement, attr, observable } from '@microsoft/webui-framework';\n\
1166 class DemoCard extends WebUIElement {\n\
1167 @observable name = '';\n\
1168 @attr({ attribute: 'cta-href' }) ctaHref = '/x';\n\
1169 @observable count = 0;\n\
1170 }\n\
1171 DemoCard.define('demo-card');",
1172 ),
1173 ]);
1174 let manifest_json = projection::test_support::build_valid_manifest_json(
1178 app.path(),
1179 &[("demo-card.ts", "source")],
1180 &[("demo-card.js", "output")],
1181 &[(
1182 "demo-card",
1183 "demo-card.ts",
1184 &["demo-card.js"],
1185 &["count", "ctaHref", "name"],
1186 &["count", "ctaHref", "name"],
1187 )],
1188 );
1189 let manifest = projection::test_support::write_manifest(
1190 app.path(),
1191 "webui-projection.json",
1192 &manifest_json,
1193 );
1194 let mut options = default_options(app.path());
1195 options.plugin = Some(Plugin::WebUI);
1196 options.projection_manifests = vec![manifest.into()];
1197 let result = build(options).unwrap();
1198
1199 assert_eq!(
1200 result.protocol.initial_state_strategy,
1201 webui_protocol::InitialStateStrategy::Components as i32
1202 );
1203 let component = result.protocol.components.get("demo-card").unwrap();
1204 assert_eq!(
1205 component.hydration_mode,
1206 webui_protocol::StateProjectionMode::Keys as i32
1207 );
1208 assert_eq!(component.hydration_keys, vec!["count", "ctaHref", "name"]);
1209 assert_eq!(
1210 component.navigation_mode,
1211 Some(webui_protocol::StateProjectionMode::Keys as i32)
1212 );
1213 assert_eq!(component.navigation_keys, vec!["count", "ctaHref", "name"]);
1214 }
1215
1216 #[test]
1217 fn test_build_scripted_component_without_manifest_falls_back_to_full_state() {
1218 let app = create_app_dir(&[
1219 (
1220 "index.html",
1221 "<html><body><demo-card></demo-card></body></html>",
1222 ),
1223 ("demo-card.html", "<p>{{name}}</p>"),
1224 (
1225 "demo-card.ts",
1226 "import { WebUIElement, observable } from '@microsoft/webui-framework';\n\
1227 class DemoCard extends WebUIElement {\n\
1228 @observable name = '';\n\
1229 }\n\
1230 DemoCard.define('demo-card');",
1231 ),
1232 ]);
1233 let mut options = default_options(app.path());
1234 options.plugin = Some(Plugin::WebUI);
1235 let result = build(options).unwrap();
1238
1239 assert_eq!(
1240 result.protocol.initial_state_strategy,
1241 webui_protocol::InitialStateStrategy::Full as i32
1242 );
1243 let component = result.protocol.components.get("demo-card").unwrap();
1244 assert_eq!(
1245 component.hydration_mode,
1246 webui_protocol::StateProjectionMode::All as i32
1247 );
1248 assert!(component.hydration_keys.is_empty());
1249 assert_eq!(
1250 component.navigation_mode,
1251 Some(webui_protocol::StateProjectionMode::All as i32)
1252 );
1253
1254 let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
1255 let state = serde_json::json!({ "name": "Visible" });
1256 let protocol = Protocol::new(result.protocol.clone());
1257 let mut writer = StringWriter { buf: String::new() };
1258 handler
1259 .render(
1260 &protocol,
1261 &state,
1262 &RenderOptions::new("index.html", "/"),
1263 &mut writer,
1264 )
1265 .unwrap();
1266 assert!(writer.buf.contains("Visible"));
1267 assert!(writer.buf.contains(r#""state":{"name":"Visible"}"#));
1270 }
1271
1272 #[test]
1273 fn test_build_rejects_scriptless_event_component() {
1274 let app = create_app_dir(&[
1275 ("index.html", "<demo-card></demo-card>"),
1276 (
1277 "demo-card.html",
1278 r#"<button @click="{onClick()}">Click</button>"#,
1279 ),
1280 ]);
1281 let mut options = default_options(app.path());
1282 options.plugin = Some(Plugin::WebUI);
1283 let err = build(options).expect_err("scriptless events should fail");
1284
1285 let message = err.chain_message();
1286 assert!(
1287 message.contains("scriptless-event-handler"),
1288 "msg: {message}"
1289 );
1290 }
1291
1292 #[test]
1293 fn test_build_returns_actionable_error_for_invalid_w_ref() {
1294 let app = create_app_dir(&[
1295 ("index.html", "<my-card>Hello</my-card>"),
1296 ("my-card.html", r#"<div><input w-ref="myInput" /></div>"#),
1297 ]);
1298 let mut options = default_options(app.path());
1299 options.plugin = Some(Plugin::WebUI);
1300
1301 let Err(err) = build(options) else {
1304 panic!("non-braced w-ref must fail the build");
1305 };
1306 let msg = err.chain_message();
1307 assert!(msg.contains("invalid w-ref binding"), "msg: {msg}");
1308 assert!(
1309 msg.contains("component <my-card> · element <input>"),
1310 "msg: {msg}"
1311 );
1312 assert!(msg.contains("help:"), "msg: {msg}");
1313 }
1314
1315 #[test]
1316 fn test_build_returns_actionable_error_for_invalid_event_handler() {
1317 let app = create_app_dir(&[
1318 ("index.html", "<my-btn>x</my-btn>"),
1319 (
1320 "my-btn.html",
1321 r#"<div><button @click="e.preventDefault()">x</button></div>"#,
1322 ),
1323 ]);
1324 let mut options = default_options(app.path());
1325 options.plugin = Some(Plugin::WebUI);
1326
1327 let Err(err) = build(options) else {
1328 panic!("invalid @event handler must fail the build");
1329 };
1330 let msg = err.chain_message();
1331 assert!(msg.contains("invalid @click handler"), "msg: {msg}");
1332 assert!(
1333 msg.contains("component <my-btn> · element <button>"),
1334 "msg: {msg}"
1335 );
1336 }
1337
1338 #[test]
1339 fn test_error_chain_message_has_no_duplicate_source() {
1340 let err = WebUIError::Parse {
1341 context: "Failed to parse index.html".to_string(),
1342 source: webui_parser::ParserError::Directive("Invalid for each: x".to_string()),
1343 };
1344 assert_eq!(err.to_string(), "Failed to parse index.html");
1347 assert_eq!(
1349 err.chain_message(),
1350 "Failed to parse index.html: Directive error: Invalid for each: x"
1351 );
1352 }
1353
1354 #[test]
1355 fn test_build_with_directives() {
1356 let html = r#"<h1>Hello</h1>
1357<for each="item in items">
1358 <p>{{item.name}}</p>
1359</for>
1360<if condition="show">
1361 <p>Visible</p>
1362</if>"#;
1363 let app = create_app_dir(&[("index.html", html)]);
1364 let result = build(default_options(app.path())).unwrap();
1365
1366 let index = &result.protocol.fragments["index.html"].fragments;
1367 assert!(index
1368 .iter()
1369 .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(_)))));
1370 assert!(index
1371 .iter()
1372 .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
1373 }
1374
1375 #[test]
1376 fn test_build_with_component_css() {
1377 let app = create_app_dir(&[
1378 ("index.html", "<my-card>Hello</my-card>"),
1379 ("my-card.html", "<div><slot></slot></div>"),
1380 ("my-card.css", ".card { color: red; }"),
1381 ("my-card.ts", "export {};"),
1382 ]);
1383 let result = build(default_options(app.path())).unwrap();
1384
1385 assert_eq!(result.css_files.len(), 1);
1386 assert_eq!(result.css_files[0].0, "my-card.css");
1387 assert!(result.css_files[0].1.contains("color: red"));
1388 assert_eq!(result.stats.css_file_count, 1);
1389 }
1390
1391 #[test]
1392 fn test_build_strips_non_legal_css_comments_from_output_file() {
1393 let app = create_app_dir(&[
1394 ("index.html", "<my-card>Hello</my-card>"),
1395 ("my-card.html", "<div><slot></slot></div>"),
1396 (
1397 "my-card.css",
1398 "/* remove var(--ignored) */ .card { color: var(--textColor); }",
1399 ),
1400 ]);
1401 let result = build(default_options(app.path())).unwrap();
1402
1403 assert_eq!(result.css_files[0].1, " .card { color: var(--textColor); }");
1404 assert_eq!(result.protocol.tokens, vec!["textColor"]);
1405 }
1406
1407 #[test]
1408 fn test_build_preserves_legal_css_comments_by_default() {
1409 let app = create_app_dir(&[
1410 ("index.html", "<my-card>Hello</my-card>"),
1411 ("my-card.html", "<div><slot></slot></div>"),
1412 (
1413 "my-card.css",
1414 "/*! @license MIT */ .card { color: red; } /* remove */",
1415 ),
1416 ]);
1417 let result = build(default_options(app.path())).unwrap();
1418
1419 assert_eq!(
1420 result.css_files[0].1,
1421 "/*! @license MIT */ .card { color: red; } "
1422 );
1423 }
1424
1425 #[test]
1426 fn test_build_legal_comments_none_strips_legal_css_comments() {
1427 let app = create_app_dir(&[
1428 ("index.html", "<my-card>Hello</my-card>"),
1429 ("my-card.html", "<div><slot></slot></div>"),
1430 ("my-card.css", "/*! @license MIT */ .card { color: red; }"),
1431 ]);
1432 let mut options = default_options(app.path());
1433 options.legal_comments = LegalComments::None;
1434 let result = build(options).unwrap();
1435
1436 assert_eq!(result.css_files[0].1, " .card { color: red; }");
1437 }
1438
1439 #[test]
1440 fn test_build_with_css_hashed_filename_template() {
1441 let app = create_app_dir(&[
1442 ("index.html", "<my-card>Hello</my-card>"),
1443 ("my-card.html", "<div><slot></slot></div>"),
1444 ("my-card.css", ".card { color: red; }"),
1445 ]);
1446 let mut options = default_options(app.path());
1447 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
1448 let result = build(options).unwrap();
1449
1450 assert_eq!(result.css_files.len(), 1);
1451 let generated = &result.css_files[0].0;
1452 assert!(generated.starts_with("my-card-"));
1453 assert!(generated.ends_with(".css"));
1454 assert_eq!(generated.len(), "my-card-".len() + 8 + ".css".len());
1455 assert_eq!(
1456 result
1457 .protocol
1458 .components
1459 .get("my-card")
1460 .map(|c| c.css_href.as_str()),
1461 Some(generated.as_str())
1462 );
1463 }
1464
1465 #[test]
1466 fn test_css_public_base_prefixes_css_href_only() {
1467 let app = create_app_dir(&[
1468 ("index.html", "<my-card>Hello</my-card>"),
1469 ("my-card.html", "<div><slot></slot></div>"),
1470 ("my-card.css", ".card { color: red; }"),
1471 ]);
1472 let mut options = default_options(app.path());
1473 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
1474 options.css_public_base = Some("https://cdn.example.com/assets".to_string());
1475 let result = build(options).unwrap();
1476
1477 let filename = &result.css_files[0].0;
1478 assert!(filename.starts_with("my-card-"));
1479 let expected_href = format!("https://cdn.example.com/assets/{filename}");
1480 assert_eq!(
1481 result
1482 .protocol
1483 .components
1484 .get("my-card")
1485 .map(|c| c.css_href.as_str()),
1486 Some(expected_href.as_str())
1487 );
1488 }
1489
1490 #[test]
1491 fn test_css_public_base_emits_parser_and_handler_links() {
1492 let app = create_app_dir(&[
1493 (
1494 "index.html",
1495 "<html><head></head><body><my-card>Hello</my-card></body></html>",
1496 ),
1497 ("my-card.html", "<div><slot></slot></div>"),
1498 ("my-card.css", ".card { color: red; }"),
1499 ]);
1500 let mut options = default_options(app.path());
1501 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
1502 options.css_public_base = Some("https://cdn.example.com/assets".to_string());
1503 let result = build(options).unwrap();
1504
1505 let filename = &result.css_files[0].0;
1506 let expected_href = format!("https://cdn.example.com/assets/{filename}");
1507 let component_html = result.protocol.fragments["my-card"]
1508 .fragments
1509 .iter()
1510 .filter_map(|fragment| match fragment.fragment.as_ref() {
1511 Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
1512 _ => None,
1513 })
1514 .collect::<String>();
1515 assert!(
1516 component_html.contains(&format!(
1517 r#"<link rel="stylesheet" href="{expected_href}">"#
1518 )),
1519 "parser-generated component template should use CDN href: {component_html}"
1520 );
1521
1522 let handler = WebUIHandler::new();
1523 let protocol = Protocol::new(result.protocol.clone());
1524 let mut writer = StringWriter { buf: String::new() };
1525 handler
1526 .render(
1527 &protocol,
1528 &serde_json::json!({}),
1529 &RenderOptions::new("index.html", "/"),
1530 &mut writer,
1531 )
1532 .unwrap();
1533 assert!(
1534 writer.buf.contains(&format!(
1535 r#"<link rel="preload" href="{expected_href}" as="style" data-webui-ssr-preload="style">"#
1536 )),
1537 "handler head preload should use CDN href: {}",
1538 writer.buf
1539 );
1540 }
1541
1542 #[test]
1543 fn test_css_public_base_emits_webui_plugin_template_links() {
1544 let app = create_app_dir(&[
1545 ("index.html", "<my-card>Hello</my-card>"),
1546 ("my-card.html", "<div><slot></slot></div>"),
1547 ("my-card.css", ".card { color: red; }"),
1548 ("my-card.ts", "export {};"),
1549 ]);
1550 let mut options = default_options(app.path());
1551 options.plugin = Some(Plugin::WebUI);
1552 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
1553 options.css_public_base = Some("https://cdn.example.com/assets".to_string());
1554 let result = build(options).unwrap();
1555
1556 let filename = &result.css_files[0].0;
1557 let expected_href = format!("https://cdn.example.com/assets/{filename}");
1558 let template = &result.protocol.components["my-card"].template_json;
1559 assert!(
1560 template.contains(&format!(r#"href=\"{expected_href}\""#)),
1561 "plugin component template should use CDN href: {template}"
1562 );
1563 }
1564
1565 #[test]
1566 fn test_css_public_base_emits_fast_plugin_template_links() {
1567 let app = create_app_dir(&[
1568 ("index.html", "<my-card>Hello</my-card>"),
1569 ("my-card.html", "<div><slot></slot></div>"),
1570 ("my-card.css", ".card { color: red; }"),
1571 ]);
1572 let mut options = default_options(app.path());
1573 options.plugin = Some(Plugin::FastV3);
1574 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
1575 options.css_public_base = Some("https://cdn.example.com/assets".to_string());
1576 let result = build(options).unwrap();
1577
1578 let filename = &result.css_files[0].0;
1579 let expected_href = format!("https://cdn.example.com/assets/{filename}");
1580 let template = &result.protocol.components["my-card"].template;
1581 assert!(
1582 template.contains(&format!(r#"href="{expected_href}""#)),
1583 "FAST component template should use CDN href: {template}"
1584 );
1585 }
1586
1587 #[test]
1588 fn test_invalid_css_template_is_rejected() {
1589 let app = create_app_dir(&[
1590 ("index.html", "<my-card>Hello</my-card>"),
1591 ("my-card.html", "<div><slot></slot></div>"),
1592 ("my-card.css", ".card { color: red; }"),
1593 ]);
1594 let mut options = default_options(app.path());
1595 options.css_file_name_template = "[name]-[bogus].[ext]".to_string();
1596 let result = build(options);
1597
1598 assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
1599 }
1600
1601 #[test]
1602 fn test_css_filename_collision_is_rejected() {
1603 let app = create_app_dir(&[
1604 ("index.html", "<card-a>A</card-a><card-b>B</card-b>"),
1605 ("card-a.html", "<div><slot></slot></div>"),
1606 ("card-a.css", ".x { color: red; }"),
1607 ("card-b.html", "<div><slot></slot></div>"),
1608 ("card-b.css", ".x { color: red; }"),
1609 ]);
1610 let mut options = default_options(app.path());
1611 options.css_file_name_template = "[hash].[ext]".to_string();
1612 let result = build(options);
1613
1614 assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
1615 }
1616
1617 #[test]
1618 fn test_build_returns_static_component_asset_files() {
1619 let app = create_app_dir(&[
1620 ("index.html", "<app-shell></app-shell>"),
1621 ("app-shell.html", "<div></div>"),
1622 (
1623 "lazy-panel.html",
1624 r#"<if condition="ready"><p>{{title}}</p></if>"#,
1625 ),
1626 ("lazy-panel.ts", "export {};"),
1627 ]);
1628 let mut options = default_options(app.path());
1629 options.plugin = Some(Plugin::WebUI);
1630 options.component_asset_roots = vec!["lazy-panel".to_string()];
1631
1632 let result = build(options).unwrap();
1633
1634 assert_eq!(result.component_asset_files.len(), 1);
1635 assert_eq!(result.component_asset_files[0].name, "lazy-panel.webui.js");
1636 assert!(result.component_asset_files[0]
1637 .content
1638 .contains(r#""type":"webui-component-asset""#));
1639 assert!(result.component_asset_files[0]
1640 .content
1641 .contains(r#""templateFunctions":{"lazy-panel":"#));
1642 assert!(result.protocol.fragments.contains_key("lazy-panel"));
1643 assert!(
1644 !result
1645 .protocol
1646 .fragments
1647 .keys()
1648 .any(|key| key.starts_with("__webui_asset_root_")),
1649 "synthetic asset root fragments must not be serialized"
1650 );
1651 }
1652
1653 #[test]
1654 fn test_component_asset_filename_collision_with_css_is_rejected() {
1655 let app = create_app_dir(&[
1656 ("index.html", "<app-shell></app-shell>"),
1657 ("app-shell.html", "<div></div>"),
1658 ("lazy-panel.html", "<p>Lazy</p>"),
1659 ("lazy-panel.css", ".panel { color: red; }"),
1660 ]);
1661 let mut options = default_options(app.path());
1662 options.plugin = Some(Plugin::WebUI);
1663 options.component_asset_roots = vec!["lazy-panel".to_string()];
1664 options.css_file_name_template = "[name]".to_string();
1665
1666 let result = build(options);
1667
1668 assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
1669 }
1670
1671 #[test]
1672 fn test_build_to_disk_rejects_protocol_name_collision_before_writing() {
1673 let app = create_app_dir(&[
1674 ("index.html", "<app-shell></app-shell>"),
1675 ("app-shell.html", "<div></div>"),
1676 ("lazy-panel.html", "<p>Lazy</p>"),
1677 ]);
1678 let out = TempDir::new().unwrap();
1679 let mut options = default_options(app.path());
1680 options.plugin = Some(Plugin::WebUI);
1681 options.component_asset_roots = vec!["lazy-panel".to_string()];
1682 options.css_file_name_template = "protocol.bin".to_string();
1683
1684 let result = build_to_disk(options, out.path());
1685
1686 assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
1687 assert!(!out.path().join("protocol.bin").exists());
1688 }
1689
1690 #[test]
1691 fn test_css_href_set_for_light_dom_link_strategy() {
1692 let app = create_app_dir(&[
1693 ("index.html", "<has-css>A</has-css><no-css>B</no-css>"),
1694 ("has-css.html", "<p><slot></slot></p>"),
1695 ("has-css.css", ".yes { color: green; }"),
1696 ("no-css.html", "<p><slot></slot></p>"),
1697 ]);
1698 let mut options = default_options(app.path());
1699 options.dom = DomStrategy::Light;
1700 let result = build(options).unwrap();
1701
1702 let href = result
1703 .protocol
1704 .components
1705 .get("has-css")
1706 .map(|c| c.css_href.as_str())
1707 .unwrap_or("");
1708 assert_eq!(
1709 href, "has-css.css",
1710 "Light×Link component with CSS should have css_href"
1711 );
1712
1713 let no_href = result
1714 .protocol
1715 .components
1716 .get("no-css")
1717 .map(|c| c.css_href.as_str())
1718 .unwrap_or("");
1719 assert!(
1720 no_href.is_empty(),
1721 "Component without CSS should have empty css_href"
1722 );
1723 }
1724
1725 #[test]
1726 fn test_shadow_dom_link_strategy_sets_css_href() {
1727 let app = create_app_dir(&[
1731 ("index.html", "<my-card>A</my-card>"),
1732 ("my-card.html", "<p><slot></slot></p>"),
1733 ("my-card.css", ".card { color: red; }"),
1734 ]);
1735 let result = build(default_options(app.path())).unwrap();
1736
1737 let comp = result.protocol.components.get("my-card").unwrap();
1738 assert_eq!(
1739 comp.css_href, "my-card.css",
1740 "Shadow×Link should set css_href"
1741 );
1742
1743 assert_eq!(
1745 result.protocol.css_strategy(),
1746 webui_protocol::CssStrategy::Link,
1747 );
1748 assert_eq!(
1749 result.protocol.dom_strategy(),
1750 webui_protocol::DomStrategy::Shadow,
1751 );
1752
1753 assert_eq!(
1755 result.css_files.len(),
1756 1,
1757 "CSS file should still be produced"
1758 );
1759 }
1760
1761 #[test]
1762 fn test_css_href_empty_for_style_strategy() {
1763 let app = create_app_dir(&[
1764 ("index.html", "<my-card>A</my-card>"),
1765 ("my-card.html", "<p><slot></slot></p>"),
1766 ("my-card.css", ".card { color: red; }"),
1767 ]);
1768 let mut options = default_options(app.path());
1769 options.css = CssStrategy::Style;
1770 let result = build(options).unwrap();
1771
1772 let href = result
1773 .protocol
1774 .components
1775 .get("my-card")
1776 .map(|c| c.css_href.as_str())
1777 .unwrap_or("");
1778 assert!(href.is_empty(), "Style-strategy should not set css_href");
1779 }
1780
1781 #[test]
1782 fn test_css_href_empty_for_module_strategy() {
1783 let app = create_app_dir(&[
1784 ("index.html", "<my-card>A</my-card>"),
1785 ("my-card.html", "<p><slot></slot></p>"),
1786 ("my-card.css", ".card { color: red; }"),
1787 ]);
1788 let mut options = default_options(app.path());
1789 options.css = CssStrategy::Module;
1790 let result = build(options).unwrap();
1791
1792 let href = result
1793 .protocol
1794 .components
1795 .get("my-card")
1796 .map(|c| c.css_href.as_str())
1797 .unwrap_or("");
1798 assert!(href.is_empty(), "Module-strategy should not set css_href");
1799 assert!(
1800 !result.protocol.components["my-card"].css.is_empty(),
1801 "Module-strategy should still populate css content"
1802 );
1803 }
1804
1805 #[test]
1806 fn test_build_to_disk_writes_files() {
1807 let app = create_app_dir(&[
1808 ("index.html", "<my-card>Hello</my-card>"),
1809 ("my-card.html", "<div><slot></slot></div>"),
1810 ("my-card.css", ".card { color: red; }"),
1811 ("my-card.ts", "export {};"),
1812 ]);
1813 let out = TempDir::new().unwrap();
1814
1815 let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
1816
1817 assert!(out.path().join("protocol.bin").exists());
1818 assert!(out.path().join("my-card.css").exists());
1819 assert_eq!(stats.css_file_count, 1);
1820 assert!(stats.fragment_count > 0);
1821 }
1822
1823 #[test]
1824 fn test_build_to_disk_creates_nested_output_dir() {
1825 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
1826 let out = TempDir::new().unwrap();
1827 let nested = out.path().join("nested").join("output");
1828
1829 let stats = build_to_disk(default_options(app.path()), &nested).unwrap();
1830 assert!(nested.join("protocol.bin").exists());
1831 assert!(stats.fragment_count > 0);
1832 }
1833
1834 #[test]
1835 fn test_build_missing_entry() {
1836 let app = create_app_dir(&[("other.html", "<h1>Not index</h1>")]);
1837 let result = build(default_options(app.path()));
1838
1839 assert!(result.is_err());
1840 let err = result.unwrap_err();
1841 assert!(matches!(err, WebUIError::Io { .. }));
1842 }
1843
1844 #[test]
1845 fn test_build_stats_populated() {
1846 let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
1847 let result = build(default_options(app.path())).unwrap();
1848
1849 assert!(result.stats.fragment_count > 0);
1850 assert!(result.stats.protocol_size_bytes > 0);
1851 assert_eq!(result.stats.css_file_count, 0);
1852 }
1853
1854 #[test]
1855 fn test_build_inline_css() {
1856 let app = create_app_dir(&[
1857 ("index.html", "<my-card>Hello</my-card>"),
1858 ("my-card.html", "<div><slot></slot></div>"),
1859 ("my-card.css", ".card { color: red; }"),
1860 ]);
1861 let mut options = default_options(app.path());
1862 options.css = CssStrategy::Style;
1863
1864 let result = build(options).unwrap();
1865 assert!(result.css_files.is_empty());
1867 assert_eq!(result.stats.css_file_count, 0);
1868 assert!(result.stats.fragment_count > 0);
1869 }
1870
1871 #[test]
1872 fn test_build_module_css() {
1873 let app = create_app_dir(&[
1874 ("index.html", "<my-card>Hello</my-card>"),
1875 ("my-card.html", "<div><slot></slot></div>"),
1876 ("my-card.css", ".card { color: red; }"),
1877 ]);
1878 let mut options = default_options(app.path());
1879 options.css = CssStrategy::Module;
1880
1881 let result = build(options).unwrap();
1882 assert!(result.css_files.is_empty());
1884 assert_eq!(result.stats.css_file_count, 0);
1885 assert!(result.stats.fragment_count > 0);
1886 }
1887
1888 #[test]
1889 fn test_build_custom_entry() {
1890 let app = create_app_dir(&[("page.html", "<h1>Custom</h1>")]);
1891 let mut options = default_options(app.path());
1892 options.entry = "page.html".to_string();
1893
1894 let result = build(options).unwrap();
1895 assert!(result.protocol.fragments.contains_key("page.html"));
1896 assert!(!result.protocol.fragments.contains_key("index.html"));
1897 }
1898
1899 #[test]
1900 fn test_build_protocol_roundtrip() {
1901 let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
1902 let result = build(default_options(app.path())).unwrap();
1903
1904 let restored = WebUIProtocol::from_protobuf(&result.protocol_bytes).unwrap();
1905 assert!(restored.fragments.contains_key("index.html"));
1906 }
1907
1908 #[test]
1909 fn protocol_is_available_from_facade() {
1910 let protocol = Protocol::new(WebUIProtocol::new(HashMap::new()));
1911 assert!(protocol.tokens().is_empty());
1912 }
1913
1914 #[test]
1915 fn test_inspect_bytes_valid() {
1916 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
1917 let result = build(default_options(app.path())).unwrap();
1918
1919 let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
1920 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1921 assert!(parsed.get("fragments").is_some());
1922 }
1923
1924 #[test]
1925 fn test_inspect_bytes_invalid() {
1926 let result = inspect_bytes(b"not a protobuf");
1927 assert!(result.is_err());
1928 assert!(matches!(result.unwrap_err(), WebUIError::Protocol(_)));
1929 }
1930
1931 #[test]
1932 fn test_inspect_file() {
1933 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
1934 let out = TempDir::new().unwrap();
1935 build_to_disk(default_options(app.path()), out.path()).unwrap();
1936
1937 let json_str = inspect(&out.path().join("protocol.bin")).unwrap();
1938 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1939 assert!(parsed["fragments"]["index.html"]["fragments"].is_array());
1940 }
1941
1942 #[test]
1943 fn test_inspect_missing_file() {
1944 let result = inspect(Path::new("/nonexistent/protocol.bin"));
1945 assert!(result.is_err());
1946 assert!(matches!(result.unwrap_err(), WebUIError::Io { .. }));
1947 }
1948
1949 #[test]
1950 fn test_build_with_components_local_path() {
1951 let app = create_app_dir(&[("index.html", "<ext-card>Hello</ext-card>")]);
1952 let ext_dir = TempDir::new().unwrap();
1953 fs::write(
1954 ext_dir.path().join("ext-card.html"),
1955 "<div class=\"card\"><slot></slot></div>",
1956 )
1957 .unwrap();
1958 fs::write(
1959 ext_dir.path().join("ext-card.css"),
1960 ".card { border: 1px solid #ccc; }",
1961 )
1962 .unwrap();
1963
1964 let mut options = default_options(app.path());
1965 options.components = vec![ext_dir.path().to_string_lossy().to_string()];
1966
1967 let result = build(options).unwrap();
1968 assert!(result.protocol.fragments.contains_key("index.html"));
1969 assert_eq!(result.css_files.len(), 1);
1970 assert!(result.css_files[0].1.contains("border"));
1971 }
1972
1973 #[test]
1974 fn test_build_with_scriptless_component_path_emits_dormant_template() {
1975 let app = create_app_dir(&[("index.html", "<ext-card></ext-card>")]);
1976 let ext_dir = TempDir::new().unwrap();
1977 fs::write(ext_dir.path().join("ext-card.html"), "<p>{{title}}</p>").unwrap();
1978
1979 let mut options = default_options(app.path());
1980 options.plugin = Some(Plugin::WebUI);
1981 options.components = vec![ext_dir.path().to_string_lossy().to_string()];
1982
1983 let result = build(options).unwrap();
1984 assert!(result
1985 .component_templates
1986 .iter()
1987 .any(|template| template.tag_name == "ext-card"));
1988 let component = result
1989 .protocol
1990 .components
1991 .get("ext-card")
1992 .expect("external scriptless component metadata should be retained");
1993 assert!(component.hydration_keys.is_empty());
1994 assert_eq!(component.navigation_keys, ["title"]);
1995 assert!(component.template_json.contains(r#""th":1"#));
1996 }
1997
1998 #[test]
1999 fn test_build_with_scripted_component_path_emits_client_artifact() {
2000 let app = create_app_dir(&[("index.html", "<ext-card></ext-card>")]);
2001 let ext_dir = TempDir::new().unwrap();
2002 fs::write(ext_dir.path().join("ext-card.html"), "<p>{{title}}</p>").unwrap();
2003 fs::write(ext_dir.path().join("ext-card.ts"), "export {};").unwrap();
2004
2005 let mut options = default_options(app.path());
2006 options.plugin = Some(Plugin::WebUI);
2007 options.components = vec![ext_dir.path().to_string_lossy().to_string()];
2008
2009 let result = build(options).unwrap();
2010 let template = &result.protocol.components["ext-card"].template_json;
2011 assert!(!template.is_empty(), "template should be emitted");
2012 }
2013
2014 #[test]
2015 fn test_build_rejects_projection_manifest_with_non_webui_plugin() {
2016 let app = create_app_dir(&[
2017 ("index.html", "<demo-card></demo-card>"),
2018 ("demo-card.html", "<p>{{name}}</p>"),
2019 ("demo-card.ts", "export {};"),
2020 ]);
2021 let manifest_json = projection::test_support::build_valid_manifest_json(
2022 app.path(),
2023 &[("demo-card.ts", "export {};")],
2024 &[("projection-bundle.js", "output")],
2025 &[(
2026 "demo-card",
2027 "demo-card.ts",
2028 &["projection-bundle.js"],
2029 &["name"],
2030 &["name"],
2031 )],
2032 );
2033 let manifest = projection::test_support::write_manifest(
2034 app.path(),
2035 "webui-projection.json",
2036 &manifest_json,
2037 );
2038 let mut options = default_options(app.path());
2039 options.projection_manifests = vec![manifest.into()];
2042 let err = build(options).expect_err("non-WebUI plugin + manifest should be rejected");
2043 assert!(
2044 err.chain_message().contains("PROJ-B002"),
2045 "msg: {}",
2046 err.chain_message()
2047 );
2048 }
2049
2050 #[test]
2051 fn test_build_rejects_missing_scripted_component_coverage() {
2052 let app = create_app_dir(&[
2053 ("index.html", "<demo-card></demo-card>"),
2054 ("demo-card.html", "<p>{{name}}</p>"),
2055 ("demo-card.ts", "export {};"),
2056 ]);
2057 let manifest_json =
2060 projection::test_support::build_valid_manifest_json(app.path(), &[], &[], &[]);
2061 let manifest = projection::test_support::write_manifest(
2062 app.path(),
2063 "webui-projection.json",
2064 &manifest_json,
2065 );
2066 let mut options = default_options(app.path());
2067 options.plugin = Some(Plugin::WebUI);
2068 options.projection_manifests = vec![manifest.into()];
2069 let err = build(options).expect_err("missing coverage should fail the build");
2070 let message = err.chain_message();
2071 assert!(message.contains("PROJ-B001"), "msg: {message}");
2072 assert!(message.contains("demo-card"), "msg: {message}");
2073 }
2074
2075 #[test]
2076 fn test_build_allows_unused_discovered_scripted_component_without_coverage() {
2077 let app = create_app_dir(&[
2078 ("index.html", "<demo-card></demo-card>"),
2079 ("demo-card.html", "<p>{{name}}</p>"),
2080 ("demo-card.ts", "export {};"),
2081 ("unused-card.html", "<p>{{title}}</p>"),
2085 ("unused-card.ts", "export {};"),
2086 ]);
2087 let manifest_json = projection::test_support::build_valid_manifest_json(
2088 app.path(),
2089 &[("demo-card.ts", "export {};")],
2090 &[("projection-bundle.js", "output")],
2091 &[(
2092 "demo-card",
2093 "demo-card.ts",
2094 &["projection-bundle.js"],
2095 &["name"],
2096 &["name"],
2097 )],
2098 );
2099 let manifest = projection::test_support::write_manifest(
2100 app.path(),
2101 "webui-projection.json",
2102 &manifest_json,
2103 );
2104 let mut options = default_options(app.path());
2105 options.plugin = Some(Plugin::WebUI);
2106 options.projection_manifests = vec![manifest.into()];
2107 let result = build(options).unwrap();
2108
2109 assert!(!result.protocol.fragments.contains_key("unused-card"));
2110 let component = result.protocol.components.get("demo-card").unwrap();
2111 assert_eq!(component.hydration_keys, vec!["name"]);
2112 }
2113
2114 #[test]
2115 fn test_build_merges_multiple_projection_manifest_fragments() {
2116 let app = create_app_dir(&[
2117 (
2118 "index.html",
2119 "<html><body><card-a></card-a><card-b></card-b></body></html>",
2120 ),
2121 ("card-a.html", "<p>{{alpha}}</p>"),
2122 ("card-a.ts", "export {};"),
2123 ("card-b.html", "<p>{{beta}}</p>"),
2124 ("card-b.ts", "export {};"),
2125 ]);
2126 let json_a = projection::test_support::build_valid_manifest_json(
2127 app.path(),
2128 &[("card-a.ts", "export {};")],
2129 &[("card-a.js", "output-a")],
2130 &[(
2131 "card-a",
2132 "card-a.ts",
2133 &["card-a.js"],
2134 &["alpha"],
2135 &["alpha"],
2136 )],
2137 );
2138 let manifest_a =
2139 projection::test_support::write_manifest(app.path(), "fragment-a.json", &json_a);
2140 let json_b = projection::test_support::build_valid_manifest_json(
2141 app.path(),
2142 &[("card-b.ts", "export {};")],
2143 &[("card-b.js", "output-b")],
2144 &[("card-b", "card-b.ts", &["card-b.js"], &["beta"], &["beta"])],
2145 );
2146 let manifest_b =
2147 projection::test_support::write_manifest(app.path(), "fragment-b.json", &json_b);
2148
2149 let mut options = default_options(app.path());
2150 options.plugin = Some(Plugin::WebUI);
2151 options.projection_manifests = vec![manifest_a.into(), manifest_b.into()];
2152 let result = build(options).unwrap();
2153
2154 assert_eq!(
2155 result.protocol.initial_state_strategy,
2156 webui_protocol::InitialStateStrategy::Components as i32
2157 );
2158 let card_a = result.protocol.components.get("card-a").unwrap();
2159 assert_eq!(card_a.hydration_keys, vec!["alpha"]);
2160 let card_b = result.protocol.components.get("card-b").unwrap();
2161 assert_eq!(card_b.hydration_keys, vec!["beta"]);
2162 }
2163
2164 #[test]
2165 fn test_build_hello_world_example() {
2166 let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
2167 let app_dir = manifest_dir.join("../../examples/app/hello-world/src");
2168
2169 let result = build(BuildOptions {
2170 app_dir,
2171 entry: "index.html".to_string(),
2172 ..BuildOptions::default()
2173 })
2174 .unwrap();
2175
2176 let index = &result.protocol.fragments["index.html"].fragments;
2177 assert!(index.iter().any(
2178 |f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(fl)) if fl.collection == "people")
2179 ));
2180 assert!(index
2181 .iter()
2182 .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
2183 }
2184
2185 #[test]
2188 fn test_css_filename_sanitizes_path_separators() {
2189 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
2192 let result = build(default_options(app.path())).unwrap();
2193
2194 for (filename, _) in &result.css_files {
2196 assert!(
2197 !filename.contains('/') && !filename.contains('\\'),
2198 "CSS filename contains path separator: {filename}"
2199 );
2200 }
2201 }
2202
2203 #[test]
2204 fn test_build_to_disk_css_stays_in_output_dir() {
2205 let app = create_app_dir(&[
2206 ("index.html", "<my-card>Hello</my-card>"),
2207 ("my-card.html", "<div><slot></slot></div>"),
2208 ("my-card.css", ".card { color: red; }"),
2209 ]);
2210 let out = TempDir::new().unwrap();
2211
2212 build_to_disk(default_options(app.path()), out.path()).unwrap();
2213
2214 let css_path = out.path().join("my-card.css");
2216 assert!(css_path.exists());
2217 let canonical = css_path.canonicalize().unwrap();
2218 let out_canonical = out.path().canonicalize().unwrap();
2219 assert!(
2220 canonical.starts_with(&out_canonical),
2221 "CSS file escaped output directory"
2222 );
2223 }
2224
2225 #[test]
2228 fn test_build_empty_html() {
2229 let app = create_app_dir(&[("index.html", "")]);
2230 let result = build(default_options(app.path())).unwrap();
2231
2232 assert!(result.protocol.fragments.contains_key("index.html"));
2233 assert_eq!(result.stats.fragment_count, 0);
2234 assert!(result.stats.protocol_size_bytes > 0);
2235 }
2236
2237 #[test]
2238 fn test_build_large_fragment_count() {
2239 let mut html = String::with_capacity(2000);
2241 for i in 0..50 {
2242 html.push_str(&format!("<p>Item {i}</p>\n"));
2243 }
2244 let app = create_app_dir(&[("index.html", &html)]);
2245 let result = build(default_options(app.path())).unwrap();
2246
2247 assert!(result.stats.fragment_count > 0);
2248 assert_eq!(result.stats.css_file_count, 0);
2249 }
2250
2251 #[test]
2252 fn test_build_with_fast_plugin() {
2253 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
2254 let mut options = default_options(app.path());
2255 options.plugin = Some(Plugin::FastV3);
2256
2257 let result = build(options).unwrap();
2258 assert!(result.protocol.fragments.contains_key("index.html"));
2259 assert_eq!(
2260 result.protocol.initial_state_strategy,
2261 webui_protocol::InitialStateStrategy::Full as i32
2262 );
2263 }
2264
2265 #[test]
2266 fn test_build_to_disk_inline_mode_no_css_files() {
2267 let app = create_app_dir(&[
2268 ("index.html", "<my-card>Hello</my-card>"),
2269 ("my-card.html", "<div><slot></slot></div>"),
2270 ("my-card.css", ".card { color: red; }"),
2271 ]);
2272 let out = TempDir::new().unwrap();
2273 let mut options = default_options(app.path());
2274 options.css = CssStrategy::Style;
2275
2276 let stats = build_to_disk(options, out.path()).unwrap();
2277
2278 assert!(out.path().join("protocol.bin").exists());
2279 assert!(!out.path().join("my-card.css").exists());
2280 assert_eq!(stats.css_file_count, 0);
2281 }
2282
2283 #[test]
2284 fn test_build_stats_duration_is_nonzero() {
2285 let app = create_app_dir(&[("index.html", "<h1>Hello {{name}}</h1>")]);
2286 let result = build(default_options(app.path())).unwrap();
2287
2288 assert!(!result.stats.duration.is_zero());
2289 }
2290
2291 #[test]
2292 fn test_build_multiple_components_css() {
2293 let app = create_app_dir(&[
2294 ("index.html", "<card-a>A</card-a><card-b>B</card-b>"),
2295 ("card-a.html", "<div><slot></slot></div>"),
2296 ("card-a.css", ".a { color: red; }"),
2297 ("card-b.html", "<span><slot></slot></span>"),
2298 ("card-b.css", ".b { color: blue; }"),
2299 ]);
2300 let result = build(default_options(app.path())).unwrap();
2301
2302 assert_eq!(result.css_files.len(), 2);
2303 assert_eq!(result.stats.css_file_count, 2);
2304 let filenames: Vec<&str> = result.css_files.iter().map(|(n, _)| n.as_str()).collect();
2305 assert!(filenames.contains(&"card-a.css"));
2306 assert!(filenames.contains(&"card-b.css"));
2307 }
2308
2309 #[test]
2310 fn test_build_unused_component_css_not_emitted() {
2311 let app = create_app_dir(&[
2313 ("index.html", "<card-a>A</card-a>"),
2314 ("card-a.html", "<div><slot></slot></div>"),
2315 ("card-a.css", ".a { color: red; }"),
2316 ("card-b.html", "<span><slot></slot></span>"),
2317 ("card-b.css", ".b { color: blue; }"),
2318 ]);
2319 let result = build(default_options(app.path())).unwrap();
2320
2321 assert_eq!(result.css_files.len(), 1);
2322 assert_eq!(result.css_files[0].0, "card-a.css");
2323 }
2324
2325 #[test]
2326 fn test_inspect_roundtrip_preserves_content() {
2327 let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
2328 let result = build(default_options(app.path())).unwrap();
2329
2330 let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
2331 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
2332
2333 let fragments = &parsed["fragments"]["index.html"]["fragments"];
2334 assert!(fragments.is_array());
2335 assert!(!fragments.as_array().unwrap().is_empty());
2336 }
2337
2338 #[test]
2339 fn test_build_nonexistent_app_dir() {
2340 let options = BuildOptions {
2341 app_dir: PathBuf::from("/nonexistent/path/that/does/not/exist"),
2342 ..BuildOptions::default()
2343 };
2344 let result = build(options);
2345 assert!(result.is_err());
2346 }
2347
2348 #[test]
2349 fn test_build_allows_fallback_chain_when_all_theme_tokens_exist() {
2350 let app = create_app_dir(&[
2351 ("index.html", "<my-card></my-card>"),
2352 ("my-card.html", "<div>Card</div>"),
2353 (
2354 "my-card.css",
2355 ":host { color: var(--token-a, var(--token-b, var(--token-c))); }",
2356 ),
2357 ]);
2358 let mut options = default_options(app.path());
2359 options.theme = Some(TokenFile {
2360 themes: HashMap::from([(
2361 "light".to_string(),
2362 HashMap::from([
2363 ("token-a".to_string(), "red".to_string()),
2364 ("token-b".to_string(), "blue".to_string()),
2365 ("token-c".to_string(), "green".to_string()),
2366 ]),
2367 )]),
2368 });
2369
2370 let result = build(options).expect("all fallback tokens are defined");
2371 assert!(result.protocol.tokens.contains(&"token-a".to_string()));
2372 assert!(result.protocol.tokens.contains(&"token-b".to_string()));
2373 assert!(result.protocol.tokens.contains(&"token-c".to_string()));
2374 }
2375
2376 #[test]
2377 fn test_build_allows_literal_fallback_token_absent_from_theme() {
2378 let app = create_app_dir(&[
2379 ("index.html", "<my-card></my-card>"),
2380 ("my-card.html", "<div>Card</div>"),
2381 ("my-card.css", ":host { color: var(--brand, #000); }"),
2382 ]);
2383 let mut options = default_options(app.path());
2384 options.theme = Some(TokenFile {
2385 themes: HashMap::from([("light".to_string(), HashMap::new())]),
2386 });
2387
2388 let result = build(options).expect("literal fallback should not fail the build");
2392 assert!(result.protocol.tokens.contains(&"brand".to_string()));
2393 assert_eq!(result.warnings.len(), 1, "warnings: {:?}", result.warnings);
2396 assert!(result.warnings[0].body().contains("--brand"));
2397 }
2398
2399 #[test]
2400 fn test_build_ancestor_inline_style_definition_after_comment_satisfies_descendant() {
2401 let app = create_app_dir(&[
2406 (
2407 "index.html",
2408 "<html><head><style>:root {\n /*{{{tokens.light}}}*/\n --foo-bar: 100px;\n}</style></head><body><my-card></my-card></body></html>",
2409 ),
2410 ("my-card.html", "<div>Card</div>"),
2411 ("my-card.css", ":host { padding: var(--foo-bar); }"),
2412 ]);
2413 let mut options = default_options(app.path());
2414 options.theme = Some(TokenFile {
2415 themes: HashMap::from([("light".to_string(), HashMap::new())]),
2416 });
2417
2418 let result = build(options)
2419 .expect("ancestor inline-style definition (after a comment) should satisfy the child");
2420 assert!(
2421 !result.protocol.tokens.contains(&"foo-bar".to_string()),
2422 "ancestor-defined token should not be hoisted: {:?}",
2423 result.protocol.tokens
2424 );
2425 }
2426
2427 #[test]
2428 fn test_build_no_warning_when_literal_fallback_token_is_themed() {
2429 let app = create_app_dir(&[
2430 ("index.html", "<my-card></my-card>"),
2431 ("my-card.html", "<div>Card</div>"),
2432 ("my-card.css", ":host { color: var(--brand, #000); }"),
2433 ]);
2434 let mut options = default_options(app.path());
2435 options.theme = Some(TokenFile {
2436 themes: HashMap::from([(
2437 "light".to_string(),
2438 HashMap::from([("brand".to_string(), "#123456".to_string())]),
2439 )]),
2440 });
2441
2442 let result = build(options).expect("themed literal-fallback token builds");
2443 assert!(
2444 result.warnings.is_empty(),
2445 "a themed token must not warn: {:?}",
2446 result.warnings
2447 );
2448 }
2449
2450 #[test]
2451 fn test_build_without_theme_produces_no_warnings() {
2452 let app = create_app_dir(&[
2453 ("index.html", "<my-card></my-card>"),
2454 ("my-card.html", "<div>Card</div>"),
2455 ("my-card.css", ":host { color: var(--brand, #000); }"),
2456 ]);
2457 let result = build(default_options(app.path())).unwrap();
2458 assert!(result.warnings.is_empty());
2459 }
2460
2461 #[test]
2462 fn test_build_excludes_tokens_defined_by_ancestor_component_css() {
2463 let app = create_app_dir(&[
2464 ("index.html", "<component-a></component-a>"),
2465 ("component-a.html", "<component-b></component-b>"),
2466 ("component-a.css", ":host { --brand-color: red; }"),
2467 ("component-b.html", "<div>Child</div>"),
2468 ("component-b.css", ":host { color: var(--brand-color); }"),
2469 ]);
2470 let mut options = default_options(app.path());
2471 options.theme = Some(TokenFile {
2472 themes: HashMap::from([("light".to_string(), HashMap::new())]),
2473 });
2474
2475 let result =
2476 build(options).expect("ancestor component custom property should satisfy child");
2477 assert!(
2478 !result.protocol.tokens.contains(&"brand-color".to_string()),
2479 "ancestor-defined token should not be exposed: {:?}",
2480 result.protocol.tokens
2481 );
2482 }
2483
2484 #[test]
2485 fn test_build_does_not_use_sibling_component_definitions_for_theme_validation() {
2486 let app = create_app_dir(&[
2487 (
2488 "index.html",
2489 "<component-a></component-a><component-b></component-b>",
2490 ),
2491 ("component-a.html", "<div>A</div>"),
2492 ("component-a.css", ":host { --brand-color: red; }"),
2493 ("component-b.html", "<div>B</div>"),
2494 ("component-b.css", ":host { color: var(--brand-color); }"),
2495 ]);
2496 let mut options = default_options(app.path());
2497 options.theme = Some(TokenFile {
2498 themes: HashMap::from([("light".to_string(), HashMap::new())]),
2499 });
2500
2501 let Err(err) = build(options) else {
2502 panic!("sibling custom property must not satisfy component-b");
2503 };
2504 let message = err.chain_message();
2505 assert!(message.contains("missing theme token"), "msg: {message}");
2506 assert!(message.contains("missing-theme-token"), "msg: {message}");
2507 assert!(message.contains("--brand-color"), "msg: {message}");
2508 }
2509
2510 #[test]
2511 fn test_build_rejects_missing_theme_token_after_local_definition_filter() {
2512 let app = create_app_dir(&[
2513 ("index.html", "<my-card></my-card>"),
2514 ("my-card.html", "<div>Card</div>"),
2515 (
2516 "my-card.css",
2517 ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c))); }",
2518 ),
2519 ]);
2520 let mut options = default_options(app.path());
2521 options.theme = Some(TokenFile {
2522 themes: HashMap::from([(
2523 "light".to_string(),
2524 HashMap::from([("token-b".to_string(), "green".to_string())]),
2525 )]),
2526 });
2527
2528 let Err(err) = build(options) else {
2529 panic!("missing --token-c in the theme must fail the build");
2530 };
2531 let message = err.chain_message();
2532 assert!(message.contains("missing-theme-token"), "msg: {message}");
2533 assert!(message.contains("--token-c"), "msg: {message}");
2534 assert!(!message.contains("add --token-a"), "msg: {message}");
2537 }
2538
2539 #[test]
2540 fn test_build_to_disk_returns_accurate_stats() {
2541 let app = create_app_dir(&[
2542 ("index.html", "<my-card>Hello</my-card><p>{{name}}</p>"),
2543 ("my-card.html", "<div><slot></slot></div>"),
2544 ("my-card.css", ".card { color: red; }"),
2545 ]);
2546 let out = TempDir::new().unwrap();
2547
2548 let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
2549
2550 assert!(stats.fragment_count > 0);
2551 assert_eq!(stats.css_file_count, 1);
2552 assert!(stats.component_count > 0);
2553 assert!(stats.protocol_size_bytes > 0);
2554 assert!(!stats.duration.is_zero());
2555 }
2556}