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