Skip to main content

webui/
lib.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Programmatic API for the WebUI build-time rendering framework.
5//!
6//! This crate provides the core build, render, and inspection APIs
7//! that power the `webui` CLI, Node.js bindings, and WASM module.
8//!
9//! # Example
10//!
11//! ```rust,no_run
12//! use webui::{build, BuildOptions, CssStrategy};
13//! use std::path::PathBuf;
14//!
15//! let result = build(BuildOptions {
16//!     app_dir: PathBuf::from("./src"),
17//!     entry: "index.html".to_string(),
18//!     css: CssStrategy::Link,
19//!     plugin: None,
20//!     components: Vec::new(),
21//! }).unwrap();
22//!
23//! println!("Built {} fragments in {:?}", result.stats.fragment_count, result.stats.duration);
24//! ```
25
26mod error;
27
28pub use error::WebUIError;
29
30// Re-export core types from downstream crates
31pub use webui_handler::route_handler::{
32    encode_inventory, get_needed_components, get_needed_components_for_request,
33    get_route_templates, get_route_templates_for_request, parse_inventory,
34};
35pub use webui_handler::{plugin::HandlerPlugin, HandlerError, ResponseWriter, WebUIHandler};
36pub use webui_parser::CssStrategy;
37pub use webui_protocol::WebUIProtocol;
38
39use std::fs;
40use std::path::Path;
41use std::time::Instant;
42use webui_parser::plugin::FastParserPlugin;
43use webui_parser::HtmlParser;
44
45/// Options for building a WebUI application.
46#[derive(Debug, Clone)]
47pub struct BuildOptions {
48    /// Path to the application folder containing templates.
49    pub app_dir: std::path::PathBuf,
50    /// Entry HTML file name (e.g., `"index.html"`).
51    pub entry: String,
52    /// CSS delivery strategy for component stylesheets.
53    pub css: CssStrategy,
54    /// Parser plugin to load (e.g., `"fast"` for FAST-HTML hydration support).
55    pub plugin: Option<String>,
56    /// Additional component sources (npm packages or local paths).
57    pub components: Vec<String>,
58}
59
60/// Statistics about a completed build.
61#[derive(Debug, Clone)]
62pub struct BuildStats {
63    /// Total wall-clock build time.
64    pub duration: std::time::Duration,
65    /// Total number of protocol fragments.
66    pub fragment_count: usize,
67    /// Number of registered components.
68    pub component_count: usize,
69    /// Number of CSS files produced.
70    pub css_file_count: usize,
71    /// Size of the serialized protocol in bytes.
72    pub protocol_size_bytes: usize,
73    /// Number of unique CSS tokens discovered.
74    pub token_count: usize,
75}
76
77/// Result of a successful build.
78#[derive(Debug)]
79pub struct BuildResult {
80    /// The compiled WebUI protocol.
81    pub protocol: WebUIProtocol,
82    /// Serialized protocol bytes (protobuf binary).
83    pub protocol_bytes: Vec<u8>,
84    /// Component CSS files: `(filename, content)` — only components referenced in the protocol.
85    pub css_files: Vec<(String, String)>,
86    /// Component f-template strings: `(tag_name, f_template_html)`.
87    /// Includes templates for all components encountered during parsing,
88    /// including route-referenced components.
89    pub component_templates: Vec<(String, String)>,
90    /// Build statistics.
91    pub stats: BuildStats,
92}
93
94/// Build a WebUI application from an app directory.
95///
96/// Parses templates, discovers components, and produces a compiled protocol
97/// with build statistics.
98///
99/// # Errors
100///
101/// Returns [`WebUIError`] if the app directory is invalid, templates fail
102/// to parse, or the protocol cannot be serialized.
103#[must_use = "BuildResult contains the compiled protocol and statistics"]
104pub fn build(options: BuildOptions) -> Result<BuildResult, WebUIError> {
105    let started = Instant::now();
106
107    let raw = build_protocol_inner(&options)?;
108
109    let protocol_bytes = raw
110        .protocol
111        .to_protobuf()
112        .map_err(|e| WebUIError::Serialization(e.to_string()))?;
113
114    let stats = BuildStats {
115        duration: started.elapsed(),
116        fragment_count: raw.fragment_count,
117        component_count: raw.component_count,
118        css_file_count: raw.css_files.len(),
119        protocol_size_bytes: protocol_bytes.len(),
120        token_count: raw.token_count,
121    };
122
123    Ok(BuildResult {
124        protocol: raw.protocol,
125        protocol_bytes,
126        css_files: raw.css_files,
127        component_templates: raw.component_templates,
128        stats,
129    })
130}
131
132/// Build a WebUI application and write output files to disk.
133///
134/// Writes `protocol.bin` and any external CSS files to `out_dir`.
135/// Creates `out_dir` if it does not exist.
136///
137/// # Errors
138///
139/// Returns [`WebUIError`] on build failure or if output files cannot be written.
140pub fn build_to_disk(options: BuildOptions, out_dir: &Path) -> Result<BuildStats, WebUIError> {
141    let result = build(options)?;
142
143    fs::create_dir_all(out_dir)
144        .map_err(|e| WebUIError::Io(format!("Failed to create {}: {e}", out_dir.display())))?;
145
146    fs::write(out_dir.join("protocol.bin"), &result.protocol_bytes).map_err(|e| {
147        WebUIError::Io(format!(
148            "Failed to write protocol.bin to {}: {e}",
149            out_dir.display()
150        ))
151    })?;
152
153    for (name, content) in &result.css_files {
154        fs::write(out_dir.join(name), content).map_err(|e| {
155            WebUIError::Io(format!(
156                "Failed to write {name} to {}: {e}",
157                out_dir.display()
158            ))
159        })?;
160    }
161
162    Ok(result.stats)
163}
164
165/// Inspect a compiled WebUI protocol file and return its JSON representation.
166pub fn inspect(protocol_path: &Path) -> Result<String, WebUIError> {
167    let bytes = fs::read(protocol_path)
168        .map_err(|e| WebUIError::Io(format!("Failed to read {}: {e}", protocol_path.display())))?;
169    inspect_bytes(&bytes)
170}
171
172/// Inspect raw protocol bytes and return their JSON representation.
173pub fn inspect_bytes(protocol_bytes: &[u8]) -> Result<String, WebUIError> {
174    let protocol = WebUIProtocol::from_protobuf(protocol_bytes)
175        .map_err(|e| WebUIError::Protocol(e.to_string()))?;
176    protocol
177        .to_json_pretty()
178        .map_err(|e| WebUIError::Serialization(e.to_string()))
179}
180
181/// Internal intermediate build output before stats are computed.
182struct RawBuildOutput {
183    protocol: WebUIProtocol,
184    css_files: Vec<(String, String)>,
185    component_templates: Vec<(String, String)>,
186    fragment_count: usize,
187    component_count: usize,
188    token_count: usize,
189}
190
191/// Internal build logic shared by `build()` and `build_to_disk()`.
192fn build_protocol_inner(options: &BuildOptions) -> Result<RawBuildOutput, WebUIError> {
193    let mut parser = match options.plugin.as_deref() {
194        Some("fast") => {
195            let mut plugin = FastParserPlugin::new();
196            plugin.set_css_strategy(options.css);
197            HtmlParser::with_plugin(Box::new(plugin))
198        }
199        Some(unknown) => return Err(WebUIError::InvalidPlugin(unknown.to_string())),
200        None => HtmlParser::new(),
201    };
202    parser.set_css_strategy(options.css);
203
204    // Register app directory components
205    parser
206        .component_registry_mut()
207        .register_from_paths(&[&options.app_dir])
208        .map_err(|e| {
209            WebUIError::ComponentRegistration(format!(
210                "Failed to register components from {}: {e}",
211                options.app_dir.display()
212            ))
213        })?;
214
215    // Discover and register external component sources
216    for source in &options.components {
217        let result = webui_discovery::discover_source(source, &options.app_dir).map_err(|e| {
218            WebUIError::ComponentDiscovery(format!(
219                "Failed to discover components from {source}: {e}"
220            ))
221        })?;
222        for comp in &result.components {
223            parser
224                .component_registry_mut()
225                .register_component(
226                    &comp.tag_name,
227                    &comp.html_content,
228                    comp.css_content.as_deref(),
229                )
230                .map_err(|e| {
231                    WebUIError::ComponentRegistration(format!(
232                        "Failed to register component '{}' from {}: {e}",
233                        comp.tag_name, comp.source
234                    ))
235                })?;
236        }
237    }
238
239    let component_count = parser.component_registry_mut().len();
240
241    // Parse entry HTML
242    let entry_path = options.app_dir.join(&options.entry);
243    let html_content = fs::read_to_string(&entry_path)
244        .map_err(|e| WebUIError::Io(format!("Failed to read {}: {e}", entry_path.display())))?;
245    parser
246        .parse(&options.entry, &html_content)
247        .map_err(|e| WebUIError::Parse(format!("Failed to parse {}: {e}", options.entry)))?;
248
249    // Snapshot CSS before consuming the parser
250    let css_snapshot: Vec<(String, String)> = parser
251        .component_registry_mut()
252        .get_all()
253        .filter_map(|c| {
254            c.css_content
255                .as_ref()
256                .map(|css| (c.tag_name.clone(), css.clone()))
257        })
258        .collect();
259
260    // Collect CSS tokens before consuming the parser
261    let tokens = parser.take_tokens();
262    let token_count = tokens.len();
263
264    // Extract individual component f-template strings from the FAST plugin
265    // before consuming the parser.
266    let component_templates = parser
267        .take_plugin()
268        .and_then(|p| {
269            p.as_any()
270                .downcast_ref::<FastParserPlugin>()
271                .map(|fast| fast.take_component_templates())
272        })
273        .unwrap_or_default();
274
275    // Build protocol (consumes parser)
276    let fragment_records = parser.into_fragment_records();
277    let fragment_count: usize = fragment_records.values().map(|v| v.fragments.len()).sum();
278
279    let mut protocol = WebUIProtocol::with_tokens(fragment_records, tokens);
280
281    // Store component CSS in the protocol keyed by tag name.
282    // Only Module strategy populates this — the handler emits CSS module
283    // <style> tags in <head> and prepends them to partial f-templates.
284    // Link and Style strategies handle CSS via <link> tags and inline
285    // <style> tags baked into raw fragments by the parser.
286    if options.css == CssStrategy::Module {
287        for (tag, css) in &css_snapshot {
288            if protocol.fragments.contains_key(tag) {
289                protocol.components.entry(tag.clone()).or_default().css = css.trim().to_string();
290            }
291        }
292    }
293
294    // Filter CSS to only protocol-referenced components.
295    // In Style mode CSS is embedded in <style> tags; in Module mode CSS is
296    // emitted as <style type="module"> definitions. Neither produces external
297    // CSS files. Sanitize filenames: strip path separators to prevent traversal.
298    let css_files: Vec<(String, String)> =
299        if matches!(options.css, CssStrategy::Style | CssStrategy::Module) {
300            Vec::new()
301        } else {
302            css_snapshot
303                .into_iter()
304                .filter(|(tag, _)| protocol.fragments.contains_key(tag))
305                .map(|(tag, css)| {
306                    let safe_tag = tag.replace(['/', '\\'], "-");
307                    (format!("{safe_tag}.css"), css)
308                })
309                .collect()
310        };
311
312    // Store f-templates in the protocol so any host server can query them
313    for (tag, tmpl) in &component_templates {
314        protocol.components.entry(tag.clone()).or_default().template = tmpl.clone();
315    }
316
317    Ok(RawBuildOutput {
318        protocol,
319        css_files,
320        component_templates,
321        fragment_count,
322        component_count,
323        token_count,
324    })
325}
326
327#[cfg(test)]
328#[allow(clippy::disallowed_methods)]
329mod tests {
330    use super::*;
331    use std::path::PathBuf;
332    use tempfile::TempDir;
333    use webui_protocol::web_ui_fragment::Fragment;
334
335    fn create_app_dir(files: &[(&str, &str)]) -> TempDir {
336        let dir = TempDir::new().unwrap();
337        for (name, content) in files {
338            let path = dir.path().join(name);
339            if let Some(parent) = path.parent() {
340                fs::create_dir_all(parent).unwrap();
341            }
342            fs::write(&path, content).unwrap();
343        }
344        dir
345    }
346
347    fn default_options(app_dir: &Path) -> BuildOptions {
348        BuildOptions {
349            app_dir: app_dir.to_path_buf(),
350            entry: "index.html".to_string(),
351            css: CssStrategy::Link,
352            plugin: None,
353            components: Vec::new(),
354        }
355    }
356
357    #[test]
358    fn test_build_simple_html() {
359        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
360        let result = build(default_options(app.path())).unwrap();
361
362        assert!(result.protocol.fragments.contains_key("index.html"));
363        assert!(result.stats.fragment_count > 0);
364        assert!(result.stats.protocol_size_bytes > 0);
365        assert!(!result.stats.duration.is_zero());
366    }
367
368    #[test]
369    fn test_build_with_directives() {
370        let html = r#"<h1>Hello</h1>
371<for each="item in items">
372    <p>{{item.name}}</p>
373</for>
374<if condition="show">
375    <p>Visible</p>
376</if>"#;
377        let app = create_app_dir(&[("index.html", html)]);
378        let result = build(default_options(app.path())).unwrap();
379
380        let index = &result.protocol.fragments["index.html"].fragments;
381        assert!(index
382            .iter()
383            .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(_)))));
384        assert!(index
385            .iter()
386            .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
387    }
388
389    #[test]
390    fn test_build_with_component_css() {
391        let app = create_app_dir(&[
392            ("index.html", "<my-card>Hello</my-card>"),
393            ("my-card.html", "<div><slot></slot></div>"),
394            ("my-card.css", ".card { color: red; }"),
395        ]);
396        let result = build(default_options(app.path())).unwrap();
397
398        assert_eq!(result.css_files.len(), 1);
399        assert_eq!(result.css_files[0].0, "my-card.css");
400        assert!(result.css_files[0].1.contains("color: red"));
401        assert_eq!(result.stats.css_file_count, 1);
402    }
403
404    #[test]
405    fn test_build_to_disk_writes_files() {
406        let app = create_app_dir(&[
407            ("index.html", "<my-card>Hello</my-card>"),
408            ("my-card.html", "<div><slot></slot></div>"),
409            ("my-card.css", ".card { color: red; }"),
410        ]);
411        let out = TempDir::new().unwrap();
412
413        let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
414
415        assert!(out.path().join("protocol.bin").exists());
416        assert!(out.path().join("my-card.css").exists());
417        assert_eq!(stats.css_file_count, 1);
418        assert!(stats.fragment_count > 0);
419    }
420
421    #[test]
422    fn test_build_to_disk_creates_nested_output_dir() {
423        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
424        let out = TempDir::new().unwrap();
425        let nested = out.path().join("nested").join("output");
426
427        let stats = build_to_disk(default_options(app.path()), &nested).unwrap();
428        assert!(nested.join("protocol.bin").exists());
429        assert!(stats.fragment_count > 0);
430    }
431
432    #[test]
433    fn test_build_missing_entry() {
434        let app = create_app_dir(&[("other.html", "<h1>Not index</h1>")]);
435        let result = build(default_options(app.path()));
436
437        assert!(result.is_err());
438        let err = result.unwrap_err();
439        assert!(matches!(err, WebUIError::Io(_)));
440    }
441
442    #[test]
443    fn test_build_invalid_plugin() {
444        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
445        let mut options = default_options(app.path());
446        options.plugin = Some("nonexistent".to_string());
447
448        let result = build(options);
449        assert!(result.is_err());
450        assert!(matches!(result.unwrap_err(), WebUIError::InvalidPlugin(_)));
451    }
452
453    #[test]
454    fn test_build_stats_populated() {
455        let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
456        let result = build(default_options(app.path())).unwrap();
457
458        assert!(result.stats.fragment_count > 0);
459        assert!(result.stats.protocol_size_bytes > 0);
460        assert_eq!(result.stats.css_file_count, 0);
461    }
462
463    #[test]
464    fn test_build_inline_css() {
465        let app = create_app_dir(&[
466            ("index.html", "<my-card>Hello</my-card>"),
467            ("my-card.html", "<div><slot></slot></div>"),
468            ("my-card.css", ".card { color: red; }"),
469        ]);
470        let mut options = default_options(app.path());
471        options.css = CssStrategy::Style;
472
473        let result = build(options).unwrap();
474        // Inline mode embeds CSS in <style> tags — no external CSS files
475        assert!(result.css_files.is_empty());
476        assert_eq!(result.stats.css_file_count, 0);
477        assert!(result.stats.fragment_count > 0);
478    }
479
480    #[test]
481    fn test_build_module_css() {
482        let app = create_app_dir(&[
483            ("index.html", "<my-card>Hello</my-card>"),
484            ("my-card.html", "<div><slot></slot></div>"),
485            ("my-card.css", ".card { color: red; }"),
486        ]);
487        let mut options = default_options(app.path());
488        options.css = CssStrategy::Module;
489
490        let result = build(options).unwrap();
491        // Module mode uses <style type="module"> — no external CSS files
492        assert!(result.css_files.is_empty());
493        assert_eq!(result.stats.css_file_count, 0);
494        assert!(result.stats.fragment_count > 0);
495    }
496
497    #[test]
498    fn test_build_custom_entry() {
499        let app = create_app_dir(&[("page.html", "<h1>Custom</h1>")]);
500        let mut options = default_options(app.path());
501        options.entry = "page.html".to_string();
502
503        let result = build(options).unwrap();
504        assert!(result.protocol.fragments.contains_key("page.html"));
505        assert!(!result.protocol.fragments.contains_key("index.html"));
506    }
507
508    #[test]
509    fn test_build_protocol_roundtrip() {
510        let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
511        let result = build(default_options(app.path())).unwrap();
512
513        let restored = WebUIProtocol::from_protobuf(&result.protocol_bytes).unwrap();
514        assert!(restored.fragments.contains_key("index.html"));
515    }
516
517    #[test]
518    fn test_inspect_bytes_valid() {
519        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
520        let result = build(default_options(app.path())).unwrap();
521
522        let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
523        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
524        assert!(parsed.get("fragments").is_some());
525    }
526
527    #[test]
528    fn test_inspect_bytes_invalid() {
529        let result = inspect_bytes(b"not a protobuf");
530        assert!(result.is_err());
531        assert!(matches!(result.unwrap_err(), WebUIError::Protocol(_)));
532    }
533
534    #[test]
535    fn test_inspect_file() {
536        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
537        let out = TempDir::new().unwrap();
538        build_to_disk(default_options(app.path()), out.path()).unwrap();
539
540        let json_str = inspect(&out.path().join("protocol.bin")).unwrap();
541        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
542        assert!(parsed["fragments"]["index.html"]["fragments"].is_array());
543    }
544
545    #[test]
546    fn test_inspect_missing_file() {
547        let result = inspect(Path::new("/nonexistent/protocol.bin"));
548        assert!(result.is_err());
549        assert!(matches!(result.unwrap_err(), WebUIError::Io(_)));
550    }
551
552    #[test]
553    fn test_build_with_components_local_path() {
554        let app = create_app_dir(&[("index.html", "<ext-card>Hello</ext-card>")]);
555        let ext_dir = TempDir::new().unwrap();
556        fs::write(
557            ext_dir.path().join("ext-card.html"),
558            "<div class=\"card\"><slot></slot></div>",
559        )
560        .unwrap();
561        fs::write(
562            ext_dir.path().join("ext-card.css"),
563            ".card { border: 1px solid #ccc; }",
564        )
565        .unwrap();
566
567        let mut options = default_options(app.path());
568        options.components = vec![ext_dir.path().to_string_lossy().to_string()];
569
570        let result = build(options).unwrap();
571        assert!(result.protocol.fragments.contains_key("index.html"));
572        assert_eq!(result.css_files.len(), 1);
573        assert!(result.css_files[0].1.contains("border"));
574    }
575
576    #[test]
577    fn test_build_hello_world_example() {
578        let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
579        let app_dir = manifest_dir.join("../../examples/app/hello-world/src");
580
581        let result = build(BuildOptions {
582            app_dir,
583            entry: "index.html".to_string(),
584            css: CssStrategy::Link,
585            plugin: None,
586            components: Vec::new(),
587        })
588        .unwrap();
589
590        let index = &result.protocol.fragments["index.html"].fragments;
591        assert!(index.iter().any(
592            |f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(fl)) if fl.collection == "people")
593        ));
594        assert!(index
595            .iter()
596            .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
597    }
598
599    // ── Security tests ───────────────────────────────────────────────
600
601    #[test]
602    fn test_css_filename_sanitizes_path_separators() {
603        // Even if a component tag somehow contains path separators,
604        // the filename should be sanitized to prevent directory traversal.
605        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
606        let result = build(default_options(app.path())).unwrap();
607
608        // Verify all CSS filenames are plain file names (no path separators)
609        for (filename, _) in &result.css_files {
610            assert!(
611                !filename.contains('/') && !filename.contains('\\'),
612                "CSS filename contains path separator: {filename}"
613            );
614        }
615    }
616
617    #[test]
618    fn test_build_to_disk_css_stays_in_output_dir() {
619        let app = create_app_dir(&[
620            ("index.html", "<my-card>Hello</my-card>"),
621            ("my-card.html", "<div><slot></slot></div>"),
622            ("my-card.css", ".card { color: red; }"),
623        ]);
624        let out = TempDir::new().unwrap();
625
626        build_to_disk(default_options(app.path()), out.path()).unwrap();
627
628        // Verify CSS file is written inside out_dir
629        let css_path = out.path().join("my-card.css");
630        assert!(css_path.exists());
631        let canonical = css_path.canonicalize().unwrap();
632        let out_canonical = out.path().canonicalize().unwrap();
633        assert!(
634            canonical.starts_with(&out_canonical),
635            "CSS file escaped output directory"
636        );
637    }
638
639    // ── Performance / edge case tests ────────────────────────────────
640
641    #[test]
642    fn test_build_empty_html() {
643        let app = create_app_dir(&[("index.html", "")]);
644        let result = build(default_options(app.path())).unwrap();
645
646        assert!(result.protocol.fragments.contains_key("index.html"));
647        assert_eq!(result.stats.fragment_count, 0);
648        assert!(result.stats.protocol_size_bytes > 0);
649    }
650
651    #[test]
652    fn test_build_large_fragment_count() {
653        // Verify stats are accurate with many fragments
654        let mut html = String::with_capacity(2000);
655        for i in 0..50 {
656            html.push_str(&format!("<p>Item {i}</p>\n"));
657        }
658        let app = create_app_dir(&[("index.html", &html)]);
659        let result = build(default_options(app.path())).unwrap();
660
661        assert!(result.stats.fragment_count > 0);
662        assert_eq!(result.stats.css_file_count, 0);
663    }
664
665    #[test]
666    fn test_build_with_fast_plugin() {
667        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
668        let mut options = default_options(app.path());
669        options.plugin = Some("fast".to_string());
670
671        let result = build(options).unwrap();
672        assert!(result.protocol.fragments.contains_key("index.html"));
673    }
674
675    #[test]
676    fn test_build_to_disk_inline_mode_no_css_files() {
677        let app = create_app_dir(&[
678            ("index.html", "<my-card>Hello</my-card>"),
679            ("my-card.html", "<div><slot></slot></div>"),
680            ("my-card.css", ".card { color: red; }"),
681        ]);
682        let out = TempDir::new().unwrap();
683        let mut options = default_options(app.path());
684        options.css = CssStrategy::Style;
685
686        let stats = build_to_disk(options, out.path()).unwrap();
687
688        assert!(out.path().join("protocol.bin").exists());
689        assert!(!out.path().join("my-card.css").exists());
690        assert_eq!(stats.css_file_count, 0);
691    }
692
693    #[test]
694    fn test_build_stats_duration_is_nonzero() {
695        let app = create_app_dir(&[("index.html", "<h1>Hello {{name}}</h1>")]);
696        let result = build(default_options(app.path())).unwrap();
697
698        assert!(!result.stats.duration.is_zero());
699    }
700
701    #[test]
702    fn test_build_multiple_components_css() {
703        let app = create_app_dir(&[
704            ("index.html", "<card-a>A</card-a><card-b>B</card-b>"),
705            ("card-a.html", "<div><slot></slot></div>"),
706            ("card-a.css", ".a { color: red; }"),
707            ("card-b.html", "<span><slot></slot></span>"),
708            ("card-b.css", ".b { color: blue; }"),
709        ]);
710        let result = build(default_options(app.path())).unwrap();
711
712        assert_eq!(result.css_files.len(), 2);
713        assert_eq!(result.stats.css_file_count, 2);
714        let filenames: Vec<&str> = result.css_files.iter().map(|(n, _)| n.as_str()).collect();
715        assert!(filenames.contains(&"card-a.css"));
716        assert!(filenames.contains(&"card-b.css"));
717    }
718
719    #[test]
720    fn test_build_unused_component_css_not_emitted() {
721        // card-b is registered but not referenced in index.html
722        let app = create_app_dir(&[
723            ("index.html", "<card-a>A</card-a>"),
724            ("card-a.html", "<div><slot></slot></div>"),
725            ("card-a.css", ".a { color: red; }"),
726            ("card-b.html", "<span><slot></slot></span>"),
727            ("card-b.css", ".b { color: blue; }"),
728        ]);
729        let result = build(default_options(app.path())).unwrap();
730
731        assert_eq!(result.css_files.len(), 1);
732        assert_eq!(result.css_files[0].0, "card-a.css");
733    }
734
735    #[test]
736    fn test_inspect_roundtrip_preserves_content() {
737        let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
738        let result = build(default_options(app.path())).unwrap();
739
740        let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
741        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
742
743        let fragments = &parsed["fragments"]["index.html"]["fragments"];
744        assert!(fragments.is_array());
745        assert!(!fragments.as_array().unwrap().is_empty());
746    }
747
748    #[test]
749    fn test_build_nonexistent_app_dir() {
750        let options = BuildOptions {
751            app_dir: PathBuf::from("/nonexistent/path/that/does/not/exist"),
752            entry: "index.html".to_string(),
753            css: CssStrategy::Link,
754            plugin: None,
755            components: Vec::new(),
756        };
757        let result = build(options);
758        assert!(result.is_err());
759    }
760
761    #[test]
762    fn test_build_to_disk_returns_accurate_stats() {
763        let app = create_app_dir(&[
764            ("index.html", "<my-card>Hello</my-card><p>{{name}}</p>"),
765            ("my-card.html", "<div><slot></slot></div>"),
766            ("my-card.css", ".card { color: red; }"),
767        ]);
768        let out = TempDir::new().unwrap();
769
770        let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
771
772        assert!(stats.fragment_count > 0);
773        assert_eq!(stats.css_file_count, 1);
774        assert!(stats.component_count > 0);
775        assert!(stats.protocol_size_bytes > 0);
776        assert!(!stats.duration.is_zero());
777    }
778}