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