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