1mod error;
28pub mod server;
29
30pub use error::WebUIError;
31
32pub use webui_handler::route_handler::{
34 encode_inventory, get_needed_components, get_needed_components_for_request, parse_inventory,
35 ProtocolIndex,
36};
37pub use webui_handler::route_matcher::CompiledRouteCache;
38pub use webui_handler::{plugin::HandlerPlugin, HandlerError, ResponseWriter, WebUIHandler};
39pub use webui_parser::CssStrategy;
40pub use webui_parser::DomStrategy;
41pub use webui_parser::Plugin;
42pub use webui_protocol::WebUIProtocol;
43
44use std::fs;
45use std::path::Path;
46use std::time::Instant;
47use webui_parser::plugin::fast::FastParserPlugin;
48use webui_parser::plugin::webui::WebUIParserPlugin;
49use webui_parser::plugin::ParserPluginArtifacts;
50use webui_parser::HtmlParser;
51
52#[derive(Debug, Clone)]
54pub struct BuildOptions {
55 pub app_dir: std::path::PathBuf,
57 pub entry: String,
59 pub css: CssStrategy,
61 pub dom: DomStrategy,
63 pub plugin: Option<Plugin>,
65 pub components: Vec<String>,
67}
68
69#[derive(Debug, Clone)]
71pub struct BuildStats {
72 pub duration: std::time::Duration,
74 pub fragment_count: usize,
76 pub component_count: usize,
78 pub css_file_count: usize,
80 pub protocol_size_bytes: usize,
82 pub token_count: usize,
84}
85
86#[derive(Debug)]
88pub struct BuildResult {
89 pub protocol: WebUIProtocol,
91 pub protocol_bytes: Vec<u8>,
93 pub css_files: Vec<(String, String)>,
95 pub component_templates: Vec<(String, String)>,
99 pub stats: BuildStats,
101}
102
103#[must_use = "BuildResult contains the compiled protocol and statistics"]
113pub fn build(options: BuildOptions) -> Result<BuildResult, WebUIError> {
114 let started = Instant::now();
115
116 let raw = build_protocol_inner(&options)?;
117
118 let protocol_bytes = raw.protocol.to_protobuf()?;
119
120 let stats = BuildStats {
121 duration: started.elapsed(),
122 fragment_count: raw.fragment_count,
123 component_count: raw.component_count,
124 css_file_count: raw.css_files.len(),
125 protocol_size_bytes: protocol_bytes.len(),
126 token_count: raw.token_count,
127 };
128
129 Ok(BuildResult {
130 protocol: raw.protocol,
131 protocol_bytes,
132 css_files: raw.css_files,
133 component_templates: raw.component_templates,
134 stats,
135 })
136}
137
138pub fn build_to_disk(options: BuildOptions, out_dir: &Path) -> Result<BuildStats, WebUIError> {
147 let result = build(options)?;
148
149 fs::create_dir_all(out_dir).map_err(|source| WebUIError::Io {
150 context: format!("Failed to create {}", out_dir.display()),
151 source,
152 })?;
153
154 fs::write(out_dir.join("protocol.bin"), &result.protocol_bytes).map_err(|source| {
155 WebUIError::Io {
156 context: format!("Failed to write protocol.bin to {}", out_dir.display()),
157 source,
158 }
159 })?;
160
161 for (name, content) in &result.css_files {
162 fs::write(out_dir.join(name), content).map_err(|source| WebUIError::Io {
163 context: format!("Failed to write {name} to {}", out_dir.display()),
164 source,
165 })?;
166 }
167
168 Ok(result.stats)
169}
170
171pub fn inspect(protocol_path: &Path) -> Result<String, WebUIError> {
173 let bytes = fs::read(protocol_path).map_err(|source| WebUIError::Io {
174 context: format!("Failed to read {}", protocol_path.display()),
175 source,
176 })?;
177 inspect_bytes(&bytes)
178}
179
180pub fn inspect_bytes(protocol_bytes: &[u8]) -> Result<String, WebUIError> {
182 let protocol = WebUIProtocol::from_protobuf(protocol_bytes)?;
183 protocol
184 .to_json_pretty()
185 .map_err(|e| WebUIError::Serialization(e.to_string()))
186}
187
188struct RawBuildOutput {
190 protocol: WebUIProtocol,
191 css_files: Vec<(String, String)>,
192 component_templates: Vec<(String, String)>,
193 fragment_count: usize,
194 component_count: usize,
195 token_count: usize,
196}
197
198fn build_protocol_inner(options: &BuildOptions) -> Result<RawBuildOutput, WebUIError> {
200 let mut parser = match options.plugin {
201 Some(Plugin::Fast) => {
202 let mut plugin = FastParserPlugin::new();
203 plugin.set_css_strategy(options.css);
204 HtmlParser::with_plugin(Box::new(plugin))
205 }
206 Some(Plugin::WebUI) => {
207 let mut plugin = WebUIParserPlugin::new();
208 plugin.set_css_strategy(options.css);
209 plugin.set_dom_strategy(options.dom);
210 HtmlParser::with_plugin(Box::new(plugin))
211 }
212 None => HtmlParser::new(),
213 };
214 parser.set_css_strategy(options.css);
215 parser.set_dom_strategy(options.dom);
216
217 parser
219 .component_registry_mut()
220 .register_from_paths(&[&options.app_dir])
221 .map_err(|e| {
222 WebUIError::ComponentRegistration(format!(
223 "Failed to register components from {}: {e}",
224 options.app_dir.display()
225 ))
226 })?;
227
228 for source in &options.components {
230 let result = webui_discovery::discover_source(source, &options.app_dir).map_err(|e| {
231 WebUIError::ComponentDiscovery(format!(
232 "Failed to discover components from {source}: {e}"
233 ))
234 })?;
235 for comp in &result.components {
236 parser
237 .component_registry_mut()
238 .register_component(
239 &comp.tag_name,
240 &comp.html_content,
241 comp.css_content.as_deref(),
242 )
243 .map_err(|e| {
244 WebUIError::ComponentRegistration(format!(
245 "Failed to register component '{}' from {}: {e}",
246 comp.tag_name, comp.source
247 ))
248 })?;
249 }
250 }
251
252 let component_count = parser.component_registry_mut().len();
253
254 let entry_path = options.app_dir.join(&options.entry);
256 let html_content = fs::read_to_string(&entry_path).map_err(|source| WebUIError::Io {
257 context: format!("Failed to read {}", entry_path.display()),
258 source,
259 })?;
260 parser
261 .parse(&options.entry, &html_content)
262 .map_err(|source| WebUIError::Parse {
263 context: format!("Failed to parse {}", options.entry),
264 source,
265 })?;
266
267 let css_snapshot: Vec<(String, String)> = parser
269 .component_registry_mut()
270 .get_all()
271 .filter_map(|c| {
272 c.css_content
273 .as_ref()
274 .map(|css| (c.tag_name.clone(), css.clone()))
275 })
276 .collect();
277
278 let tokens = parser.take_tokens();
280 let token_count = tokens.len();
281
282 let component_templates = match parser.take_plugin_artifacts() {
283 ParserPluginArtifacts::None => Vec::new(),
284 ParserPluginArtifacts::ComponentTemplates(templates) => templates,
285 };
286
287 let fragment_records = parser.into_fragment_records();
289 let fragment_count: usize = fragment_records.values().map(|v| v.fragments.len()).sum();
290
291 let mut protocol = WebUIProtocol::with_tokens(fragment_records, tokens);
292
293 protocol.set_css_strategy(match options.css {
295 CssStrategy::Link => webui_protocol::CssStrategy::Link,
296 CssStrategy::Style => webui_protocol::CssStrategy::Style,
297 CssStrategy::Module => webui_protocol::CssStrategy::Module,
298 });
299 protocol.set_dom_strategy(match options.dom {
300 DomStrategy::Shadow => webui_protocol::DomStrategy::Shadow,
301 DomStrategy::Light => webui_protocol::DomStrategy::Light,
302 });
303
304 let is_module = options.css == CssStrategy::Module;
307 let is_link = options.css == CssStrategy::Link;
308 let mut css_files: Vec<(String, String)> = Vec::new();
309 for (tag, css) in css_snapshot {
310 if !protocol.fragments.contains_key(&tag) {
311 continue;
312 }
313 if is_module {
314 protocol.components.entry(tag).or_default().css = css.trim().to_string();
315 } else if is_link {
316 let safe_tag = tag.replace(['/', '\\'], "-");
317 let href = format!("{safe_tag}.css");
318 protocol.components.entry(tag).or_default().css_href = href;
319 css_files.push((format!("{safe_tag}.css"), css));
320 }
321 }
324
325 for (tag, tmpl) in &component_templates {
327 protocol.components.entry(tag.clone()).or_default().template = tmpl.clone();
328 }
329
330 Ok(RawBuildOutput {
331 protocol,
332 css_files,
333 component_templates,
334 fragment_count,
335 component_count,
336 token_count,
337 })
338}
339
340#[cfg(test)]
341#[allow(clippy::disallowed_methods)]
342mod tests {
343 use super::*;
344 use std::path::PathBuf;
345 use tempfile::TempDir;
346 use webui_protocol::web_ui_fragment::Fragment;
347
348 fn create_app_dir(files: &[(&str, &str)]) -> TempDir {
349 let dir = TempDir::new().unwrap();
350 for (name, content) in files {
351 let path = dir.path().join(name);
352 if let Some(parent) = path.parent() {
353 fs::create_dir_all(parent).unwrap();
354 }
355 fs::write(&path, content).unwrap();
356 }
357 dir
358 }
359
360 fn default_options(app_dir: &Path) -> BuildOptions {
361 BuildOptions {
362 app_dir: app_dir.to_path_buf(),
363 entry: "index.html".to_string(),
364 css: CssStrategy::Link,
365 dom: DomStrategy::Shadow,
366 plugin: None,
367 components: Vec::new(),
368 }
369 }
370
371 #[test]
372 fn test_build_simple_html() {
373 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
374 let result = build(default_options(app.path())).unwrap();
375
376 assert!(result.protocol.fragments.contains_key("index.html"));
377 assert!(result.stats.fragment_count > 0);
378 assert!(result.stats.protocol_size_bytes > 0);
379 assert!(!result.stats.duration.is_zero());
380 }
381
382 #[test]
383 fn test_build_with_directives() {
384 let html = r#"<h1>Hello</h1>
385<for each="item in items">
386 <p>{{item.name}}</p>
387</for>
388<if condition="show">
389 <p>Visible</p>
390</if>"#;
391 let app = create_app_dir(&[("index.html", html)]);
392 let result = build(default_options(app.path())).unwrap();
393
394 let index = &result.protocol.fragments["index.html"].fragments;
395 assert!(index
396 .iter()
397 .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(_)))));
398 assert!(index
399 .iter()
400 .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
401 }
402
403 #[test]
404 fn test_build_with_component_css() {
405 let app = create_app_dir(&[
406 ("index.html", "<my-card>Hello</my-card>"),
407 ("my-card.html", "<div><slot></slot></div>"),
408 ("my-card.css", ".card { color: red; }"),
409 ]);
410 let result = build(default_options(app.path())).unwrap();
411
412 assert_eq!(result.css_files.len(), 1);
413 assert_eq!(result.css_files[0].0, "my-card.css");
414 assert!(result.css_files[0].1.contains("color: red"));
415 assert_eq!(result.stats.css_file_count, 1);
416 }
417
418 #[test]
419 fn test_css_href_set_for_light_dom_link_strategy() {
420 let app = create_app_dir(&[
421 ("index.html", "<has-css>A</has-css><no-css>B</no-css>"),
422 ("has-css.html", "<p><slot></slot></p>"),
423 ("has-css.css", ".yes { color: green; }"),
424 ("no-css.html", "<p><slot></slot></p>"),
425 ]);
426 let mut options = default_options(app.path());
427 options.dom = DomStrategy::Light;
428 let result = build(options).unwrap();
429
430 let href = result
431 .protocol
432 .components
433 .get("has-css")
434 .map(|c| c.css_href.as_str())
435 .unwrap_or("");
436 assert_eq!(
437 href, "has-css.css",
438 "Light×Link component with CSS should have css_href"
439 );
440
441 let no_href = result
442 .protocol
443 .components
444 .get("no-css")
445 .map(|c| c.css_href.as_str())
446 .unwrap_or("");
447 assert!(
448 no_href.is_empty(),
449 "Component without CSS should have empty css_href"
450 );
451 }
452
453 #[test]
454 fn test_shadow_dom_link_strategy_sets_css_href() {
455 let app = create_app_dir(&[
459 ("index.html", "<my-card>A</my-card>"),
460 ("my-card.html", "<p><slot></slot></p>"),
461 ("my-card.css", ".card { color: red; }"),
462 ]);
463 let result = build(default_options(app.path())).unwrap();
464
465 let comp = result.protocol.components.get("my-card").unwrap();
466 assert_eq!(
467 comp.css_href, "my-card.css",
468 "Shadow×Link should set css_href"
469 );
470
471 assert_eq!(
473 result.protocol.css_strategy(),
474 webui_protocol::CssStrategy::Link,
475 );
476 assert_eq!(
477 result.protocol.dom_strategy(),
478 webui_protocol::DomStrategy::Shadow,
479 );
480
481 assert_eq!(
483 result.css_files.len(),
484 1,
485 "CSS file should still be produced"
486 );
487 }
488
489 #[test]
490 fn test_css_href_empty_for_style_strategy() {
491 let app = create_app_dir(&[
492 ("index.html", "<my-card>A</my-card>"),
493 ("my-card.html", "<p><slot></slot></p>"),
494 ("my-card.css", ".card { color: red; }"),
495 ]);
496 let mut options = default_options(app.path());
497 options.css = CssStrategy::Style;
498 let result = build(options).unwrap();
499
500 let href = result
501 .protocol
502 .components
503 .get("my-card")
504 .map(|c| c.css_href.as_str())
505 .unwrap_or("");
506 assert!(href.is_empty(), "Style-strategy should not set css_href");
507 }
508
509 #[test]
510 fn test_css_href_empty_for_module_strategy() {
511 let app = create_app_dir(&[
512 ("index.html", "<my-card>A</my-card>"),
513 ("my-card.html", "<p><slot></slot></p>"),
514 ("my-card.css", ".card { color: red; }"),
515 ]);
516 let mut options = default_options(app.path());
517 options.css = CssStrategy::Module;
518 let result = build(options).unwrap();
519
520 let href = result
521 .protocol
522 .components
523 .get("my-card")
524 .map(|c| c.css_href.as_str())
525 .unwrap_or("");
526 assert!(href.is_empty(), "Module-strategy should not set css_href");
527 assert!(
528 !result.protocol.components["my-card"].css.is_empty(),
529 "Module-strategy should still populate css content"
530 );
531 }
532
533 #[test]
534 fn test_build_to_disk_writes_files() {
535 let app = create_app_dir(&[
536 ("index.html", "<my-card>Hello</my-card>"),
537 ("my-card.html", "<div><slot></slot></div>"),
538 ("my-card.css", ".card { color: red; }"),
539 ]);
540 let out = TempDir::new().unwrap();
541
542 let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
543
544 assert!(out.path().join("protocol.bin").exists());
545 assert!(out.path().join("my-card.css").exists());
546 assert_eq!(stats.css_file_count, 1);
547 assert!(stats.fragment_count > 0);
548 }
549
550 #[test]
551 fn test_build_to_disk_creates_nested_output_dir() {
552 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
553 let out = TempDir::new().unwrap();
554 let nested = out.path().join("nested").join("output");
555
556 let stats = build_to_disk(default_options(app.path()), &nested).unwrap();
557 assert!(nested.join("protocol.bin").exists());
558 assert!(stats.fragment_count > 0);
559 }
560
561 #[test]
562 fn test_build_missing_entry() {
563 let app = create_app_dir(&[("other.html", "<h1>Not index</h1>")]);
564 let result = build(default_options(app.path()));
565
566 assert!(result.is_err());
567 let err = result.unwrap_err();
568 assert!(matches!(err, WebUIError::Io { .. }));
569 }
570
571 #[test]
572 fn test_build_stats_populated() {
573 let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
574 let result = build(default_options(app.path())).unwrap();
575
576 assert!(result.stats.fragment_count > 0);
577 assert!(result.stats.protocol_size_bytes > 0);
578 assert_eq!(result.stats.css_file_count, 0);
579 }
580
581 #[test]
582 fn test_build_inline_css() {
583 let app = create_app_dir(&[
584 ("index.html", "<my-card>Hello</my-card>"),
585 ("my-card.html", "<div><slot></slot></div>"),
586 ("my-card.css", ".card { color: red; }"),
587 ]);
588 let mut options = default_options(app.path());
589 options.css = CssStrategy::Style;
590
591 let result = build(options).unwrap();
592 assert!(result.css_files.is_empty());
594 assert_eq!(result.stats.css_file_count, 0);
595 assert!(result.stats.fragment_count > 0);
596 }
597
598 #[test]
599 fn test_build_module_css() {
600 let app = create_app_dir(&[
601 ("index.html", "<my-card>Hello</my-card>"),
602 ("my-card.html", "<div><slot></slot></div>"),
603 ("my-card.css", ".card { color: red; }"),
604 ]);
605 let mut options = default_options(app.path());
606 options.css = CssStrategy::Module;
607
608 let result = build(options).unwrap();
609 assert!(result.css_files.is_empty());
611 assert_eq!(result.stats.css_file_count, 0);
612 assert!(result.stats.fragment_count > 0);
613 }
614
615 #[test]
616 fn test_build_custom_entry() {
617 let app = create_app_dir(&[("page.html", "<h1>Custom</h1>")]);
618 let mut options = default_options(app.path());
619 options.entry = "page.html".to_string();
620
621 let result = build(options).unwrap();
622 assert!(result.protocol.fragments.contains_key("page.html"));
623 assert!(!result.protocol.fragments.contains_key("index.html"));
624 }
625
626 #[test]
627 fn test_build_protocol_roundtrip() {
628 let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
629 let result = build(default_options(app.path())).unwrap();
630
631 let restored = WebUIProtocol::from_protobuf(&result.protocol_bytes).unwrap();
632 assert!(restored.fragments.contains_key("index.html"));
633 }
634
635 #[test]
636 fn test_inspect_bytes_valid() {
637 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
638 let result = build(default_options(app.path())).unwrap();
639
640 let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
641 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
642 assert!(parsed.get("fragments").is_some());
643 }
644
645 #[test]
646 fn test_inspect_bytes_invalid() {
647 let result = inspect_bytes(b"not a protobuf");
648 assert!(result.is_err());
649 assert!(matches!(result.unwrap_err(), WebUIError::Protocol(_)));
650 }
651
652 #[test]
653 fn test_inspect_file() {
654 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
655 let out = TempDir::new().unwrap();
656 build_to_disk(default_options(app.path()), out.path()).unwrap();
657
658 let json_str = inspect(&out.path().join("protocol.bin")).unwrap();
659 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
660 assert!(parsed["fragments"]["index.html"]["fragments"].is_array());
661 }
662
663 #[test]
664 fn test_inspect_missing_file() {
665 let result = inspect(Path::new("/nonexistent/protocol.bin"));
666 assert!(result.is_err());
667 assert!(matches!(result.unwrap_err(), WebUIError::Io { .. }));
668 }
669
670 #[test]
671 fn test_build_with_components_local_path() {
672 let app = create_app_dir(&[("index.html", "<ext-card>Hello</ext-card>")]);
673 let ext_dir = TempDir::new().unwrap();
674 fs::write(
675 ext_dir.path().join("ext-card.html"),
676 "<div class=\"card\"><slot></slot></div>",
677 )
678 .unwrap();
679 fs::write(
680 ext_dir.path().join("ext-card.css"),
681 ".card { border: 1px solid #ccc; }",
682 )
683 .unwrap();
684
685 let mut options = default_options(app.path());
686 options.components = vec![ext_dir.path().to_string_lossy().to_string()];
687
688 let result = build(options).unwrap();
689 assert!(result.protocol.fragments.contains_key("index.html"));
690 assert_eq!(result.css_files.len(), 1);
691 assert!(result.css_files[0].1.contains("border"));
692 }
693
694 #[test]
695 fn test_build_hello_world_example() {
696 let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
697 let app_dir = manifest_dir.join("../../examples/app/hello-world/src");
698
699 let result = build(BuildOptions {
700 app_dir,
701 entry: "index.html".to_string(),
702 css: CssStrategy::Link,
703 dom: DomStrategy::Shadow,
704 plugin: None,
705 components: Vec::new(),
706 })
707 .unwrap();
708
709 let index = &result.protocol.fragments["index.html"].fragments;
710 assert!(index.iter().any(
711 |f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(fl)) if fl.collection == "people")
712 ));
713 assert!(index
714 .iter()
715 .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
716 }
717
718 #[test]
721 fn test_css_filename_sanitizes_path_separators() {
722 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
725 let result = build(default_options(app.path())).unwrap();
726
727 for (filename, _) in &result.css_files {
729 assert!(
730 !filename.contains('/') && !filename.contains('\\'),
731 "CSS filename contains path separator: {filename}"
732 );
733 }
734 }
735
736 #[test]
737 fn test_build_to_disk_css_stays_in_output_dir() {
738 let app = create_app_dir(&[
739 ("index.html", "<my-card>Hello</my-card>"),
740 ("my-card.html", "<div><slot></slot></div>"),
741 ("my-card.css", ".card { color: red; }"),
742 ]);
743 let out = TempDir::new().unwrap();
744
745 build_to_disk(default_options(app.path()), out.path()).unwrap();
746
747 let css_path = out.path().join("my-card.css");
749 assert!(css_path.exists());
750 let canonical = css_path.canonicalize().unwrap();
751 let out_canonical = out.path().canonicalize().unwrap();
752 assert!(
753 canonical.starts_with(&out_canonical),
754 "CSS file escaped output directory"
755 );
756 }
757
758 #[test]
761 fn test_build_empty_html() {
762 let app = create_app_dir(&[("index.html", "")]);
763 let result = build(default_options(app.path())).unwrap();
764
765 assert!(result.protocol.fragments.contains_key("index.html"));
766 assert_eq!(result.stats.fragment_count, 0);
767 assert!(result.stats.protocol_size_bytes > 0);
768 }
769
770 #[test]
771 fn test_build_large_fragment_count() {
772 let mut html = String::with_capacity(2000);
774 for i in 0..50 {
775 html.push_str(&format!("<p>Item {i}</p>\n"));
776 }
777 let app = create_app_dir(&[("index.html", &html)]);
778 let result = build(default_options(app.path())).unwrap();
779
780 assert!(result.stats.fragment_count > 0);
781 assert_eq!(result.stats.css_file_count, 0);
782 }
783
784 #[test]
785 fn test_build_with_fast_plugin() {
786 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
787 let mut options = default_options(app.path());
788 options.plugin = Some(Plugin::Fast);
789
790 let result = build(options).unwrap();
791 assert!(result.protocol.fragments.contains_key("index.html"));
792 }
793
794 #[test]
795 fn test_build_to_disk_inline_mode_no_css_files() {
796 let app = create_app_dir(&[
797 ("index.html", "<my-card>Hello</my-card>"),
798 ("my-card.html", "<div><slot></slot></div>"),
799 ("my-card.css", ".card { color: red; }"),
800 ]);
801 let out = TempDir::new().unwrap();
802 let mut options = default_options(app.path());
803 options.css = CssStrategy::Style;
804
805 let stats = build_to_disk(options, out.path()).unwrap();
806
807 assert!(out.path().join("protocol.bin").exists());
808 assert!(!out.path().join("my-card.css").exists());
809 assert_eq!(stats.css_file_count, 0);
810 }
811
812 #[test]
813 fn test_build_stats_duration_is_nonzero() {
814 let app = create_app_dir(&[("index.html", "<h1>Hello {{name}}</h1>")]);
815 let result = build(default_options(app.path())).unwrap();
816
817 assert!(!result.stats.duration.is_zero());
818 }
819
820 #[test]
821 fn test_build_multiple_components_css() {
822 let app = create_app_dir(&[
823 ("index.html", "<card-a>A</card-a><card-b>B</card-b>"),
824 ("card-a.html", "<div><slot></slot></div>"),
825 ("card-a.css", ".a { color: red; }"),
826 ("card-b.html", "<span><slot></slot></span>"),
827 ("card-b.css", ".b { color: blue; }"),
828 ]);
829 let result = build(default_options(app.path())).unwrap();
830
831 assert_eq!(result.css_files.len(), 2);
832 assert_eq!(result.stats.css_file_count, 2);
833 let filenames: Vec<&str> = result.css_files.iter().map(|(n, _)| n.as_str()).collect();
834 assert!(filenames.contains(&"card-a.css"));
835 assert!(filenames.contains(&"card-b.css"));
836 }
837
838 #[test]
839 fn test_build_unused_component_css_not_emitted() {
840 let app = create_app_dir(&[
842 ("index.html", "<card-a>A</card-a>"),
843 ("card-a.html", "<div><slot></slot></div>"),
844 ("card-a.css", ".a { color: red; }"),
845 ("card-b.html", "<span><slot></slot></span>"),
846 ("card-b.css", ".b { color: blue; }"),
847 ]);
848 let result = build(default_options(app.path())).unwrap();
849
850 assert_eq!(result.css_files.len(), 1);
851 assert_eq!(result.css_files[0].0, "card-a.css");
852 }
853
854 #[test]
855 fn test_inspect_roundtrip_preserves_content() {
856 let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
857 let result = build(default_options(app.path())).unwrap();
858
859 let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
860 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
861
862 let fragments = &parsed["fragments"]["index.html"]["fragments"];
863 assert!(fragments.is_array());
864 assert!(!fragments.as_array().unwrap().is_empty());
865 }
866
867 #[test]
868 fn test_build_nonexistent_app_dir() {
869 let options = BuildOptions {
870 app_dir: PathBuf::from("/nonexistent/path/that/does/not/exist"),
871 entry: "index.html".to_string(),
872 css: CssStrategy::Link,
873 dom: DomStrategy::Shadow,
874 plugin: None,
875 components: Vec::new(),
876 };
877 let result = build(options);
878 assert!(result.is_err());
879 }
880
881 #[test]
882 fn test_build_to_disk_returns_accurate_stats() {
883 let app = create_app_dir(&[
884 ("index.html", "<my-card>Hello</my-card><p>{{name}}</p>"),
885 ("my-card.html", "<div><slot></slot></div>"),
886 ("my-card.css", ".card { color: red; }"),
887 ]);
888 let out = TempDir::new().unwrap();
889
890 let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
891
892 assert!(stats.fragment_count > 0);
893 assert_eq!(stats.css_file_count, 1);
894 assert!(stats.component_count > 0);
895 assert!(stats.protocol_size_bytes > 0);
896 assert!(!stats.duration.is_zero());
897 }
898}