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