1mod component_assets;
24mod error;
25mod projection;
26pub mod server;
27pub mod streaming;
28
29pub use component_assets::{render_component_assets, ComponentAssetFile};
30pub use error::WebUIError;
31
32pub use webui_handler::route_handler::{encode_inventory, get_needed_components, parse_inventory};
34pub use webui_handler::Result as HandlerResult;
35pub use webui_handler::{
36 plugin::HandlerPlugin, HandlerError, Protocol, RenderOptions, ResponseWriter, WebUIHandler,
37};
38pub use webui_parser::plugin::{ComponentTemplateArtifact, StateSurface};
39pub use webui_parser::CssStrategy;
40pub use webui_parser::DomStrategy;
41pub use webui_parser::LegalComments;
42pub use webui_parser::ParserError;
43pub use webui_parser::ParserOptions;
44pub use webui_parser::Plugin;
45pub use webui_parser::DEFAULT_CSS_FILE_NAME_TEMPLATE;
46pub use webui_parser::{
47 AssetFileNameTemplate, AssetFileNameTemplateError, DEFAULT_ASSET_FILE_NAME_TEMPLATE,
48};
49pub use webui_parser::{CssFallbackChain, CssTokenAnalysis};
50pub use webui_parser::{Diagnostic, Severity};
51pub use webui_protocol::WebUIProtocol;
52pub use webui_tokens::{load_token_file, resolve_theme_path, TokenFile};
53
54use std::collections::HashSet;
55use std::ffi::{OsStr, OsString};
56use std::fs;
57use std::path::Path;
58use std::time::Instant;
59use webui_parser::plugin::fast_v2::FastV2ParserPlugin;
60use webui_parser::plugin::fast_v3::FastV3ParserPlugin;
61use webui_parser::plugin::webui::WebUIParserPlugin;
62use webui_parser::plugin::ParserPluginArtifacts;
63use webui_parser::HtmlParser;
64
65#[derive(Debug, Clone)]
67pub struct PreparedProjectionManifests {
68 snapshot: std::sync::Arc<projection::ProjectionSnapshot>,
69}
70
71#[derive(Debug)]
72enum ProjectionBarrierState {
73 Waiting,
74 Ready(PreparedProjectionManifests),
75 Failed(String),
76}
77
78#[doc(hidden)]
80#[derive(Debug, Clone)]
81pub struct PendingProjectionManifests {
82 state: std::sync::Arc<(std::sync::Mutex<ProjectionBarrierState>, std::sync::Condvar)>,
83}
84
85#[doc(hidden)]
87pub struct ProjectionManifestCompleter {
88 state: std::sync::Arc<(std::sync::Mutex<ProjectionBarrierState>, std::sync::Condvar)>,
89 completed: bool,
90}
91
92#[derive(Debug, Clone)]
94pub enum ProjectionManifestSource {
95 Path(std::path::PathBuf),
97 Inline {
102 manifest_path: std::path::PathBuf,
104 json: String,
106 },
107 Prepared(PreparedProjectionManifests),
110 #[doc(hidden)]
112 Pending(PendingProjectionManifests),
113}
114
115impl From<std::path::PathBuf> for ProjectionManifestSource {
116 fn from(path: std::path::PathBuf) -> Self {
117 Self::Path(path)
118 }
119}
120
121pub fn prepare_projection_manifests(
126 sources: &[ProjectionManifestSource],
127) -> Result<PreparedProjectionManifests, WebUIError> {
128 let snapshot = projection::load_and_merge(sources)?
129 .unwrap_or_else(|| std::sync::Arc::new(projection::ProjectionSnapshot::default()));
130 Ok(PreparedProjectionManifests { snapshot })
131}
132
133#[doc(hidden)]
135#[must_use]
136pub fn projection_manifest_barrier() -> (ProjectionManifestSource, ProjectionManifestCompleter) {
137 let state = std::sync::Arc::new((
138 std::sync::Mutex::new(ProjectionBarrierState::Waiting),
139 std::sync::Condvar::new(),
140 ));
141 (
142 ProjectionManifestSource::Pending(PendingProjectionManifests {
143 state: std::sync::Arc::clone(&state),
144 }),
145 ProjectionManifestCompleter {
146 state,
147 completed: false,
148 },
149 )
150}
151
152impl PendingProjectionManifests {
153 fn wait(&self) -> Result<PreparedProjectionManifests, WebUIError> {
154 let (lock, ready) = self.state.as_ref();
155 let mut state = lock.lock().map_err(|_| {
156 WebUIError::InvalidBuildOptions("projection synchronization lock poisoned".to_string())
157 })?;
158 loop {
159 match &*state {
160 ProjectionBarrierState::Waiting => {
161 state = ready.wait(state).map_err(|_| {
162 WebUIError::InvalidBuildOptions(
163 "projection synchronization lock poisoned".to_string(),
164 )
165 })?;
166 }
167 ProjectionBarrierState::Ready(projection) => {
168 return Ok(projection.clone());
169 }
170 ProjectionBarrierState::Failed(message) => {
171 return Err(WebUIError::InvalidBuildOptions(message.clone()));
172 }
173 }
174 }
175 }
176}
177
178impl ProjectionManifestCompleter {
179 pub fn complete(mut self, result: std::result::Result<PreparedProjectionManifests, String>) {
181 let (lock, ready) = self.state.as_ref();
182 if let Ok(mut state) = lock.lock() {
183 *state = match result {
184 Ok(projection) => ProjectionBarrierState::Ready(projection),
185 Err(message) => ProjectionBarrierState::Failed(message),
186 };
187 self.completed = true;
188 ready.notify_all();
189 }
190 }
191}
192
193impl Drop for ProjectionManifestCompleter {
194 fn drop(&mut self) {
195 if self.completed {
196 return;
197 }
198 let (lock, ready) = self.state.as_ref();
199 if let Ok(mut state) = lock.lock() {
200 *state = ProjectionBarrierState::Failed(
201 "projection producer terminated before completing metadata".to_string(),
202 );
203 ready.notify_all();
204 }
205 }
206}
207
208#[derive(Debug, Clone)]
210pub struct BuildOptions {
211 pub app_dir: std::path::PathBuf,
213 pub entry: String,
215 pub css: CssStrategy,
217 pub dom: DomStrategy,
219 pub plugin: Option<Plugin>,
221 pub components: Vec<String>,
223 pub component_asset_roots: Vec<String>,
229 pub css_file_name_template: String,
233 pub css_public_base: Option<String>,
236 pub legal_comments: LegalComments,
238 pub theme: Option<TokenFile>,
246 pub projection_manifests: Vec<ProjectionManifestSource>,
267}
268
269impl Default for BuildOptions {
270 fn default() -> Self {
271 Self {
272 app_dir: std::path::PathBuf::from("."),
273 entry: "index.html".to_string(),
274 css: CssStrategy::Link,
275 dom: DomStrategy::Shadow,
276 plugin: None,
277 components: Vec::new(),
278 component_asset_roots: Vec::new(),
279 css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
280 css_public_base: None,
281 legal_comments: LegalComments::default(),
282 theme: None,
283 projection_manifests: Vec::new(),
284 }
285 }
286}
287
288#[derive(Debug, Clone)]
290pub struct BuildStats {
291 pub duration: std::time::Duration,
293 pub fragment_count: usize,
295 pub component_count: usize,
297 pub css_file_count: usize,
299 pub protocol_size_bytes: usize,
301 pub token_count: usize,
303}
304
305#[derive(Debug)]
307pub struct BuildResult {
308 pub protocol: WebUIProtocol,
310 pub protocol_bytes: Vec<u8>,
312 pub css_files: Vec<(String, String)>,
314 pub component_asset_files: Vec<ComponentAssetFile>,
318 pub component_templates: Vec<ComponentTemplateArtifact>,
322 pub warnings: Vec<Diagnostic>,
329 pub stats: BuildStats,
331}
332
333#[must_use = "BuildResult contains the compiled protocol and statistics"]
343pub fn build(options: BuildOptions) -> Result<BuildResult, WebUIError> {
344 let started = Instant::now();
345
346 let raw = build_protocol_inner(&options)?;
347
348 let protocol_bytes = raw.protocol.to_protobuf()?;
349
350 let stats = BuildStats {
351 duration: started.elapsed(),
352 fragment_count: raw.fragment_count,
353 component_count: raw.component_count,
354 css_file_count: raw.css_files.len(),
355 protocol_size_bytes: protocol_bytes.len(),
356 token_count: raw.token_count,
357 };
358
359 Ok(BuildResult {
360 protocol: raw.protocol,
361 protocol_bytes,
362 css_files: raw.css_files,
363 component_asset_files: raw.component_asset_files,
364 component_templates: raw.component_templates,
365 warnings: raw.warnings,
366 stats,
367 })
368}
369
370pub fn build_to_disk(options: BuildOptions, out_dir: &Path) -> Result<BuildStats, WebUIError> {
380 let result = build(options)?;
381 validate_output_file_names(OsStr::new("protocol.bin"), &result)?;
382
383 fs::create_dir_all(out_dir).map_err(|source| WebUIError::Io {
384 context: format!("Failed to create {}", out_dir.display()),
385 source,
386 })?;
387
388 fs::write(out_dir.join("protocol.bin"), &result.protocol_bytes).map_err(|source| {
389 WebUIError::Io {
390 context: format!("Failed to write protocol.bin to {}", out_dir.display()),
391 source,
392 }
393 })?;
394
395 for (name, content) in &result.css_files {
396 fs::write(out_dir.join(name), content).map_err(|source| WebUIError::Io {
397 context: format!("Failed to write {name} to {}", out_dir.display()),
398 source,
399 })?;
400 }
401 for file in &result.component_asset_files {
402 fs::write(out_dir.join(&file.name), &file.content).map_err(|source| WebUIError::Io {
403 context: format!(
404 "Failed to write component asset {} to {}",
405 file.name,
406 out_dir.display()
407 ),
408 source,
409 })?;
410 }
411
412 Ok(result.stats)
413}
414
415pub fn inspect(protocol_path: &Path) -> Result<String, WebUIError> {
417 let bytes = fs::read(protocol_path).map_err(|source| WebUIError::Io {
418 context: format!("Failed to read {}", protocol_path.display()),
419 source,
420 })?;
421 inspect_bytes(&bytes)
422}
423
424pub fn inspect_bytes(protocol_bytes: &[u8]) -> Result<String, WebUIError> {
426 let protocol = WebUIProtocol::from_protobuf(protocol_bytes)?;
427 protocol
428 .to_json_pretty()
429 .map_err(|e| WebUIError::Serialization(e.to_string()))
430}
431
432struct RawBuildOutput {
434 protocol: WebUIProtocol,
435 css_files: Vec<(String, String)>,
436 component_asset_files: Vec<ComponentAssetFile>,
437 component_templates: Vec<ComponentTemplateArtifact>,
438 warnings: Vec<Diagnostic>,
439 fragment_count: usize,
440 component_count: usize,
441 token_count: usize,
442}
443
444fn build_protocol_inner(options: &BuildOptions) -> Result<RawBuildOutput, WebUIError> {
446 let projection_enabled = !options.projection_manifests.is_empty();
447 if projection_enabled && options.plugin != Some(Plugin::WebUI) {
448 return Err(projection::incompatible_plugin_error());
449 }
450 let parser_options = ParserOptions::try_new(
451 options.css,
452 options.dom,
453 &options.css_file_name_template,
454 options.css_public_base.as_deref(),
455 options.legal_comments,
456 )
457 .map_err(|e| WebUIError::InvalidBuildOptions(e.to_string()))?;
458 let css_link_options = parser_options.css_link_options.clone();
459
460 let mut parser = match options.plugin {
461 Some(Plugin::Fast | Plugin::FastV2) => {
462 HtmlParser::with_plugin_options(Box::new(FastV2ParserPlugin::new()), parser_options)
463 }
464 Some(Plugin::FastV3) => {
465 HtmlParser::with_plugin_options(Box::new(FastV3ParserPlugin::new()), parser_options)
466 }
467 Some(Plugin::WebUI) => {
468 HtmlParser::with_plugin_options(Box::new(WebUIParserPlugin::new()), parser_options)
469 }
470 None => HtmlParser::with_options(parser_options),
471 };
472
473 parser
475 .component_registry_mut()
476 .register_from_paths(&[&options.app_dir])
477 .map_err(|e| {
478 WebUIError::ComponentRegistration(format!(
479 "Failed to register components from {}: {e}",
480 options.app_dir.display()
481 ))
482 })?;
483
484 for source in &options.components {
486 let result = webui_discovery::discover_source(source, &options.app_dir).map_err(|e| {
487 WebUIError::ComponentDiscovery(format!(
488 "Failed to discover components from {source}: {e}"
489 ))
490 })?;
491 for comp in &result.components {
492 parser
495 .component_registry_mut()
496 .register_component(webui_parser::ComponentRegistration {
497 tag_name: &comp.tag_name,
498 html_content: &comp.html_content,
499 css_content: comp.css_content.as_deref(),
500 is_client_owned: comp.is_client_owned,
501 })
502 .map_err(|e| {
503 WebUIError::ComponentRegistration(format!(
504 "Failed to register component '{}' from {}: {e}",
505 comp.tag_name, comp.source
506 ))
507 })?;
508 }
509 }
510
511 let component_count = parser.component_registry().len();
512
513 let entry_path = options.app_dir.join(&options.entry);
515 let html_content = fs::read_to_string(&entry_path).map_err(|source| WebUIError::Io {
516 context: format!("Failed to read {}", entry_path.display()),
517 source,
518 })?;
519 parser
520 .parse(&options.entry, &html_content)
521 .map_err(|source| WebUIError::Parse {
522 context: format!("Failed to parse {}", options.entry),
523 source,
524 })?;
525
526 let synthetic_asset_fragments =
527 parse_component_asset_roots(&mut parser, &options.component_asset_roots)?;
528
529 let css_snapshot: Vec<(String, String)> = parser
530 .component_registry()
531 .get_all()
532 .filter(|component| parser.has_fragment(&component.tag_name))
533 .filter_map(|component| {
534 component
535 .css_content
536 .as_ref()
537 .map(|css| (component.tag_name.clone(), css.clone()))
538 })
539 .collect();
540
541 let token_analysis = parser.token_analysis();
543 let mut warnings: Vec<Diagnostic> = Vec::new();
544 if let Some(theme) = options.theme.as_ref() {
545 token_analysis
546 .validate_theme_tokens(theme)
547 .map_err(|source| WebUIError::Parse {
548 context: "Failed to validate theme tokens".to_string(),
549 source,
550 })?;
551 warnings = token_analysis.theme_token_warnings(theme);
556 }
557 let token_count = token_analysis.protocol_tokens.len();
558
559 let component_templates =
560 match parser
561 .take_plugin_artifacts()
562 .map_err(|source| WebUIError::Parse {
563 context: format!(
564 "Failed to compile component templates for {}",
565 options.entry
566 ),
567 source,
568 })? {
569 ParserPluginArtifacts::None => Vec::new(),
570 ParserPluginArtifacts::ComponentTemplates(templates) => templates,
571 };
572 let mut fragment_records = parser.into_fragment_records();
574 for fragment_id in synthetic_asset_fragments {
575 fragment_records.remove(&fragment_id);
576 }
577 let fragment_count: usize = fragment_records.values().map(|v| v.fragments.len()).sum();
578
579 let merged_manifest = projection::load_and_merge(&options.projection_manifests)?;
583 let mut protocol = WebUIProtocol::with_tokens(fragment_records, token_analysis.protocol_tokens);
584 protocol.initial_state_strategy = if merged_manifest.is_some() {
585 webui_protocol::InitialStateStrategy::Components as i32
586 } else {
587 webui_protocol::InitialStateStrategy::Full as i32
588 };
589
590 if let Some(merged) = &merged_manifest {
595 let compiled_scripted_tags: Vec<&str> = component_templates
596 .iter()
597 .filter(|artifact| {
598 artifact.is_scripted && protocol.fragments.contains_key(&artifact.tag_name)
599 })
600 .map(|artifact| artifact.tag_name.as_str())
601 .collect();
602 projection::validate_coverage(&merged.components, &compiled_scripted_tags)?;
603 }
604
605 protocol.set_css_strategy(match options.css {
607 CssStrategy::Link => webui_protocol::CssStrategy::Link,
608 CssStrategy::Style => webui_protocol::CssStrategy::Style,
609 CssStrategy::Module => webui_protocol::CssStrategy::Module,
610 });
611 protocol.set_dom_strategy(match options.dom {
612 DomStrategy::Shadow => webui_protocol::DomStrategy::Shadow,
613 DomStrategy::Light => webui_protocol::DomStrategy::Light,
614 });
615
616 let is_module = options.css == CssStrategy::Module;
619 let is_link = options.css == CssStrategy::Link;
620 let mut css_files: Vec<(String, String)> = Vec::new();
621 let mut emitted_names: HashSet<String> = HashSet::new();
622 for (tag, css) in css_snapshot {
623 if !protocol.fragments.contains_key(&tag) {
624 continue;
625 }
626 if is_module {
627 protocol.components.entry(tag).or_default().css = css.trim().to_string();
628 } else if is_link {
629 let resolved = css_link_options.resolve(&tag, &css);
630 if !emitted_names.insert(resolved.filename.clone()) {
631 return Err(WebUIError::InvalidBuildOptions(format!(
632 "CSS filename collision for Link strategy: '{}'. Adjust the asset filename template to include [name] or another unique component-specific segment.",
633 resolved.filename
634 )));
635 }
636 protocol.components.entry(tag).or_default().css_href = resolved.href;
637 css_files.push((resolved.filename, css));
638 }
639 }
642
643 for artifact in &component_templates {
647 let component = protocol
648 .components
649 .entry(artifact.tag_name.clone())
650 .or_default();
651 component.template = artifact.template.clone();
652 component.template_json = artifact.template_json.clone();
653 component.template_functions = artifact.template_functions.clone();
654 let manifest_entry = if artifact.is_scripted {
659 merged_manifest
660 .as_ref()
661 .and_then(|merged| merged.components.get(&artifact.tag_name))
662 } else {
663 None
664 };
665 let (hydration_mode, hydration_keys) = match manifest_entry {
666 Some(entry) => encode_state_surface(&StateSurface::Keys(entry.hydration_keys.clone())),
667 None => encode_state_surface(&artifact.hydration),
668 };
669 component.hydration_mode = hydration_mode;
670 component.hydration_keys = hydration_keys;
671 let (navigation_mode, navigation_keys) = match manifest_entry {
672 Some(entry) => {
673 let union =
674 projection::union_keys(&entry.navigation_keys, &artifact.template_roots);
675 encode_state_surface(&StateSurface::Keys(union))
676 }
677 None => encode_state_surface(&artifact.navigation),
678 };
679 component.navigation_mode = navigation_mode;
680 component.navigation_keys = navigation_keys;
681 }
682
683 let component_asset_files = component_assets::render_component_assets(
684 &protocol,
685 &options.component_asset_roots,
686 &options.css_file_name_template,
687 )?;
688 validate_generated_file_names(&css_files, &component_asset_files)?;
689
690 Ok(RawBuildOutput {
691 protocol,
692 css_files,
693 component_asset_files,
694 component_templates,
695 warnings,
696 fragment_count,
697 component_count,
698 token_count,
699 })
700}
701
702fn encode_state_surface(surface: &StateSurface) -> (i32, Vec<String>) {
703 match surface {
704 StateSurface::None => (webui_protocol::StateProjectionMode::None as i32, Vec::new()),
705 StateSurface::Keys(keys) => (
706 webui_protocol::StateProjectionMode::Keys as i32,
707 keys.clone(),
708 ),
709 StateSurface::All => (webui_protocol::StateProjectionMode::All as i32, Vec::new()),
710 }
711}
712
713fn parse_component_asset_roots(
714 parser: &mut HtmlParser,
715 roots: &[String],
716) -> Result<Vec<String>, WebUIError> {
717 let mut synthetic_fragments = Vec::with_capacity(roots.len());
718 for (index, root) in roots.iter().enumerate() {
719 let root = root.trim();
720 if parser.has_fragment(root) {
721 continue;
722 }
723 let mut synthetic = String::with_capacity(root.len() * 2 + 5);
726 synthetic.push('<');
727 synthetic.push_str(root);
728 synthetic.push_str("></");
729 synthetic.push_str(root);
730 synthetic.push('>');
731
732 let fragment_id = format!("__webui_asset_root_{index}");
733 parser
734 .parse(&fragment_id, &synthetic)
735 .map_err(|source| WebUIError::Parse {
736 context: format!("Failed to parse component asset root <{root}>"),
737 source,
738 })?;
739 synthetic_fragments.push(fragment_id);
740 }
741 Ok(synthetic_fragments)
742}
743
744fn validate_generated_file_names(
745 css_files: &[(String, String)],
746 component_asset_files: &[ComponentAssetFile],
747) -> Result<(), WebUIError> {
748 let mut names = HashSet::with_capacity(css_files.len() + component_asset_files.len());
749 for (name, _) in css_files {
750 names.insert(name.as_str());
751 }
752 for file in component_asset_files {
753 if !names.insert(file.name.as_str()) {
754 return Err(WebUIError::InvalidBuildOptions(format!(
755 "output filename collision for '{}'. Adjust the asset filename template to include [ext] or another unique asset-type segment.",
756 file.name
757 )));
758 }
759 }
760 Ok(())
761}
762
763fn validate_output_file_names(
764 protocol_name: &OsStr,
765 result: &BuildResult,
766) -> Result<(), WebUIError> {
767 let mut names =
768 HashSet::with_capacity(1 + result.css_files.len() + result.component_asset_files.len());
769 names.insert(protocol_name.to_os_string());
770 for (name, _) in &result.css_files {
771 let file_name = OsString::from(name);
772 if !names.insert(file_name.clone()) {
773 return Err(output_file_collision_error(&file_name));
774 }
775 }
776 for file in &result.component_asset_files {
777 let file_name = OsString::from(&file.name);
778 if !names.insert(file_name.clone()) {
779 return Err(output_file_collision_error(&file_name));
780 }
781 }
782 Ok(())
783}
784
785fn output_file_collision_error(name: &OsStr) -> WebUIError {
786 WebUIError::InvalidBuildOptions(format!(
787 "output filename collision for '{}'. Adjust the asset filename template to include [ext] or another unique asset-type segment.",
788 name.to_string_lossy()
789 ))
790}
791
792#[cfg(test)]
793#[allow(clippy::disallowed_methods)]
794mod tests {
795 use super::*;
796 use std::collections::HashMap;
797 use std::path::PathBuf;
798 use tempfile::TempDir;
799 use webui_handler::plugin::webui::WebUIHydrationPlugin;
800 use webui_protocol::web_ui_fragment::Fragment;
801
802 struct StringWriter {
803 buf: String,
804 }
805
806 impl ResponseWriter for StringWriter {
807 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
808 self.buf.push_str(content);
809 Ok(())
810 }
811
812 fn end(&mut self) -> webui_handler::Result<()> {
813 Ok(())
814 }
815 }
816
817 fn create_app_dir(files: &[(&str, &str)]) -> TempDir {
818 let dir = TempDir::new().unwrap();
819 for (name, content) in files {
820 let path = dir.path().join(name);
821 if let Some(parent) = path.parent() {
822 fs::create_dir_all(parent).unwrap();
823 }
824 fs::write(&path, content).unwrap();
825 }
826 dir
827 }
828
829 fn default_options(app_dir: &Path) -> BuildOptions {
830 BuildOptions {
831 app_dir: app_dir.to_path_buf(),
832 ..BuildOptions::default()
833 }
834 }
835
836 #[test]
837 fn test_build_simple_html() {
838 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
839 let result = build(default_options(app.path())).unwrap();
840
841 assert!(result.protocol.fragments.contains_key("index.html"));
842 assert!(result.stats.fragment_count > 0);
843 assert!(result.stats.protocol_size_bytes > 0);
844 assert!(!result.stats.duration.is_zero());
845 assert_eq!(
846 result.protocol.initial_state_strategy,
847 webui_protocol::InitialStateStrategy::Full as i32
848 );
849 }
850
851 #[test]
852 fn pending_projection_unblocks_after_template_compilation() {
853 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
854 let (source, completer) = projection_manifest_barrier();
855 let options = BuildOptions {
856 app_dir: app.path().to_path_buf(),
857 plugin: Some(Plugin::WebUI),
858 projection_manifests: vec![source],
859 ..BuildOptions::default()
860 };
861 let handle = std::thread::spawn(move || build(options));
862 let prepared = prepare_projection_manifests(&[]).unwrap();
863 completer.complete(Ok(prepared));
864 let result = handle.join().unwrap().unwrap();
865 assert_eq!(
866 result.protocol.initial_state_strategy,
867 webui_protocol::InitialStateStrategy::Components as i32
868 );
869 }
870
871 #[test]
872 fn test_build_keeps_scriptless_component_dormant_until_client_use() {
873 let app = create_app_dir(&[
874 (
875 "index.html",
876 "<html><body><demo-card></demo-card></body></html>",
877 ),
878 ("demo-card.html", "<p>{{name}} {{serverTitle}}</p>"),
879 ]);
880 let manifest_json =
885 projection::test_support::build_valid_manifest_json(app.path(), &[], &[], &[]);
886 let manifest = projection::test_support::write_manifest(
887 app.path(),
888 "webui-projection.json",
889 &manifest_json,
890 );
891 let mut options = default_options(app.path());
892 options.plugin = Some(Plugin::WebUI);
893 options.projection_manifests = vec![manifest.into()];
894 let result = build(options).unwrap();
895 assert_eq!(
896 result.protocol.initial_state_strategy,
897 webui_protocol::InitialStateStrategy::Components as i32
898 );
899
900 assert!(result
901 .component_templates
902 .iter()
903 .any(|template| template.tag_name == "demo-card"));
904 let component = result
905 .protocol
906 .components
907 .get("demo-card")
908 .expect("scriptless component metadata should be retained");
909 assert!(component.hydration_keys.is_empty());
910 assert_eq!(component.navigation_keys, ["name", "serverTitle"]);
911 assert!(component.template_json.contains(r#""th":1"#));
912
913 let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
914 let state = serde_json::json!({ "name": "Server rendered" });
915 let protocol = Protocol::new(result.protocol.clone());
916 let mut writer = StringWriter { buf: String::new() };
917 handler
918 .render(
919 &protocol,
920 &state,
921 &RenderOptions::new("index.html", "/"),
922 &mut writer,
923 )
924 .unwrap();
925 assert!(writer.buf.contains("Server rendered"));
926 assert!(writer.buf.contains(r#""state":{}"#));
927 assert!(writer.buf.contains(r#""templates":{"demo-card":"#));
928 }
929
930 #[test]
931 fn test_build_treats_sibling_component_script_as_scripted() {
932 let app = create_app_dir(&[
933 ("index.html", "<demo-card></demo-card>"),
934 (
935 "demo-card.html",
936 r#"<button @click="{onClick()}">{{name}}</button>"#,
937 ),
938 (
939 "demo-card.ts",
940 "import { WebUIElement } from '@microsoft/webui-framework';\n\
941 class DemoCard extends WebUIElement { onClick() {} }\n\
942 DemoCard.define('demo-card');",
943 ),
944 ]);
945 let mut options = default_options(app.path());
946 options.plugin = Some(Plugin::WebUI);
947 let result = build(options).unwrap();
948
949 let template = result
950 .component_templates
951 .iter()
952 .find(|template| template.tag_name == "demo-card")
953 .unwrap();
954 assert!(template.template_json.contains(r#""eg":[["click""#));
955 }
956
957 #[test]
958 fn test_build_populates_component_hydration_keys_from_script() {
959 let app = create_app_dir(&[
960 ("index.html", "<demo-card></demo-card>"),
961 ("demo-card.html", "<p>{{name}}</p>"),
962 (
963 "demo-card.ts",
964 "import { WebUIElement, attr, observable } from '@microsoft/webui-framework';\n\
965 class DemoCard extends WebUIElement {\n\
966 @observable name = '';\n\
967 @attr({ attribute: 'cta-href' }) ctaHref = '/x';\n\
968 @observable count = 0;\n\
969 }\n\
970 DemoCard.define('demo-card');",
971 ),
972 ]);
973 let manifest_json = projection::test_support::build_valid_manifest_json(
977 app.path(),
978 &[("demo-card.ts", "source")],
979 &[("demo-card.js", "output")],
980 &[(
981 "demo-card",
982 "demo-card.ts",
983 &["demo-card.js"],
984 &["count", "ctaHref", "name"],
985 &["count", "ctaHref", "name"],
986 )],
987 );
988 let manifest = projection::test_support::write_manifest(
989 app.path(),
990 "webui-projection.json",
991 &manifest_json,
992 );
993 let mut options = default_options(app.path());
994 options.plugin = Some(Plugin::WebUI);
995 options.projection_manifests = vec![manifest.into()];
996 let result = build(options).unwrap();
997
998 assert_eq!(
999 result.protocol.initial_state_strategy,
1000 webui_protocol::InitialStateStrategy::Components as i32
1001 );
1002 let component = result.protocol.components.get("demo-card").unwrap();
1003 assert_eq!(
1004 component.hydration_mode,
1005 webui_protocol::StateProjectionMode::Keys as i32
1006 );
1007 assert_eq!(component.hydration_keys, vec!["count", "ctaHref", "name"]);
1008 assert_eq!(
1009 component.navigation_mode,
1010 webui_protocol::StateProjectionMode::Keys as i32
1011 );
1012 assert_eq!(component.navigation_keys, vec!["count", "ctaHref", "name"]);
1013 }
1014
1015 #[test]
1016 fn test_build_scripted_component_without_manifest_falls_back_to_full_state() {
1017 let app = create_app_dir(&[
1018 (
1019 "index.html",
1020 "<html><body><demo-card></demo-card></body></html>",
1021 ),
1022 ("demo-card.html", "<p>{{name}}</p>"),
1023 (
1024 "demo-card.ts",
1025 "import { WebUIElement, observable } from '@microsoft/webui-framework';\n\
1026 class DemoCard extends WebUIElement {\n\
1027 @observable name = '';\n\
1028 }\n\
1029 DemoCard.define('demo-card');",
1030 ),
1031 ]);
1032 let mut options = default_options(app.path());
1033 options.plugin = Some(Plugin::WebUI);
1034 let result = build(options).unwrap();
1037
1038 assert_eq!(
1039 result.protocol.initial_state_strategy,
1040 webui_protocol::InitialStateStrategy::Full as i32
1041 );
1042 let component = result.protocol.components.get("demo-card").unwrap();
1043 assert_eq!(
1044 component.hydration_mode,
1045 webui_protocol::StateProjectionMode::All as i32
1046 );
1047 assert!(component.hydration_keys.is_empty());
1048 assert_eq!(
1049 component.navigation_mode,
1050 webui_protocol::StateProjectionMode::All as i32
1051 );
1052
1053 let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
1054 let state = serde_json::json!({ "name": "Visible" });
1055 let protocol = Protocol::new(result.protocol.clone());
1056 let mut writer = StringWriter { buf: String::new() };
1057 handler
1058 .render(
1059 &protocol,
1060 &state,
1061 &RenderOptions::new("index.html", "/"),
1062 &mut writer,
1063 )
1064 .unwrap();
1065 assert!(writer.buf.contains("Visible"));
1066 assert!(writer.buf.contains(r#""state":{"name":"Visible"}"#));
1069 }
1070
1071 #[test]
1072 fn test_build_rejects_scriptless_event_component() {
1073 let app = create_app_dir(&[
1074 ("index.html", "<demo-card></demo-card>"),
1075 (
1076 "demo-card.html",
1077 r#"<button @click="{onClick()}">Click</button>"#,
1078 ),
1079 ]);
1080 let mut options = default_options(app.path());
1081 options.plugin = Some(Plugin::WebUI);
1082 let err = build(options).expect_err("scriptless events should fail");
1083
1084 let message = err.chain_message();
1085 assert!(
1086 message.contains("scriptless-event-handler"),
1087 "msg: {message}"
1088 );
1089 }
1090
1091 #[test]
1092 fn test_build_returns_actionable_error_for_invalid_w_ref() {
1093 let app = create_app_dir(&[
1094 ("index.html", "<my-card>Hello</my-card>"),
1095 ("my-card.html", r#"<div><input w-ref="myInput" /></div>"#),
1096 ]);
1097 let mut options = default_options(app.path());
1098 options.plugin = Some(Plugin::WebUI);
1099
1100 let Err(err) = build(options) else {
1103 panic!("non-braced w-ref must fail the build");
1104 };
1105 let msg = err.chain_message();
1106 assert!(msg.contains("invalid w-ref binding"), "msg: {msg}");
1107 assert!(
1108 msg.contains("component <my-card> · element <input>"),
1109 "msg: {msg}"
1110 );
1111 assert!(msg.contains("help:"), "msg: {msg}");
1112 }
1113
1114 #[test]
1115 fn test_build_returns_actionable_error_for_invalid_event_handler() {
1116 let app = create_app_dir(&[
1117 ("index.html", "<my-btn>x</my-btn>"),
1118 (
1119 "my-btn.html",
1120 r#"<div><button @click="e.preventDefault()">x</button></div>"#,
1121 ),
1122 ]);
1123 let mut options = default_options(app.path());
1124 options.plugin = Some(Plugin::WebUI);
1125
1126 let Err(err) = build(options) else {
1127 panic!("invalid @event handler must fail the build");
1128 };
1129 let msg = err.chain_message();
1130 assert!(msg.contains("invalid @click handler"), "msg: {msg}");
1131 assert!(
1132 msg.contains("component <my-btn> · element <button>"),
1133 "msg: {msg}"
1134 );
1135 }
1136
1137 #[test]
1138 fn test_error_chain_message_has_no_duplicate_source() {
1139 let err = WebUIError::Parse {
1140 context: "Failed to parse index.html".to_string(),
1141 source: webui_parser::ParserError::Directive("Invalid for each: x".to_string()),
1142 };
1143 assert_eq!(err.to_string(), "Failed to parse index.html");
1146 assert_eq!(
1148 err.chain_message(),
1149 "Failed to parse index.html: Directive error: Invalid for each: x"
1150 );
1151 }
1152
1153 #[test]
1154 fn test_build_with_directives() {
1155 let html = r#"<h1>Hello</h1>
1156<for each="item in items">
1157 <p>{{item.name}}</p>
1158</for>
1159<if condition="show">
1160 <p>Visible</p>
1161</if>"#;
1162 let app = create_app_dir(&[("index.html", html)]);
1163 let result = build(default_options(app.path())).unwrap();
1164
1165 let index = &result.protocol.fragments["index.html"].fragments;
1166 assert!(index
1167 .iter()
1168 .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(_)))));
1169 assert!(index
1170 .iter()
1171 .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
1172 }
1173
1174 #[test]
1175 fn test_build_with_component_css() {
1176 let app = create_app_dir(&[
1177 ("index.html", "<my-card>Hello</my-card>"),
1178 ("my-card.html", "<div><slot></slot></div>"),
1179 ("my-card.css", ".card { color: red; }"),
1180 ("my-card.ts", "export {};"),
1181 ]);
1182 let result = build(default_options(app.path())).unwrap();
1183
1184 assert_eq!(result.css_files.len(), 1);
1185 assert_eq!(result.css_files[0].0, "my-card.css");
1186 assert!(result.css_files[0].1.contains("color: red"));
1187 assert_eq!(result.stats.css_file_count, 1);
1188 }
1189
1190 #[test]
1191 fn test_build_strips_non_legal_css_comments_from_output_file() {
1192 let app = create_app_dir(&[
1193 ("index.html", "<my-card>Hello</my-card>"),
1194 ("my-card.html", "<div><slot></slot></div>"),
1195 (
1196 "my-card.css",
1197 "/* remove var(--ignored) */ .card { color: var(--textColor); }",
1198 ),
1199 ]);
1200 let result = build(default_options(app.path())).unwrap();
1201
1202 assert_eq!(result.css_files[0].1, " .card { color: var(--textColor); }");
1203 assert_eq!(result.protocol.tokens, vec!["textColor"]);
1204 }
1205
1206 #[test]
1207 fn test_build_preserves_legal_css_comments_by_default() {
1208 let app = create_app_dir(&[
1209 ("index.html", "<my-card>Hello</my-card>"),
1210 ("my-card.html", "<div><slot></slot></div>"),
1211 (
1212 "my-card.css",
1213 "/*! @license MIT */ .card { color: red; } /* remove */",
1214 ),
1215 ]);
1216 let result = build(default_options(app.path())).unwrap();
1217
1218 assert_eq!(
1219 result.css_files[0].1,
1220 "/*! @license MIT */ .card { color: red; } "
1221 );
1222 }
1223
1224 #[test]
1225 fn test_build_legal_comments_none_strips_legal_css_comments() {
1226 let app = create_app_dir(&[
1227 ("index.html", "<my-card>Hello</my-card>"),
1228 ("my-card.html", "<div><slot></slot></div>"),
1229 ("my-card.css", "/*! @license MIT */ .card { color: red; }"),
1230 ]);
1231 let mut options = default_options(app.path());
1232 options.legal_comments = LegalComments::None;
1233 let result = build(options).unwrap();
1234
1235 assert_eq!(result.css_files[0].1, " .card { color: red; }");
1236 }
1237
1238 #[test]
1239 fn test_build_with_css_hashed_filename_template() {
1240 let app = create_app_dir(&[
1241 ("index.html", "<my-card>Hello</my-card>"),
1242 ("my-card.html", "<div><slot></slot></div>"),
1243 ("my-card.css", ".card { color: red; }"),
1244 ]);
1245 let mut options = default_options(app.path());
1246 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
1247 let result = build(options).unwrap();
1248
1249 assert_eq!(result.css_files.len(), 1);
1250 let generated = &result.css_files[0].0;
1251 assert!(generated.starts_with("my-card-"));
1252 assert!(generated.ends_with(".css"));
1253 assert_eq!(generated.len(), "my-card-".len() + 8 + ".css".len());
1254 assert_eq!(
1255 result
1256 .protocol
1257 .components
1258 .get("my-card")
1259 .map(|c| c.css_href.as_str()),
1260 Some(generated.as_str())
1261 );
1262 }
1263
1264 #[test]
1265 fn test_css_public_base_prefixes_css_href_only() {
1266 let app = create_app_dir(&[
1267 ("index.html", "<my-card>Hello</my-card>"),
1268 ("my-card.html", "<div><slot></slot></div>"),
1269 ("my-card.css", ".card { color: red; }"),
1270 ]);
1271 let mut options = default_options(app.path());
1272 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
1273 options.css_public_base = Some("https://cdn.example.com/assets".to_string());
1274 let result = build(options).unwrap();
1275
1276 let filename = &result.css_files[0].0;
1277 assert!(filename.starts_with("my-card-"));
1278 let expected_href = format!("https://cdn.example.com/assets/{filename}");
1279 assert_eq!(
1280 result
1281 .protocol
1282 .components
1283 .get("my-card")
1284 .map(|c| c.css_href.as_str()),
1285 Some(expected_href.as_str())
1286 );
1287 }
1288
1289 #[test]
1290 fn test_css_public_base_emits_parser_and_handler_links() {
1291 let app = create_app_dir(&[
1292 (
1293 "index.html",
1294 "<html><head></head><body><my-card>Hello</my-card></body></html>",
1295 ),
1296 ("my-card.html", "<div><slot></slot></div>"),
1297 ("my-card.css", ".card { color: red; }"),
1298 ]);
1299 let mut options = default_options(app.path());
1300 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
1301 options.css_public_base = Some("https://cdn.example.com/assets".to_string());
1302 let result = build(options).unwrap();
1303
1304 let filename = &result.css_files[0].0;
1305 let expected_href = format!("https://cdn.example.com/assets/{filename}");
1306 let component_html = result.protocol.fragments["my-card"]
1307 .fragments
1308 .iter()
1309 .filter_map(|fragment| match fragment.fragment.as_ref() {
1310 Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
1311 _ => None,
1312 })
1313 .collect::<String>();
1314 assert!(
1315 component_html.contains(&format!(
1316 r#"<link rel="stylesheet" href="{expected_href}">"#
1317 )),
1318 "parser-generated component template should use CDN href: {component_html}"
1319 );
1320
1321 let handler = WebUIHandler::new();
1322 let protocol = Protocol::new(result.protocol.clone());
1323 let mut writer = StringWriter { buf: String::new() };
1324 handler
1325 .render(
1326 &protocol,
1327 &serde_json::json!({}),
1328 &RenderOptions::new("index.html", "/"),
1329 &mut writer,
1330 )
1331 .unwrap();
1332 assert!(
1333 writer.buf.contains(&format!(
1334 r#"<link rel="preload" href="{expected_href}" as="style" data-webui-ssr-preload="style">"#
1335 )),
1336 "handler head preload should use CDN href: {}",
1337 writer.buf
1338 );
1339 }
1340
1341 #[test]
1342 fn test_css_public_base_emits_webui_plugin_template_links() {
1343 let app = create_app_dir(&[
1344 ("index.html", "<my-card>Hello</my-card>"),
1345 ("my-card.html", "<div><slot></slot></div>"),
1346 ("my-card.css", ".card { color: red; }"),
1347 ("my-card.ts", "export {};"),
1348 ]);
1349 let mut options = default_options(app.path());
1350 options.plugin = Some(Plugin::WebUI);
1351 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
1352 options.css_public_base = Some("https://cdn.example.com/assets".to_string());
1353 let result = build(options).unwrap();
1354
1355 let filename = &result.css_files[0].0;
1356 let expected_href = format!("https://cdn.example.com/assets/{filename}");
1357 let template = &result.protocol.components["my-card"].template_json;
1358 assert!(
1359 template.contains(&format!(r#"href=\"{expected_href}\""#)),
1360 "plugin component template should use CDN href: {template}"
1361 );
1362 }
1363
1364 #[test]
1365 fn test_css_public_base_emits_fast_plugin_template_links() {
1366 let app = create_app_dir(&[
1367 ("index.html", "<my-card>Hello</my-card>"),
1368 ("my-card.html", "<div><slot></slot></div>"),
1369 ("my-card.css", ".card { color: red; }"),
1370 ]);
1371 let mut options = default_options(app.path());
1372 options.plugin = Some(Plugin::FastV3);
1373 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
1374 options.css_public_base = Some("https://cdn.example.com/assets".to_string());
1375 let result = build(options).unwrap();
1376
1377 let filename = &result.css_files[0].0;
1378 let expected_href = format!("https://cdn.example.com/assets/{filename}");
1379 let template = &result.protocol.components["my-card"].template;
1380 assert!(
1381 template.contains(&format!(r#"href="{expected_href}""#)),
1382 "FAST component template should use CDN href: {template}"
1383 );
1384 }
1385
1386 #[test]
1387 fn test_invalid_css_template_is_rejected() {
1388 let app = create_app_dir(&[
1389 ("index.html", "<my-card>Hello</my-card>"),
1390 ("my-card.html", "<div><slot></slot></div>"),
1391 ("my-card.css", ".card { color: red; }"),
1392 ]);
1393 let mut options = default_options(app.path());
1394 options.css_file_name_template = "[name]-[bogus].[ext]".to_string();
1395 let result = build(options);
1396
1397 assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
1398 }
1399
1400 #[test]
1401 fn test_css_filename_collision_is_rejected() {
1402 let app = create_app_dir(&[
1403 ("index.html", "<card-a>A</card-a><card-b>B</card-b>"),
1404 ("card-a.html", "<div><slot></slot></div>"),
1405 ("card-a.css", ".x { color: red; }"),
1406 ("card-b.html", "<div><slot></slot></div>"),
1407 ("card-b.css", ".x { color: red; }"),
1408 ]);
1409 let mut options = default_options(app.path());
1410 options.css_file_name_template = "[hash].[ext]".to_string();
1411 let result = build(options);
1412
1413 assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
1414 }
1415
1416 #[test]
1417 fn test_build_returns_static_component_asset_files() {
1418 let app = create_app_dir(&[
1419 ("index.html", "<app-shell></app-shell>"),
1420 ("app-shell.html", "<div></div>"),
1421 (
1422 "lazy-panel.html",
1423 r#"<if condition="ready"><p>{{title}}</p></if>"#,
1424 ),
1425 ("lazy-panel.ts", "export {};"),
1426 ]);
1427 let mut options = default_options(app.path());
1428 options.plugin = Some(Plugin::WebUI);
1429 options.component_asset_roots = vec!["lazy-panel".to_string()];
1430
1431 let result = build(options).unwrap();
1432
1433 assert_eq!(result.component_asset_files.len(), 1);
1434 assert_eq!(result.component_asset_files[0].name, "lazy-panel.webui.js");
1435 assert!(result.component_asset_files[0]
1436 .content
1437 .contains(r#""type":"webui-component-asset""#));
1438 assert!(result.component_asset_files[0]
1439 .content
1440 .contains(r#""templateFunctions":{"lazy-panel":"#));
1441 assert!(result.protocol.fragments.contains_key("lazy-panel"));
1442 assert!(
1443 !result
1444 .protocol
1445 .fragments
1446 .keys()
1447 .any(|key| key.starts_with("__webui_asset_root_")),
1448 "synthetic asset root fragments must not be serialized"
1449 );
1450 }
1451
1452 #[test]
1453 fn test_component_asset_filename_collision_with_css_is_rejected() {
1454 let app = create_app_dir(&[
1455 ("index.html", "<app-shell></app-shell>"),
1456 ("app-shell.html", "<div></div>"),
1457 ("lazy-panel.html", "<p>Lazy</p>"),
1458 ("lazy-panel.css", ".panel { color: red; }"),
1459 ]);
1460 let mut options = default_options(app.path());
1461 options.plugin = Some(Plugin::WebUI);
1462 options.component_asset_roots = vec!["lazy-panel".to_string()];
1463 options.css_file_name_template = "[name]".to_string();
1464
1465 let result = build(options);
1466
1467 assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
1468 }
1469
1470 #[test]
1471 fn test_build_to_disk_rejects_protocol_name_collision_before_writing() {
1472 let app = create_app_dir(&[
1473 ("index.html", "<app-shell></app-shell>"),
1474 ("app-shell.html", "<div></div>"),
1475 ("lazy-panel.html", "<p>Lazy</p>"),
1476 ]);
1477 let out = TempDir::new().unwrap();
1478 let mut options = default_options(app.path());
1479 options.plugin = Some(Plugin::WebUI);
1480 options.component_asset_roots = vec!["lazy-panel".to_string()];
1481 options.css_file_name_template = "protocol.bin".to_string();
1482
1483 let result = build_to_disk(options, out.path());
1484
1485 assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
1486 assert!(!out.path().join("protocol.bin").exists());
1487 }
1488
1489 #[test]
1490 fn test_css_href_set_for_light_dom_link_strategy() {
1491 let app = create_app_dir(&[
1492 ("index.html", "<has-css>A</has-css><no-css>B</no-css>"),
1493 ("has-css.html", "<p><slot></slot></p>"),
1494 ("has-css.css", ".yes { color: green; }"),
1495 ("no-css.html", "<p><slot></slot></p>"),
1496 ]);
1497 let mut options = default_options(app.path());
1498 options.dom = DomStrategy::Light;
1499 let result = build(options).unwrap();
1500
1501 let href = result
1502 .protocol
1503 .components
1504 .get("has-css")
1505 .map(|c| c.css_href.as_str())
1506 .unwrap_or("");
1507 assert_eq!(
1508 href, "has-css.css",
1509 "Light×Link component with CSS should have css_href"
1510 );
1511
1512 let no_href = result
1513 .protocol
1514 .components
1515 .get("no-css")
1516 .map(|c| c.css_href.as_str())
1517 .unwrap_or("");
1518 assert!(
1519 no_href.is_empty(),
1520 "Component without CSS should have empty css_href"
1521 );
1522 }
1523
1524 #[test]
1525 fn test_shadow_dom_link_strategy_sets_css_href() {
1526 let app = create_app_dir(&[
1530 ("index.html", "<my-card>A</my-card>"),
1531 ("my-card.html", "<p><slot></slot></p>"),
1532 ("my-card.css", ".card { color: red; }"),
1533 ]);
1534 let result = build(default_options(app.path())).unwrap();
1535
1536 let comp = result.protocol.components.get("my-card").unwrap();
1537 assert_eq!(
1538 comp.css_href, "my-card.css",
1539 "Shadow×Link should set css_href"
1540 );
1541
1542 assert_eq!(
1544 result.protocol.css_strategy(),
1545 webui_protocol::CssStrategy::Link,
1546 );
1547 assert_eq!(
1548 result.protocol.dom_strategy(),
1549 webui_protocol::DomStrategy::Shadow,
1550 );
1551
1552 assert_eq!(
1554 result.css_files.len(),
1555 1,
1556 "CSS file should still be produced"
1557 );
1558 }
1559
1560 #[test]
1561 fn test_css_href_empty_for_style_strategy() {
1562 let app = create_app_dir(&[
1563 ("index.html", "<my-card>A</my-card>"),
1564 ("my-card.html", "<p><slot></slot></p>"),
1565 ("my-card.css", ".card { color: red; }"),
1566 ]);
1567 let mut options = default_options(app.path());
1568 options.css = CssStrategy::Style;
1569 let result = build(options).unwrap();
1570
1571 let href = result
1572 .protocol
1573 .components
1574 .get("my-card")
1575 .map(|c| c.css_href.as_str())
1576 .unwrap_or("");
1577 assert!(href.is_empty(), "Style-strategy should not set css_href");
1578 }
1579
1580 #[test]
1581 fn test_css_href_empty_for_module_strategy() {
1582 let app = create_app_dir(&[
1583 ("index.html", "<my-card>A</my-card>"),
1584 ("my-card.html", "<p><slot></slot></p>"),
1585 ("my-card.css", ".card { color: red; }"),
1586 ]);
1587 let mut options = default_options(app.path());
1588 options.css = CssStrategy::Module;
1589 let result = build(options).unwrap();
1590
1591 let href = result
1592 .protocol
1593 .components
1594 .get("my-card")
1595 .map(|c| c.css_href.as_str())
1596 .unwrap_or("");
1597 assert!(href.is_empty(), "Module-strategy should not set css_href");
1598 assert!(
1599 !result.protocol.components["my-card"].css.is_empty(),
1600 "Module-strategy should still populate css content"
1601 );
1602 }
1603
1604 #[test]
1605 fn test_build_to_disk_writes_files() {
1606 let app = create_app_dir(&[
1607 ("index.html", "<my-card>Hello</my-card>"),
1608 ("my-card.html", "<div><slot></slot></div>"),
1609 ("my-card.css", ".card { color: red; }"),
1610 ("my-card.ts", "export {};"),
1611 ]);
1612 let out = TempDir::new().unwrap();
1613
1614 let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
1615
1616 assert!(out.path().join("protocol.bin").exists());
1617 assert!(out.path().join("my-card.css").exists());
1618 assert_eq!(stats.css_file_count, 1);
1619 assert!(stats.fragment_count > 0);
1620 }
1621
1622 #[test]
1623 fn test_build_to_disk_creates_nested_output_dir() {
1624 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
1625 let out = TempDir::new().unwrap();
1626 let nested = out.path().join("nested").join("output");
1627
1628 let stats = build_to_disk(default_options(app.path()), &nested).unwrap();
1629 assert!(nested.join("protocol.bin").exists());
1630 assert!(stats.fragment_count > 0);
1631 }
1632
1633 #[test]
1634 fn test_build_missing_entry() {
1635 let app = create_app_dir(&[("other.html", "<h1>Not index</h1>")]);
1636 let result = build(default_options(app.path()));
1637
1638 assert!(result.is_err());
1639 let err = result.unwrap_err();
1640 assert!(matches!(err, WebUIError::Io { .. }));
1641 }
1642
1643 #[test]
1644 fn test_build_stats_populated() {
1645 let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
1646 let result = build(default_options(app.path())).unwrap();
1647
1648 assert!(result.stats.fragment_count > 0);
1649 assert!(result.stats.protocol_size_bytes > 0);
1650 assert_eq!(result.stats.css_file_count, 0);
1651 }
1652
1653 #[test]
1654 fn test_build_inline_css() {
1655 let app = create_app_dir(&[
1656 ("index.html", "<my-card>Hello</my-card>"),
1657 ("my-card.html", "<div><slot></slot></div>"),
1658 ("my-card.css", ".card { color: red; }"),
1659 ]);
1660 let mut options = default_options(app.path());
1661 options.css = CssStrategy::Style;
1662
1663 let result = build(options).unwrap();
1664 assert!(result.css_files.is_empty());
1666 assert_eq!(result.stats.css_file_count, 0);
1667 assert!(result.stats.fragment_count > 0);
1668 }
1669
1670 #[test]
1671 fn test_build_module_css() {
1672 let app = create_app_dir(&[
1673 ("index.html", "<my-card>Hello</my-card>"),
1674 ("my-card.html", "<div><slot></slot></div>"),
1675 ("my-card.css", ".card { color: red; }"),
1676 ]);
1677 let mut options = default_options(app.path());
1678 options.css = CssStrategy::Module;
1679
1680 let result = build(options).unwrap();
1681 assert!(result.css_files.is_empty());
1683 assert_eq!(result.stats.css_file_count, 0);
1684 assert!(result.stats.fragment_count > 0);
1685 }
1686
1687 #[test]
1688 fn test_build_custom_entry() {
1689 let app = create_app_dir(&[("page.html", "<h1>Custom</h1>")]);
1690 let mut options = default_options(app.path());
1691 options.entry = "page.html".to_string();
1692
1693 let result = build(options).unwrap();
1694 assert!(result.protocol.fragments.contains_key("page.html"));
1695 assert!(!result.protocol.fragments.contains_key("index.html"));
1696 }
1697
1698 #[test]
1699 fn test_build_protocol_roundtrip() {
1700 let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
1701 let result = build(default_options(app.path())).unwrap();
1702
1703 let restored = WebUIProtocol::from_protobuf(&result.protocol_bytes).unwrap();
1704 assert!(restored.fragments.contains_key("index.html"));
1705 }
1706
1707 #[test]
1708 fn protocol_is_available_from_facade() {
1709 let protocol = Protocol::new(WebUIProtocol::new(HashMap::new()));
1710 assert!(protocol.tokens().is_empty());
1711 }
1712
1713 #[test]
1714 fn test_inspect_bytes_valid() {
1715 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
1716 let result = build(default_options(app.path())).unwrap();
1717
1718 let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
1719 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1720 assert!(parsed.get("fragments").is_some());
1721 }
1722
1723 #[test]
1724 fn test_inspect_bytes_invalid() {
1725 let result = inspect_bytes(b"not a protobuf");
1726 assert!(result.is_err());
1727 assert!(matches!(result.unwrap_err(), WebUIError::Protocol(_)));
1728 }
1729
1730 #[test]
1731 fn test_inspect_file() {
1732 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
1733 let out = TempDir::new().unwrap();
1734 build_to_disk(default_options(app.path()), out.path()).unwrap();
1735
1736 let json_str = inspect(&out.path().join("protocol.bin")).unwrap();
1737 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1738 assert!(parsed["fragments"]["index.html"]["fragments"].is_array());
1739 }
1740
1741 #[test]
1742 fn test_inspect_missing_file() {
1743 let result = inspect(Path::new("/nonexistent/protocol.bin"));
1744 assert!(result.is_err());
1745 assert!(matches!(result.unwrap_err(), WebUIError::Io { .. }));
1746 }
1747
1748 #[test]
1749 fn test_build_with_components_local_path() {
1750 let app = create_app_dir(&[("index.html", "<ext-card>Hello</ext-card>")]);
1751 let ext_dir = TempDir::new().unwrap();
1752 fs::write(
1753 ext_dir.path().join("ext-card.html"),
1754 "<div class=\"card\"><slot></slot></div>",
1755 )
1756 .unwrap();
1757 fs::write(
1758 ext_dir.path().join("ext-card.css"),
1759 ".card { border: 1px solid #ccc; }",
1760 )
1761 .unwrap();
1762
1763 let mut options = default_options(app.path());
1764 options.components = vec![ext_dir.path().to_string_lossy().to_string()];
1765
1766 let result = build(options).unwrap();
1767 assert!(result.protocol.fragments.contains_key("index.html"));
1768 assert_eq!(result.css_files.len(), 1);
1769 assert!(result.css_files[0].1.contains("border"));
1770 }
1771
1772 #[test]
1773 fn test_build_with_scriptless_component_path_emits_dormant_template() {
1774 let app = create_app_dir(&[("index.html", "<ext-card></ext-card>")]);
1775 let ext_dir = TempDir::new().unwrap();
1776 fs::write(ext_dir.path().join("ext-card.html"), "<p>{{title}}</p>").unwrap();
1777
1778 let mut options = default_options(app.path());
1779 options.plugin = Some(Plugin::WebUI);
1780 options.components = vec![ext_dir.path().to_string_lossy().to_string()];
1781
1782 let result = build(options).unwrap();
1783 assert!(result
1784 .component_templates
1785 .iter()
1786 .any(|template| template.tag_name == "ext-card"));
1787 let component = result
1788 .protocol
1789 .components
1790 .get("ext-card")
1791 .expect("external scriptless component metadata should be retained");
1792 assert!(component.hydration_keys.is_empty());
1793 assert_eq!(component.navigation_keys, ["title"]);
1794 assert!(component.template_json.contains(r#""th":1"#));
1795 }
1796
1797 #[test]
1798 fn test_build_with_scripted_component_path_emits_client_artifact() {
1799 let app = create_app_dir(&[("index.html", "<ext-card></ext-card>")]);
1800 let ext_dir = TempDir::new().unwrap();
1801 fs::write(ext_dir.path().join("ext-card.html"), "<p>{{title}}</p>").unwrap();
1802 fs::write(ext_dir.path().join("ext-card.ts"), "export {};").unwrap();
1803
1804 let mut options = default_options(app.path());
1805 options.plugin = Some(Plugin::WebUI);
1806 options.components = vec![ext_dir.path().to_string_lossy().to_string()];
1807
1808 let result = build(options).unwrap();
1809 let template = &result.protocol.components["ext-card"].template_json;
1810 assert!(!template.is_empty(), "template should be emitted");
1811 }
1812
1813 #[test]
1814 fn test_build_rejects_projection_manifest_with_non_webui_plugin() {
1815 let app = create_app_dir(&[
1816 ("index.html", "<demo-card></demo-card>"),
1817 ("demo-card.html", "<p>{{name}}</p>"),
1818 ("demo-card.ts", "export {};"),
1819 ]);
1820 let manifest_json = projection::test_support::build_valid_manifest_json(
1821 app.path(),
1822 &[("demo-card.ts", "export {};")],
1823 &[("projection-bundle.js", "output")],
1824 &[(
1825 "demo-card",
1826 "demo-card.ts",
1827 &["projection-bundle.js"],
1828 &["name"],
1829 &["name"],
1830 )],
1831 );
1832 let manifest = projection::test_support::write_manifest(
1833 app.path(),
1834 "webui-projection.json",
1835 &manifest_json,
1836 );
1837 let mut options = default_options(app.path());
1838 options.projection_manifests = vec![manifest.into()];
1841 let err = build(options).expect_err("non-WebUI plugin + manifest should be rejected");
1842 assert!(
1843 err.chain_message().contains("PROJ-B002"),
1844 "msg: {}",
1845 err.chain_message()
1846 );
1847 }
1848
1849 #[test]
1850 fn test_build_rejects_missing_scripted_component_coverage() {
1851 let app = create_app_dir(&[
1852 ("index.html", "<demo-card></demo-card>"),
1853 ("demo-card.html", "<p>{{name}}</p>"),
1854 ("demo-card.ts", "export {};"),
1855 ]);
1856 let manifest_json =
1859 projection::test_support::build_valid_manifest_json(app.path(), &[], &[], &[]);
1860 let manifest = projection::test_support::write_manifest(
1861 app.path(),
1862 "webui-projection.json",
1863 &manifest_json,
1864 );
1865 let mut options = default_options(app.path());
1866 options.plugin = Some(Plugin::WebUI);
1867 options.projection_manifests = vec![manifest.into()];
1868 let err = build(options).expect_err("missing coverage should fail the build");
1869 let message = err.chain_message();
1870 assert!(message.contains("PROJ-B001"), "msg: {message}");
1871 assert!(message.contains("demo-card"), "msg: {message}");
1872 }
1873
1874 #[test]
1875 fn test_build_allows_unused_discovered_scripted_component_without_coverage() {
1876 let app = create_app_dir(&[
1877 ("index.html", "<demo-card></demo-card>"),
1878 ("demo-card.html", "<p>{{name}}</p>"),
1879 ("demo-card.ts", "export {};"),
1880 ("unused-card.html", "<p>{{title}}</p>"),
1884 ("unused-card.ts", "export {};"),
1885 ]);
1886 let manifest_json = projection::test_support::build_valid_manifest_json(
1887 app.path(),
1888 &[("demo-card.ts", "export {};")],
1889 &[("projection-bundle.js", "output")],
1890 &[(
1891 "demo-card",
1892 "demo-card.ts",
1893 &["projection-bundle.js"],
1894 &["name"],
1895 &["name"],
1896 )],
1897 );
1898 let manifest = projection::test_support::write_manifest(
1899 app.path(),
1900 "webui-projection.json",
1901 &manifest_json,
1902 );
1903 let mut options = default_options(app.path());
1904 options.plugin = Some(Plugin::WebUI);
1905 options.projection_manifests = vec![manifest.into()];
1906 let result = build(options).unwrap();
1907
1908 assert!(!result.protocol.fragments.contains_key("unused-card"));
1909 let component = result.protocol.components.get("demo-card").unwrap();
1910 assert_eq!(component.hydration_keys, vec!["name"]);
1911 }
1912
1913 #[test]
1914 fn test_build_merges_multiple_projection_manifest_fragments() {
1915 let app = create_app_dir(&[
1916 (
1917 "index.html",
1918 "<html><body><card-a></card-a><card-b></card-b></body></html>",
1919 ),
1920 ("card-a.html", "<p>{{alpha}}</p>"),
1921 ("card-a.ts", "export {};"),
1922 ("card-b.html", "<p>{{beta}}</p>"),
1923 ("card-b.ts", "export {};"),
1924 ]);
1925 let json_a = projection::test_support::build_valid_manifest_json(
1926 app.path(),
1927 &[("card-a.ts", "export {};")],
1928 &[("card-a.js", "output-a")],
1929 &[(
1930 "card-a",
1931 "card-a.ts",
1932 &["card-a.js"],
1933 &["alpha"],
1934 &["alpha"],
1935 )],
1936 );
1937 let manifest_a =
1938 projection::test_support::write_manifest(app.path(), "fragment-a.json", &json_a);
1939 let json_b = projection::test_support::build_valid_manifest_json(
1940 app.path(),
1941 &[("card-b.ts", "export {};")],
1942 &[("card-b.js", "output-b")],
1943 &[("card-b", "card-b.ts", &["card-b.js"], &["beta"], &["beta"])],
1944 );
1945 let manifest_b =
1946 projection::test_support::write_manifest(app.path(), "fragment-b.json", &json_b);
1947
1948 let mut options = default_options(app.path());
1949 options.plugin = Some(Plugin::WebUI);
1950 options.projection_manifests = vec![manifest_a.into(), manifest_b.into()];
1951 let result = build(options).unwrap();
1952
1953 assert_eq!(
1954 result.protocol.initial_state_strategy,
1955 webui_protocol::InitialStateStrategy::Components as i32
1956 );
1957 let card_a = result.protocol.components.get("card-a").unwrap();
1958 assert_eq!(card_a.hydration_keys, vec!["alpha"]);
1959 let card_b = result.protocol.components.get("card-b").unwrap();
1960 assert_eq!(card_b.hydration_keys, vec!["beta"]);
1961 }
1962
1963 #[test]
1964 fn test_build_hello_world_example() {
1965 let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1966 let app_dir = manifest_dir.join("../../examples/app/hello-world/src");
1967
1968 let result = build(BuildOptions {
1969 app_dir,
1970 entry: "index.html".to_string(),
1971 ..BuildOptions::default()
1972 })
1973 .unwrap();
1974
1975 let index = &result.protocol.fragments["index.html"].fragments;
1976 assert!(index.iter().any(
1977 |f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(fl)) if fl.collection == "people")
1978 ));
1979 assert!(index
1980 .iter()
1981 .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
1982 }
1983
1984 #[test]
1987 fn test_css_filename_sanitizes_path_separators() {
1988 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
1991 let result = build(default_options(app.path())).unwrap();
1992
1993 for (filename, _) in &result.css_files {
1995 assert!(
1996 !filename.contains('/') && !filename.contains('\\'),
1997 "CSS filename contains path separator: {filename}"
1998 );
1999 }
2000 }
2001
2002 #[test]
2003 fn test_build_to_disk_css_stays_in_output_dir() {
2004 let app = create_app_dir(&[
2005 ("index.html", "<my-card>Hello</my-card>"),
2006 ("my-card.html", "<div><slot></slot></div>"),
2007 ("my-card.css", ".card { color: red; }"),
2008 ]);
2009 let out = TempDir::new().unwrap();
2010
2011 build_to_disk(default_options(app.path()), out.path()).unwrap();
2012
2013 let css_path = out.path().join("my-card.css");
2015 assert!(css_path.exists());
2016 let canonical = css_path.canonicalize().unwrap();
2017 let out_canonical = out.path().canonicalize().unwrap();
2018 assert!(
2019 canonical.starts_with(&out_canonical),
2020 "CSS file escaped output directory"
2021 );
2022 }
2023
2024 #[test]
2027 fn test_build_empty_html() {
2028 let app = create_app_dir(&[("index.html", "")]);
2029 let result = build(default_options(app.path())).unwrap();
2030
2031 assert!(result.protocol.fragments.contains_key("index.html"));
2032 assert_eq!(result.stats.fragment_count, 0);
2033 assert!(result.stats.protocol_size_bytes > 0);
2034 }
2035
2036 #[test]
2037 fn test_build_large_fragment_count() {
2038 let mut html = String::with_capacity(2000);
2040 for i in 0..50 {
2041 html.push_str(&format!("<p>Item {i}</p>\n"));
2042 }
2043 let app = create_app_dir(&[("index.html", &html)]);
2044 let result = build(default_options(app.path())).unwrap();
2045
2046 assert!(result.stats.fragment_count > 0);
2047 assert_eq!(result.stats.css_file_count, 0);
2048 }
2049
2050 #[test]
2051 fn test_build_with_fast_plugin() {
2052 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
2053 let mut options = default_options(app.path());
2054 options.plugin = Some(Plugin::FastV3);
2055
2056 let result = build(options).unwrap();
2057 assert!(result.protocol.fragments.contains_key("index.html"));
2058 assert_eq!(
2059 result.protocol.initial_state_strategy,
2060 webui_protocol::InitialStateStrategy::Full as i32
2061 );
2062 }
2063
2064 #[test]
2065 fn test_build_to_disk_inline_mode_no_css_files() {
2066 let app = create_app_dir(&[
2067 ("index.html", "<my-card>Hello</my-card>"),
2068 ("my-card.html", "<div><slot></slot></div>"),
2069 ("my-card.css", ".card { color: red; }"),
2070 ]);
2071 let out = TempDir::new().unwrap();
2072 let mut options = default_options(app.path());
2073 options.css = CssStrategy::Style;
2074
2075 let stats = build_to_disk(options, out.path()).unwrap();
2076
2077 assert!(out.path().join("protocol.bin").exists());
2078 assert!(!out.path().join("my-card.css").exists());
2079 assert_eq!(stats.css_file_count, 0);
2080 }
2081
2082 #[test]
2083 fn test_build_stats_duration_is_nonzero() {
2084 let app = create_app_dir(&[("index.html", "<h1>Hello {{name}}</h1>")]);
2085 let result = build(default_options(app.path())).unwrap();
2086
2087 assert!(!result.stats.duration.is_zero());
2088 }
2089
2090 #[test]
2091 fn test_build_multiple_components_css() {
2092 let app = create_app_dir(&[
2093 ("index.html", "<card-a>A</card-a><card-b>B</card-b>"),
2094 ("card-a.html", "<div><slot></slot></div>"),
2095 ("card-a.css", ".a { color: red; }"),
2096 ("card-b.html", "<span><slot></slot></span>"),
2097 ("card-b.css", ".b { color: blue; }"),
2098 ]);
2099 let result = build(default_options(app.path())).unwrap();
2100
2101 assert_eq!(result.css_files.len(), 2);
2102 assert_eq!(result.stats.css_file_count, 2);
2103 let filenames: Vec<&str> = result.css_files.iter().map(|(n, _)| n.as_str()).collect();
2104 assert!(filenames.contains(&"card-a.css"));
2105 assert!(filenames.contains(&"card-b.css"));
2106 }
2107
2108 #[test]
2109 fn test_build_unused_component_css_not_emitted() {
2110 let app = create_app_dir(&[
2112 ("index.html", "<card-a>A</card-a>"),
2113 ("card-a.html", "<div><slot></slot></div>"),
2114 ("card-a.css", ".a { color: red; }"),
2115 ("card-b.html", "<span><slot></slot></span>"),
2116 ("card-b.css", ".b { color: blue; }"),
2117 ]);
2118 let result = build(default_options(app.path())).unwrap();
2119
2120 assert_eq!(result.css_files.len(), 1);
2121 assert_eq!(result.css_files[0].0, "card-a.css");
2122 }
2123
2124 #[test]
2125 fn test_inspect_roundtrip_preserves_content() {
2126 let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
2127 let result = build(default_options(app.path())).unwrap();
2128
2129 let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
2130 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
2131
2132 let fragments = &parsed["fragments"]["index.html"]["fragments"];
2133 assert!(fragments.is_array());
2134 assert!(!fragments.as_array().unwrap().is_empty());
2135 }
2136
2137 #[test]
2138 fn test_build_nonexistent_app_dir() {
2139 let options = BuildOptions {
2140 app_dir: PathBuf::from("/nonexistent/path/that/does/not/exist"),
2141 ..BuildOptions::default()
2142 };
2143 let result = build(options);
2144 assert!(result.is_err());
2145 }
2146
2147 #[test]
2148 fn test_build_allows_fallback_chain_when_all_theme_tokens_exist() {
2149 let app = create_app_dir(&[
2150 ("index.html", "<my-card></my-card>"),
2151 ("my-card.html", "<div>Card</div>"),
2152 (
2153 "my-card.css",
2154 ":host { color: var(--token-a, var(--token-b, var(--token-c))); }",
2155 ),
2156 ]);
2157 let mut options = default_options(app.path());
2158 options.theme = Some(TokenFile {
2159 themes: HashMap::from([(
2160 "light".to_string(),
2161 HashMap::from([
2162 ("token-a".to_string(), "red".to_string()),
2163 ("token-b".to_string(), "blue".to_string()),
2164 ("token-c".to_string(), "green".to_string()),
2165 ]),
2166 )]),
2167 });
2168
2169 let result = build(options).expect("all fallback tokens are defined");
2170 assert!(result.protocol.tokens.contains(&"token-a".to_string()));
2171 assert!(result.protocol.tokens.contains(&"token-b".to_string()));
2172 assert!(result.protocol.tokens.contains(&"token-c".to_string()));
2173 }
2174
2175 #[test]
2176 fn test_build_allows_literal_fallback_token_absent_from_theme() {
2177 let app = create_app_dir(&[
2178 ("index.html", "<my-card></my-card>"),
2179 ("my-card.html", "<div>Card</div>"),
2180 ("my-card.css", ":host { color: var(--brand, #000); }"),
2181 ]);
2182 let mut options = default_options(app.path());
2183 options.theme = Some(TokenFile {
2184 themes: HashMap::from([("light".to_string(), HashMap::new())]),
2185 });
2186
2187 let result = build(options).expect("literal fallback should not fail the build");
2191 assert!(result.protocol.tokens.contains(&"brand".to_string()));
2192 assert_eq!(result.warnings.len(), 1, "warnings: {:?}", result.warnings);
2195 assert!(result.warnings[0].body().contains("--brand"));
2196 }
2197
2198 #[test]
2199 fn test_build_ancestor_inline_style_definition_after_comment_satisfies_descendant() {
2200 let app = create_app_dir(&[
2205 (
2206 "index.html",
2207 "<html><head><style>:root {\n /*{{{tokens.light}}}*/\n --foo-bar: 100px;\n}</style></head><body><my-card></my-card></body></html>",
2208 ),
2209 ("my-card.html", "<div>Card</div>"),
2210 ("my-card.css", ":host { padding: var(--foo-bar); }"),
2211 ]);
2212 let mut options = default_options(app.path());
2213 options.theme = Some(TokenFile {
2214 themes: HashMap::from([("light".to_string(), HashMap::new())]),
2215 });
2216
2217 let result = build(options)
2218 .expect("ancestor inline-style definition (after a comment) should satisfy the child");
2219 assert!(
2220 !result.protocol.tokens.contains(&"foo-bar".to_string()),
2221 "ancestor-defined token should not be hoisted: {:?}",
2222 result.protocol.tokens
2223 );
2224 }
2225
2226 #[test]
2227 fn test_build_no_warning_when_literal_fallback_token_is_themed() {
2228 let app = create_app_dir(&[
2229 ("index.html", "<my-card></my-card>"),
2230 ("my-card.html", "<div>Card</div>"),
2231 ("my-card.css", ":host { color: var(--brand, #000); }"),
2232 ]);
2233 let mut options = default_options(app.path());
2234 options.theme = Some(TokenFile {
2235 themes: HashMap::from([(
2236 "light".to_string(),
2237 HashMap::from([("brand".to_string(), "#123456".to_string())]),
2238 )]),
2239 });
2240
2241 let result = build(options).expect("themed literal-fallback token builds");
2242 assert!(
2243 result.warnings.is_empty(),
2244 "a themed token must not warn: {:?}",
2245 result.warnings
2246 );
2247 }
2248
2249 #[test]
2250 fn test_build_without_theme_produces_no_warnings() {
2251 let app = create_app_dir(&[
2252 ("index.html", "<my-card></my-card>"),
2253 ("my-card.html", "<div>Card</div>"),
2254 ("my-card.css", ":host { color: var(--brand, #000); }"),
2255 ]);
2256 let result = build(default_options(app.path())).unwrap();
2257 assert!(result.warnings.is_empty());
2258 }
2259
2260 #[test]
2261 fn test_build_excludes_tokens_defined_by_ancestor_component_css() {
2262 let app = create_app_dir(&[
2263 ("index.html", "<component-a></component-a>"),
2264 ("component-a.html", "<component-b></component-b>"),
2265 ("component-a.css", ":host { --brand-color: red; }"),
2266 ("component-b.html", "<div>Child</div>"),
2267 ("component-b.css", ":host { color: var(--brand-color); }"),
2268 ]);
2269 let mut options = default_options(app.path());
2270 options.theme = Some(TokenFile {
2271 themes: HashMap::from([("light".to_string(), HashMap::new())]),
2272 });
2273
2274 let result =
2275 build(options).expect("ancestor component custom property should satisfy child");
2276 assert!(
2277 !result.protocol.tokens.contains(&"brand-color".to_string()),
2278 "ancestor-defined token should not be exposed: {:?}",
2279 result.protocol.tokens
2280 );
2281 }
2282
2283 #[test]
2284 fn test_build_does_not_use_sibling_component_definitions_for_theme_validation() {
2285 let app = create_app_dir(&[
2286 (
2287 "index.html",
2288 "<component-a></component-a><component-b></component-b>",
2289 ),
2290 ("component-a.html", "<div>A</div>"),
2291 ("component-a.css", ":host { --brand-color: red; }"),
2292 ("component-b.html", "<div>B</div>"),
2293 ("component-b.css", ":host { color: var(--brand-color); }"),
2294 ]);
2295 let mut options = default_options(app.path());
2296 options.theme = Some(TokenFile {
2297 themes: HashMap::from([("light".to_string(), HashMap::new())]),
2298 });
2299
2300 let Err(err) = build(options) else {
2301 panic!("sibling custom property must not satisfy component-b");
2302 };
2303 let message = err.chain_message();
2304 assert!(message.contains("missing theme token"), "msg: {message}");
2305 assert!(message.contains("missing-theme-token"), "msg: {message}");
2306 assert!(message.contains("--brand-color"), "msg: {message}");
2307 }
2308
2309 #[test]
2310 fn test_build_rejects_missing_theme_token_after_local_definition_filter() {
2311 let app = create_app_dir(&[
2312 ("index.html", "<my-card></my-card>"),
2313 ("my-card.html", "<div>Card</div>"),
2314 (
2315 "my-card.css",
2316 ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c))); }",
2317 ),
2318 ]);
2319 let mut options = default_options(app.path());
2320 options.theme = Some(TokenFile {
2321 themes: HashMap::from([(
2322 "light".to_string(),
2323 HashMap::from([("token-b".to_string(), "green".to_string())]),
2324 )]),
2325 });
2326
2327 let Err(err) = build(options) else {
2328 panic!("missing --token-c in the theme must fail the build");
2329 };
2330 let message = err.chain_message();
2331 assert!(message.contains("missing-theme-token"), "msg: {message}");
2332 assert!(message.contains("--token-c"), "msg: {message}");
2333 assert!(!message.contains("add --token-a"), "msg: {message}");
2336 }
2337
2338 #[test]
2339 fn test_build_to_disk_returns_accurate_stats() {
2340 let app = create_app_dir(&[
2341 ("index.html", "<my-card>Hello</my-card><p>{{name}}</p>"),
2342 ("my-card.html", "<div><slot></slot></div>"),
2343 ("my-card.css", ".card { color: red; }"),
2344 ]);
2345 let out = TempDir::new().unwrap();
2346
2347 let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
2348
2349 assert!(stats.fragment_count > 0);
2350 assert_eq!(stats.css_file_count, 1);
2351 assert!(stats.component_count > 0);
2352 assert!(stats.protocol_size_bytes > 0);
2353 assert!(!stats.duration.is_zero());
2354 }
2355}