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};
13//! use std::path::PathBuf;
14//!
15//! let result = build(BuildOptions {
16//!     app_dir: PathBuf::from("./src"),
17//!     ..BuildOptions::default()
18//! }).unwrap();
19//!
20//! println!("Built {} fragments in {:?}", result.stats.fragment_count, result.stats.duration);
21//! ```
22
23mod component_assets;
24mod error;
25mod module_preload;
26mod projection;
27pub mod server;
28pub mod streaming;
29
30pub use component_assets::{render_component_assets, ComponentAssetFile};
31pub use error::WebUIError;
32
33// Re-export core types from downstream crates
34pub use webui_handler::route_handler::{encode_inventory, get_needed_components, parse_inventory};
35pub use webui_handler::Result as HandlerResult;
36pub use webui_handler::{
37    plugin::HandlerPlugin, BoundaryId, BoundaryMode, FlushWriter, HandlerError, Protocol,
38    RenderOptions, ResponseWriter, StreamingResponse, WebUIHandler,
39};
40pub use webui_parser::plugin::{ComponentTemplateArtifact, StateSurface};
41pub use webui_parser::CssStrategy;
42pub use webui_parser::DomStrategy;
43pub use webui_parser::LegalComments;
44pub use webui_parser::ParserError;
45pub use webui_parser::ParserOptions;
46pub use webui_parser::Plugin;
47pub use webui_parser::DEFAULT_CSS_FILE_NAME_TEMPLATE;
48pub use webui_parser::{
49    AssetFileNameTemplate, AssetFileNameTemplateError, DEFAULT_ASSET_FILE_NAME_TEMPLATE,
50};
51pub use webui_parser::{CssFallbackChain, CssTokenAnalysis};
52pub use webui_parser::{Diagnostic, Severity};
53pub use webui_protocol::WebUIProtocol;
54pub use webui_tokens::{load_token_file, resolve_theme_path, TokenFile};
55
56use std::collections::HashSet;
57use std::ffi::{OsStr, OsString};
58use std::fs;
59use std::path::Path;
60use std::time::Instant;
61use webui_parser::plugin::fast_v2::FastV2ParserPlugin;
62use webui_parser::plugin::fast_v3::FastV3ParserPlugin;
63use webui_parser::plugin::webui::WebUIParserPlugin;
64use webui_parser::plugin::ParserPluginArtifacts;
65use webui_parser::HtmlParser;
66
67/// Projection metadata validated once for reuse across many protocol builds.
68#[derive(Debug, Clone)]
69pub struct PreparedProjectionManifests {
70    snapshot: std::sync::Arc<projection::ProjectionSnapshot>,
71}
72
73#[derive(Debug)]
74enum ProjectionBarrierState {
75    Waiting,
76    Ready(PreparedProjectionManifests),
77    Failed(String),
78}
79
80/// A reusable projection source that waits for an orchestrated client build.
81#[doc(hidden)]
82#[derive(Debug, Clone)]
83pub struct PendingProjectionManifests {
84    state: std::sync::Arc<(std::sync::Mutex<ProjectionBarrierState>, std::sync::Condvar)>,
85}
86
87/// Completes a pending projection source exactly once.
88#[doc(hidden)]
89pub struct ProjectionManifestCompleter {
90    state: std::sync::Arc<(std::sync::Mutex<ProjectionBarrierState>, std::sync::Condvar)>,
91    completed: bool,
92}
93
94/// One projection manifest supplied to a build.
95#[derive(Debug, Clone)]
96pub enum ProjectionManifestSource {
97    /// Read and validate a manifest from disk.
98    Path(std::path::PathBuf),
99    /// Validate an already parsed/transported manifest JSON object.
100    ///
101    /// `manifest_path` is its logical disk location and anchors the manifest's
102    /// relative `root` plus stale input/output checks.
103    Inline {
104        /// Logical path the manifest would occupy on disk.
105        manifest_path: std::path::PathBuf,
106        /// Canonical manifest JSON.
107        json: String,
108    },
109    /// Reuse metadata that has already passed schema, hash, merge, and stale
110    /// validation.
111    Prepared(PreparedProjectionManifests),
112    /// Wait for an orchestrator to validate the completed client bundle.
113    #[doc(hidden)]
114    Pending(PendingProjectionManifests),
115}
116
117impl From<std::path::PathBuf> for ProjectionManifestSource {
118    fn from(path: std::path::PathBuf) -> Self {
119        Self::Path(path)
120    }
121}
122
123/// Validate and merge projection sources once for repeated builds.
124///
125/// This is intended for orchestrators such as `webui-press` that build many
126/// page protocols against one completed client bundle.
127pub fn prepare_projection_manifests(
128    sources: &[ProjectionManifestSource],
129) -> Result<PreparedProjectionManifests, WebUIError> {
130    let snapshot = projection::load_and_merge(sources)?
131        .unwrap_or_else(|| std::sync::Arc::new(projection::ProjectionSnapshot::default()));
132    Ok(PreparedProjectionManifests { snapshot })
133}
134
135/// Resolve preload hints for compiler-generated entries using exact output
136/// identities and host-provided served URLs.
137///
138/// This is an internal orchestration hook for hosts such as `webui-press`.
139/// Resolution consumes the manifest's pre-sorted closures and performs no
140/// graph traversal or sorting.
141#[doc(hidden)]
142#[must_use]
143pub fn resolve_generated_module_preloads(
144    projection: &PreparedProjectionManifests,
145    existing_hrefs: &[String],
146    entry_outputs: &[&Path],
147    output_urls: &std::collections::BTreeMap<std::path::PathBuf, String>,
148) -> (Vec<String>, Vec<Diagnostic>) {
149    let mut resolved = module_preload::resolve_exact(
150        &projection.snapshot.entry_closures,
151        existing_hrefs,
152        entry_outputs,
153        output_urls,
154    );
155    (
156        std::mem::take(&mut resolved.hrefs),
157        std::mem::take(&mut resolved.warnings),
158    )
159}
160
161/// Return the exact physical outputs needed to map generated preload URLs.
162#[doc(hidden)]
163#[must_use]
164pub fn generated_module_preload_outputs(
165    projection: &PreparedProjectionManifests,
166    entry_outputs: &[&Path],
167) -> Vec<std::path::PathBuf> {
168    module_preload::exact_output_identities(&projection.snapshot.entry_closures, entry_outputs)
169}
170
171/// Create a pending projection source and its one-shot completer.
172#[doc(hidden)]
173#[must_use]
174pub fn projection_manifest_barrier() -> (ProjectionManifestSource, ProjectionManifestCompleter) {
175    let state = std::sync::Arc::new((
176        std::sync::Mutex::new(ProjectionBarrierState::Waiting),
177        std::sync::Condvar::new(),
178    ));
179    (
180        ProjectionManifestSource::Pending(PendingProjectionManifests {
181            state: std::sync::Arc::clone(&state),
182        }),
183        ProjectionManifestCompleter {
184            state,
185            completed: false,
186        },
187    )
188}
189
190impl PendingProjectionManifests {
191    fn wait(&self) -> Result<PreparedProjectionManifests, WebUIError> {
192        let (lock, ready) = self.state.as_ref();
193        let mut state = lock.lock().map_err(|_| {
194            WebUIError::InvalidBuildOptions("projection synchronization lock poisoned".to_string())
195        })?;
196        loop {
197            match &*state {
198                ProjectionBarrierState::Waiting => {
199                    state = ready.wait(state).map_err(|_| {
200                        WebUIError::InvalidBuildOptions(
201                            "projection synchronization lock poisoned".to_string(),
202                        )
203                    })?;
204                }
205                ProjectionBarrierState::Ready(projection) => {
206                    return Ok(projection.clone());
207                }
208                ProjectionBarrierState::Failed(message) => {
209                    return Err(WebUIError::InvalidBuildOptions(message.clone()));
210                }
211            }
212        }
213    }
214}
215
216impl ProjectionManifestCompleter {
217    /// Wake waiting builds with validated metadata or a producer failure.
218    pub fn complete(mut self, result: std::result::Result<PreparedProjectionManifests, String>) {
219        let (lock, ready) = self.state.as_ref();
220        if let Ok(mut state) = lock.lock() {
221            *state = match result {
222                Ok(projection) => ProjectionBarrierState::Ready(projection),
223                Err(message) => ProjectionBarrierState::Failed(message),
224            };
225            self.completed = true;
226            ready.notify_all();
227        }
228    }
229}
230
231impl Drop for ProjectionManifestCompleter {
232    fn drop(&mut self) {
233        if self.completed {
234            return;
235        }
236        let (lock, ready) = self.state.as_ref();
237        if let Ok(mut state) = lock.lock() {
238            *state = ProjectionBarrierState::Failed(
239                "projection producer terminated before completing metadata".to_string(),
240            );
241            ready.notify_all();
242        }
243    }
244}
245
246/// Options for building a WebUI application.
247#[derive(Debug, Clone)]
248pub struct BuildOptions {
249    /// Path to the application folder containing templates.
250    pub app_dir: std::path::PathBuf,
251    /// Entry HTML file name (e.g., `"index.html"`).
252    pub entry: String,
253    /// CSS delivery strategy for component stylesheets.
254    pub css: CssStrategy,
255    /// DOM strategy for component rendering (shadow or light).
256    pub dom: DomStrategy,
257    /// Framework plugin to load.
258    pub plugin: Option<Plugin>,
259    /// Additional component sources (npm packages or local paths).
260    pub components: Vec<String>,
261    /// Additional root components to compile for static component asset emission.
262    ///
263    /// These roots are parsed into the protocol so their templates, styles, and
264    /// dependency closures can be emitted later, but they are not connected to
265    /// the entry fragment and therefore are not rendered during initial SSR.
266    pub component_asset_roots: Vec<String>,
267    /// Emitted asset filename template using `[name]`, `[hash]`, and `[ext]`.
268    ///
269    /// Applies to Link-mode CSS files and static component assets.
270    pub css_file_name_template: String,
271    /// Optional URL/base-path prefix for Link-mode `css_href` values.
272    /// When set, emitted protocol hrefs become `<base>/<filename>`.
273    pub css_public_base: Option<String>,
274    /// Legal comment preservation strategy.
275    pub legal_comments: LegalComments,
276    /// Optional loaded design-token theme used to validate discovered CSS tokens.
277    ///
278    /// When present, every discovered CSS token that remains unresolved after
279    /// local and ancestor CSS definitions are removed must exist in every theme,
280    /// unless the `var()` usage supplies a literal CSS fallback (e.g.
281    /// `var(--x, 16px)`), which provides its own value. Missing required tokens
282    /// fail the build as parser diagnostics.
283    pub theme: Option<TokenFile>,
284    /// Bundler-neutral state projection manifest fragments supplied as disk
285    /// paths or inline JSON with a logical manifest path.
286    ///
287    /// Empty (the default) means projection is disabled: the build emits
288    /// [`webui_protocol::InitialStateStrategy::Full`] and every scripted
289    /// component keeps a full-state (`All`) partial-navigation surface. No
290    /// JavaScript/TypeScript analysis ever runs in Rust.
291    ///
292    /// When non-empty, every manifest is loaded, hash-validated against the
293    /// files it declares, merged by component tag, and used to narrow each
294    /// scripted component that is actually compiled into the protocol down to
295    /// an exact hydration/navigation key surface. Every such scripted
296    /// component must have exactly one manifest entry, or the build fails
297    /// with [`WebUIError::Projection`] (`PROJ-B001`). Supplying manifests with
298    /// a `plugin` other than [`Plugin::WebUI`] fails the build immediately
299    /// (`PROJ-B002`): only the WebUI plugin produces protocol fields
300    /// compatible with per-component key encoding.
301    ///
302    /// Manifests are read only at build time. The runtime handler never
303    /// opens a manifest file; the compiled protocol is fully self-contained.
304    pub projection_manifests: Vec<ProjectionManifestSource>,
305}
306
307impl Default for BuildOptions {
308    fn default() -> Self {
309        Self {
310            app_dir: std::path::PathBuf::from("."),
311            entry: "index.html".to_string(),
312            css: CssStrategy::Link,
313            dom: DomStrategy::Shadow,
314            plugin: None,
315            components: Vec::new(),
316            component_asset_roots: Vec::new(),
317            css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
318            css_public_base: None,
319            legal_comments: LegalComments::default(),
320            theme: None,
321            projection_manifests: Vec::new(),
322        }
323    }
324}
325
326/// Statistics about a completed build.
327#[derive(Debug, Clone)]
328pub struct BuildStats {
329    /// Total wall-clock build time.
330    pub duration: std::time::Duration,
331    /// Total number of protocol fragments.
332    pub fragment_count: usize,
333    /// Number of registered components.
334    pub component_count: usize,
335    /// Number of CSS files produced.
336    pub css_file_count: usize,
337    /// Size of the serialized protocol in bytes.
338    pub protocol_size_bytes: usize,
339    /// Number of unique CSS tokens discovered.
340    pub token_count: usize,
341}
342
343/// Result of a successful build.
344#[derive(Debug)]
345pub struct BuildResult {
346    /// The compiled WebUI protocol.
347    pub protocol: WebUIProtocol,
348    /// Serialized protocol bytes (protobuf binary).
349    pub protocol_bytes: Vec<u8>,
350    /// Component CSS files: `(filename, content)` — only components referenced in the protocol.
351    pub css_files: Vec<(String, String)>,
352    /// Static component asset files: `(filename, ESM module content)`.
353    ///
354    /// Populated when [`BuildOptions::component_asset_roots`] is non-empty.
355    pub component_asset_files: Vec<ComponentAssetFile>,
356    /// Component client template payloads.
357    /// Includes templates for all components encountered during parsing,
358    /// including route-referenced components.
359    pub component_templates: Vec<ComponentTemplateArtifact>,
360    /// Non-fatal build advisories as warning-severity [`Diagnostic`]s.
361    ///
362    /// Currently surfaces CSS tokens that are referenced only with a literal
363    /// `var()` fallback and defined in no theme — often typos. Empty when no
364    /// theme is supplied. Carries the same structured location/snippet/`help:`
365    /// data as errors; the entry point decides how to present (and color) them.
366    pub warnings: Vec<Diagnostic>,
367    /// Build statistics.
368    pub stats: BuildStats,
369}
370
371/// Advisory for a streaming entry compiled without a state-projection
372/// manifest.
373///
374/// Cold: constructed at most once per build, and only for streaming entries.
375#[cold]
376#[inline(never)]
377fn streaming_without_projection_warning(entry: &str, boundary_count: usize) -> Diagnostic {
378    let plural = if boundary_count == 1 { "" } else { "s" };
379    Diagnostic::warning(format!(
380        "{entry} declares {boundary_count} streaming boundary checkpoint{plural} \
381         but this build has no state-projection manifest"
382    ))
383    .code(webui_parser::codes::STREAMING_WITHOUT_PROJECTION)
384    .help(
385        "every checkpoint will serialize the whole state object instead of its own \
386         components' keys; pass the client build's `webui-projection.json` through \
387         `BuildOptions::projection_manifests` to send boundary-local state only",
388    )
389}
390
391/// Build a WebUI application from an app directory.
392///
393/// Parses templates, discovers components, and produces a compiled protocol
394/// with build statistics.
395///
396/// # Errors
397///
398/// Returns [`WebUIError`] if the app directory is invalid, templates fail
399/// to parse, theme tokens are incomplete, or the protocol cannot be serialized.
400#[must_use = "BuildResult contains the compiled protocol and statistics"]
401pub fn build(options: BuildOptions) -> Result<BuildResult, WebUIError> {
402    let started = Instant::now();
403
404    let raw = build_protocol_inner(&options)?;
405
406    let protocol_bytes = raw.protocol.to_protobuf()?;
407
408    let stats = BuildStats {
409        duration: started.elapsed(),
410        fragment_count: raw.fragment_count,
411        component_count: raw.component_count,
412        css_file_count: raw.css_files.len(),
413        protocol_size_bytes: protocol_bytes.len(),
414        token_count: raw.token_count,
415    };
416
417    Ok(BuildResult {
418        protocol: raw.protocol,
419        protocol_bytes,
420        css_files: raw.css_files,
421        component_asset_files: raw.component_asset_files,
422        component_templates: raw.component_templates,
423        warnings: raw.warnings,
424        stats,
425    })
426}
427
428/// Build a WebUI application and write output files to disk.
429///
430/// Writes `protocol.bin`, any external CSS files, and static component assets
431/// to `out_dir`.
432/// Creates `out_dir` if it does not exist.
433///
434/// # Errors
435///
436/// Returns [`WebUIError`] on build failure or if output files cannot be written.
437pub fn build_to_disk(options: BuildOptions, out_dir: &Path) -> Result<BuildStats, WebUIError> {
438    let result = build(options)?;
439    validate_output_file_names(OsStr::new("protocol.bin"), &result)?;
440
441    fs::create_dir_all(out_dir).map_err(|source| WebUIError::Io {
442        context: format!("Failed to create {}", out_dir.display()),
443        source,
444    })?;
445
446    fs::write(out_dir.join("protocol.bin"), &result.protocol_bytes).map_err(|source| {
447        WebUIError::Io {
448            context: format!("Failed to write protocol.bin to {}", out_dir.display()),
449            source,
450        }
451    })?;
452
453    for (name, content) in &result.css_files {
454        fs::write(out_dir.join(name), content).map_err(|source| WebUIError::Io {
455            context: format!("Failed to write {name} to {}", out_dir.display()),
456            source,
457        })?;
458    }
459    for file in &result.component_asset_files {
460        fs::write(out_dir.join(&file.name), &file.content).map_err(|source| WebUIError::Io {
461            context: format!(
462                "Failed to write component asset {} to {}",
463                file.name,
464                out_dir.display()
465            ),
466            source,
467        })?;
468    }
469
470    Ok(result.stats)
471}
472
473/// Inspect a compiled WebUI protocol file and return its JSON representation.
474pub fn inspect(protocol_path: &Path) -> Result<String, WebUIError> {
475    let bytes = fs::read(protocol_path).map_err(|source| WebUIError::Io {
476        context: format!("Failed to read {}", protocol_path.display()),
477        source,
478    })?;
479    inspect_bytes(&bytes)
480}
481
482/// Inspect raw protocol bytes and return their JSON representation.
483pub fn inspect_bytes(protocol_bytes: &[u8]) -> Result<String, WebUIError> {
484    let protocol = WebUIProtocol::from_protobuf(protocol_bytes)?;
485    protocol
486        .to_json_pretty()
487        .map_err(|e| WebUIError::Serialization(e.to_string()))
488}
489
490/// Internal intermediate build output before stats are computed.
491struct RawBuildOutput {
492    protocol: WebUIProtocol,
493    css_files: Vec<(String, String)>,
494    component_asset_files: Vec<ComponentAssetFile>,
495    component_templates: Vec<ComponentTemplateArtifact>,
496    warnings: Vec<Diagnostic>,
497    fragment_count: usize,
498    component_count: usize,
499    token_count: usize,
500}
501
502/// Internal build logic shared by `build()` and `build_to_disk()`.
503fn build_protocol_inner(options: &BuildOptions) -> Result<RawBuildOutput, WebUIError> {
504    let projection_enabled = !options.projection_manifests.is_empty();
505    if projection_enabled && options.plugin != Some(Plugin::WebUI) {
506        return Err(projection::incompatible_plugin_error());
507    }
508    let parser_options = ParserOptions::try_new(
509        options.css,
510        options.dom,
511        &options.css_file_name_template,
512        options.css_public_base.as_deref(),
513        options.legal_comments,
514    )
515    .map_err(|e| WebUIError::InvalidBuildOptions(e.to_string()))?;
516    let css_link_options = parser_options.css_link_options.clone();
517
518    let mut parser = match options.plugin {
519        Some(Plugin::Fast | Plugin::FastV2) => {
520            HtmlParser::with_plugin_options(Box::new(FastV2ParserPlugin::new()), parser_options)
521        }
522        Some(Plugin::FastV3) => {
523            HtmlParser::with_plugin_options(Box::new(FastV3ParserPlugin::new()), parser_options)
524        }
525        Some(Plugin::WebUI) => {
526            HtmlParser::with_plugin_options(Box::new(WebUIParserPlugin::new()), parser_options)
527        }
528        None => HtmlParser::with_options(parser_options),
529    };
530
531    // Register app directory components
532    parser
533        .component_registry_mut()
534        .register_from_paths(&[&options.app_dir])
535        .map_err(|e| {
536            WebUIError::ComponentRegistration(format!(
537                "Failed to register components from {}: {e}",
538                options.app_dir.display()
539            ))
540        })?;
541
542    // Discover and register external component sources
543    for source in &options.components {
544        let result = webui_discovery::discover_source(source, &options.app_dir).map_err(|e| {
545            WebUIError::ComponentDiscovery(format!(
546                "Failed to discover components from {source}: {e}"
547            ))
548        })?;
549        for comp in &result.components {
550            // Script presence marks client ownership. Rust never analyzes the
551            // source; exact state surfaces come from projection manifests.
552            parser
553                .component_registry_mut()
554                .register_component(webui_parser::ComponentRegistration {
555                    tag_name: &comp.tag_name,
556                    html_content: &comp.html_content,
557                    css_content: comp.css_content.as_deref(),
558                    is_client_owned: comp.is_client_owned,
559                })
560                .map_err(|e| {
561                    WebUIError::ComponentRegistration(format!(
562                        "Failed to register component '{}' from {}: {e}",
563                        comp.tag_name, comp.source
564                    ))
565                })?;
566        }
567    }
568
569    let component_count = parser.component_registry().len();
570
571    // Parse entry HTML
572    let entry_path = options.app_dir.join(&options.entry);
573    let html_content = fs::read_to_string(&entry_path).map_err(|source| WebUIError::Io {
574        context: format!("Failed to read {}", entry_path.display()),
575        source,
576    })?;
577    parser
578        .parse(&options.entry, &html_content)
579        .map_err(|source| WebUIError::Parse {
580            context: format!("Failed to parse {}", options.entry),
581            source,
582        })?;
583
584    // Read before anything else parses. Both accessors describe the *entry*
585    // template, and every top-level `parse()` starts a fresh namespace, so a
586    // later synthetic parse (asset roots, below) would silently zero them.
587    // They are also copied out because `token_analysis()` borrows the parser
588    // and the parser is consumed before the projection manifest is known.
589    let boundary_count = parser.boundary_count();
590    let boundary_names = parser.boundary_names().to_vec();
591    let module_entry_srcs = parser.module_entry_srcs().to_vec();
592
593    let synthetic_asset_fragments =
594        parse_component_asset_roots(&mut parser, &options.component_asset_roots)?;
595
596    let css_snapshot: Vec<(String, String)> = parser
597        .component_registry()
598        .get_all()
599        .filter(|component| parser.has_fragment(&component.tag_name))
600        .filter_map(|component| {
601            component
602                .css_content
603                .as_ref()
604                .map(|css| (component.tag_name.clone(), css.clone()))
605        })
606        .collect();
607
608    // Collect CSS token analysis before consuming the parser.
609    let token_analysis = parser.token_analysis();
610    let mut warnings: Vec<Diagnostic> = Vec::new();
611    if let Some(theme) = options.theme.as_ref() {
612        token_analysis
613            .validate_theme_tokens(theme)
614            .map_err(|source| WebUIError::Parse {
615                context: "Failed to validate theme tokens".to_string(),
616                source,
617            })?;
618        // Advisory: a token used only as `var(--x, <literal>)` and absent from
619        // every theme is often a misspelling. The literal keeps the build green,
620        // so this is a warning (with a "did you mean …?" suggestion), not an
621        // error.
622        warnings = token_analysis.theme_token_warnings(theme);
623    }
624    let token_count = token_analysis.protocol_tokens.len();
625
626    let component_templates =
627        match parser
628            .take_plugin_artifacts()
629            .map_err(|source| WebUIError::Parse {
630                context: format!(
631                    "Failed to compile component templates for {}",
632                    options.entry
633                ),
634                source,
635            })? {
636            ParserPluginArtifacts::None => Vec::new(),
637            ParserPluginArtifacts::ComponentTemplates(templates) => templates,
638        };
639    // Build protocol (consumes parser)
640    let mut fragment_records = parser.into_fragment_records();
641    for fragment_id in synthetic_asset_fragments {
642        fragment_records.remove(&fragment_id);
643    }
644    let fragment_count: usize = fragment_records.values().map(|v| v.fragments.len()).sum();
645
646    // Resolve projection only after template compilation. Ordinary path/inline
647    // builds pay the same validation cost, while orchestrators can overlap
648    // parser work with an in-flight client bundle through a pending source.
649    let merged_manifest = projection::load_and_merge(&options.projection_manifests)?;
650    let mut protocol = WebUIProtocol::with_tokens(fragment_records, token_analysis.protocol_tokens);
651    if !boundary_names.is_empty() {
652        protocol.streaming_boundaries.insert(
653            options.entry.clone(),
654            webui_protocol::StreamingBoundaryList {
655                names: boundary_names,
656            },
657        );
658    }
659    protocol.initial_state_strategy = if merged_manifest.is_some() {
660        webui_protocol::InitialStateStrategy::Components as i32
661    } else {
662        webui_protocol::InitialStateStrategy::Full as i32
663    };
664
665    // Advisory: without a projection manifest every streaming checkpoint falls
666    // back to serializing the entire state object, so the cost is
667    // O(boundaries x full state) rather than O(boundaries x that boundary's
668    // own keys). The build still works, so this is a warning.
669    if boundary_count > 0 && merged_manifest.is_none() {
670        warnings.push(streaming_without_projection_warning(
671            &options.entry,
672            boundary_count,
673        ));
674    }
675
676    // Resolve `modulepreload` hints once, at build time. The shared chunks an
677    // entry statically imports are named only inside that entry's bytes, so
678    // the preload scanner cannot find them; hinting them removes a serialized
679    // round trip. The handler writes the result verbatim.
680    if let Some(merged) = &merged_manifest {
681        let mut preloads = module_preload::resolve(&merged.entry_closures, &module_entry_srcs);
682        protocol.module_preloads = std::mem::take(&mut preloads.hrefs);
683        warnings.append(&mut preloads.warnings);
684    }
685
686    // Strict coverage applies only to scripted components that actually made
687    // it into the compiled protocol/route closure (i.e. their fragment was
688    // emitted). Scripted components discovered but never compiled into a
689    // route/fragment do not require manifest coverage.
690    if let Some(merged) = &merged_manifest {
691        let compiled_scripted_tags: Vec<&str> = component_templates
692            .iter()
693            .filter(|artifact| {
694                artifact.is_scripted && protocol.fragments.contains_key(&artifact.tag_name)
695            })
696            .map(|artifact| artifact.tag_name.as_str())
697            .collect();
698        projection::validate_coverage(&merged.components, &compiled_scripted_tags)?;
699    }
700
701    // Record build-wide strategies so the handler can decide rendering behavior.
702    protocol.set_css_strategy(match options.css {
703        CssStrategy::Link => webui_protocol::CssStrategy::Link,
704        CssStrategy::Style => webui_protocol::CssStrategy::Style,
705        CssStrategy::Module => webui_protocol::CssStrategy::Module,
706    });
707    protocol.set_dom_strategy(match options.dom {
708        DomStrategy::Shadow => webui_protocol::DomStrategy::Shadow,
709        DomStrategy::Light => webui_protocol::DomStrategy::Light,
710    });
711
712    // Process component CSS in a single pass: store Module CSS content,
713    // set Link-strategy css_href, and collect external CSS files.
714    let is_module = options.css == CssStrategy::Module;
715    let is_link = options.css == CssStrategy::Link;
716    let mut css_files: Vec<(String, String)> = Vec::new();
717    let mut emitted_names: HashSet<String> = HashSet::new();
718    for (tag, css) in css_snapshot {
719        if !protocol.fragments.contains_key(&tag) {
720            continue;
721        }
722        if is_module {
723            protocol.components.entry(tag).or_default().css = css.trim().to_string();
724        } else if is_link {
725            let resolved = css_link_options.resolve(&tag, &css);
726            if !emitted_names.insert(resolved.filename.clone()) {
727                return Err(WebUIError::InvalidBuildOptions(format!(
728                    "CSS filename collision for Link strategy: '{}'. Adjust the asset filename template to include [name] or another unique component-specific segment.",
729                    resolved.filename
730                )));
731            }
732            protocol.components.entry(tag).or_default().css_href = resolved.href;
733            css_files.push((resolved.filename, css));
734        }
735        // Style strategy: CSS is already baked into raw fragments by the
736        // parser — nothing to store in the protocol or emit as files.
737    }
738
739    // Store compiled client templates in the protocol so any host server can
740    // query them. Scriptless templates retain navigation metadata but contribute
741    // no initial hydration state.
742    for artifact in &component_templates {
743        let component = protocol
744            .components
745            .entry(artifact.tag_name.clone())
746            .or_default();
747        component.template = artifact.template.clone();
748        component.template_json = artifact.template_json.clone();
749        component.template_functions = artifact.template_functions.clone();
750        // Manifest keys are the initial hydration surface. Navigation is the
751        // union of manifest client keys and Rust-compiled template roots.
752        // Scriptless components are never governed by a manifest: they keep
753        // their Rust-derived surface regardless of projection.
754        let manifest_entry = if artifact.is_scripted {
755            merged_manifest
756                .as_ref()
757                .and_then(|merged| merged.components.get(&artifact.tag_name))
758        } else {
759            None
760        };
761        let (hydration_mode, hydration_keys) = match manifest_entry {
762            Some(entry) => encode_state_surface(&StateSurface::Keys(entry.hydration_keys.clone())),
763            None => encode_state_surface(&artifact.hydration),
764        };
765        component.hydration_mode = hydration_mode;
766        component.hydration_keys = hydration_keys;
767        let (navigation_mode, navigation_keys) = match manifest_entry {
768            Some(entry) => {
769                let union =
770                    projection::union_keys(&entry.navigation_keys, &artifact.template_roots);
771                encode_state_surface(&StateSurface::Keys(union))
772            }
773            None => encode_state_surface(&artifact.navigation),
774        };
775        component.navigation_mode = Some(navigation_mode);
776        component.navigation_keys = navigation_keys;
777    }
778
779    let component_asset_files = component_assets::render_component_assets(
780        &protocol,
781        &options.component_asset_roots,
782        &options.css_file_name_template,
783    )?;
784    validate_generated_file_names(&css_files, &component_asset_files)?;
785
786    Ok(RawBuildOutput {
787        protocol,
788        css_files,
789        component_asset_files,
790        component_templates,
791        warnings,
792        fragment_count,
793        component_count,
794        token_count,
795    })
796}
797
798fn encode_state_surface(surface: &StateSurface) -> (i32, Vec<String>) {
799    match surface {
800        StateSurface::None => (webui_protocol::StateProjectionMode::None as i32, Vec::new()),
801        StateSurface::Keys(keys) => (
802            webui_protocol::StateProjectionMode::Keys as i32,
803            keys.clone(),
804        ),
805        StateSurface::All => (webui_protocol::StateProjectionMode::All as i32, Vec::new()),
806    }
807}
808
809fn parse_component_asset_roots(
810    parser: &mut HtmlParser,
811    roots: &[String],
812) -> Result<Vec<String>, WebUIError> {
813    let mut synthetic_fragments = Vec::with_capacity(roots.len());
814    for (index, root) in roots.iter().enumerate() {
815        let root = root.trim();
816        if parser.has_fragment(root) {
817            continue;
818        }
819        // Compile the requested lazy root without connecting it to the entry
820        // graph, so it can be emitted as a static asset but stays out of SSR.
821        let mut synthetic = String::with_capacity(root.len() * 2 + 5);
822        synthetic.push('<');
823        synthetic.push_str(root);
824        synthetic.push_str("></");
825        synthetic.push_str(root);
826        synthetic.push('>');
827
828        let fragment_id = format!("__webui_asset_root_{index}");
829        parser
830            .parse(&fragment_id, &synthetic)
831            .map_err(|source| WebUIError::Parse {
832                context: format!("Failed to parse component asset root <{root}>"),
833                source,
834            })?;
835        synthetic_fragments.push(fragment_id);
836    }
837    Ok(synthetic_fragments)
838}
839
840fn validate_generated_file_names(
841    css_files: &[(String, String)],
842    component_asset_files: &[ComponentAssetFile],
843) -> Result<(), WebUIError> {
844    let mut names = HashSet::with_capacity(css_files.len() + component_asset_files.len());
845    for (name, _) in css_files {
846        names.insert(name.as_str());
847    }
848    for file in component_asset_files {
849        if !names.insert(file.name.as_str()) {
850            return Err(WebUIError::InvalidBuildOptions(format!(
851                "output filename collision for '{}'. Adjust the asset filename template to include [ext] or another unique asset-type segment.",
852                file.name
853            )));
854        }
855    }
856    Ok(())
857}
858
859fn validate_output_file_names(
860    protocol_name: &OsStr,
861    result: &BuildResult,
862) -> Result<(), WebUIError> {
863    let mut names =
864        HashSet::with_capacity(1 + result.css_files.len() + result.component_asset_files.len());
865    names.insert(protocol_name.to_os_string());
866    for (name, _) in &result.css_files {
867        let file_name = OsString::from(name);
868        if !names.insert(file_name.clone()) {
869            return Err(output_file_collision_error(&file_name));
870        }
871    }
872    for file in &result.component_asset_files {
873        let file_name = OsString::from(&file.name);
874        if !names.insert(file_name.clone()) {
875            return Err(output_file_collision_error(&file_name));
876        }
877    }
878    Ok(())
879}
880
881fn output_file_collision_error(name: &OsStr) -> WebUIError {
882    WebUIError::InvalidBuildOptions(format!(
883        "output filename collision for '{}'. Adjust the asset filename template to include [ext] or another unique asset-type segment.",
884        name.to_string_lossy()
885    ))
886}
887
888#[cfg(test)]
889#[allow(clippy::disallowed_methods)]
890mod tests {
891    use super::*;
892    use std::collections::HashMap;
893    use std::path::PathBuf;
894    use tempfile::TempDir;
895    use webui_handler::plugin::webui::WebUIHydrationPlugin;
896    use webui_protocol::web_ui_fragment::Fragment;
897
898    struct StringWriter {
899        buf: String,
900    }
901
902    impl ResponseWriter for StringWriter {
903        fn write(&mut self, content: &str) -> webui_handler::Result<()> {
904            self.buf.push_str(content);
905            Ok(())
906        }
907
908        fn end(&mut self) -> webui_handler::Result<()> {
909            Ok(())
910        }
911    }
912
913    fn create_app_dir(files: &[(&str, &str)]) -> TempDir {
914        let dir = TempDir::new().unwrap();
915        for (name, content) in files {
916            let path = dir.path().join(name);
917            if let Some(parent) = path.parent() {
918                fs::create_dir_all(parent).unwrap();
919            }
920            fs::write(&path, content).unwrap();
921        }
922        dir
923    }
924
925    fn default_options(app_dir: &Path) -> BuildOptions {
926        BuildOptions {
927            app_dir: app_dir.to_path_buf(),
928            ..BuildOptions::default()
929        }
930    }
931
932    #[test]
933    fn test_build_simple_html() {
934        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
935        let result = build(default_options(app.path())).unwrap();
936
937        assert!(result.protocol.fragments.contains_key("index.html"));
938        assert!(result.stats.fragment_count > 0);
939        assert!(result.stats.protocol_size_bytes > 0);
940        assert!(!result.stats.duration.is_zero());
941        assert_eq!(
942            result.protocol.initial_state_strategy,
943            webui_protocol::InitialStateStrategy::Full as i32
944        );
945    }
946
947    #[test]
948    fn pending_projection_unblocks_after_template_compilation() {
949        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
950        let (source, completer) = projection_manifest_barrier();
951        let options = BuildOptions {
952            app_dir: app.path().to_path_buf(),
953            plugin: Some(Plugin::WebUI),
954            projection_manifests: vec![source],
955            ..BuildOptions::default()
956        };
957        let handle = std::thread::spawn(move || build(options));
958        let prepared = prepare_projection_manifests(&[]).unwrap();
959        completer.complete(Ok(prepared));
960        let result = handle.join().unwrap().unwrap();
961        assert_eq!(
962            result.protocol.initial_state_strategy,
963            webui_protocol::InitialStateStrategy::Components as i32
964        );
965    }
966
967    /// Streaming entries pay O(boundaries x full state) without a projection
968    /// manifest, so the build must say so.
969    #[test]
970    fn streaming_entry_without_projection_manifest_warns() {
971        let app = create_app_dir(&[(
972            "index.html",
973            concat!(
974                "<html><body>",
975                r#"<boundary name="a"><p>{{one}}</p></boundary>"#,
976                r#"<boundary name="b"><p>{{two}}</p></boundary>"#,
977                "</body></html>",
978            ),
979        )]);
980        let mut options = default_options(app.path());
981        options.plugin = Some(Plugin::WebUI);
982
983        let result = build(options).unwrap();
984
985        assert_eq!(
986            result.protocol.initial_state_strategy,
987            webui_protocol::InitialStateStrategy::Full as i32
988        );
989        assert_eq!(
990            result.protocol.streaming_boundaries["index.html"].names,
991            ["a", "b"]
992        );
993        let decoded = WebUIProtocol::from_protobuf(&result.protocol_bytes).unwrap();
994        assert_eq!(decoded.streaming_boundaries["index.html"].names, ["a", "b"]);
995        let warning = result
996            .warnings
997            .iter()
998            .find(|diag| {
999                diag.error_code() == Some(webui_parser::codes::STREAMING_WITHOUT_PROJECTION)
1000            })
1001            .expect("a streaming build without projection must warn");
1002        assert_eq!(warning.severity(), webui_parser::Severity::Warning);
1003        assert!(
1004            warning.to_string().contains("2 streaming boundary"),
1005            "warning should report the boundary count: {warning}"
1006        );
1007        assert!(
1008            warning
1009                .help_text()
1010                .is_some_and(|help| help.contains("projection_manifests")),
1011            "warning should point at the fix: {warning}"
1012        );
1013    }
1014
1015    #[test]
1016    fn streaming_entry_with_projection_manifest_does_not_warn() {
1017        let app = create_app_dir(&[(
1018            "index.html",
1019            concat!(
1020                "<html><body>",
1021                r#"<boundary name="a"><p>{{one}}</p></boundary>"#,
1022                "</body></html>",
1023            ),
1024        )]);
1025        let manifest_json =
1026            projection::test_support::build_valid_manifest_json(app.path(), &[], &[], &[]);
1027        let manifest = projection::test_support::write_manifest(
1028            app.path(),
1029            "webui-projection.json",
1030            &manifest_json,
1031        );
1032        let mut options = default_options(app.path());
1033        options.plugin = Some(Plugin::WebUI);
1034        options.projection_manifests = vec![manifest.into()];
1035
1036        let result = build(options).unwrap();
1037
1038        assert_eq!(
1039            result.protocol.initial_state_strategy,
1040            webui_protocol::InitialStateStrategy::Components as i32
1041        );
1042        assert!(
1043            !result
1044                .warnings
1045                .iter()
1046                .any(|diag| diag.error_code()
1047                    == Some(webui_parser::codes::STREAMING_WITHOUT_PROJECTION)),
1048            "a projected streaming build must not warn"
1049        );
1050    }
1051
1052    /// The advisory is streaming-specific: ordinary entries emit one bootstrap
1053    /// block, so full state there is not a per-checkpoint multiplier.
1054    #[test]
1055    fn non_streaming_entry_without_projection_manifest_does_not_warn() {
1056        let app = create_app_dir(&[("index.html", "<html><body><p>{{one}}</p></body></html>")]);
1057        let mut options = default_options(app.path());
1058        options.plugin = Some(Plugin::WebUI);
1059
1060        let result = build(options).unwrap();
1061
1062        assert!(
1063            !result
1064                .warnings
1065                .iter()
1066                .any(|diag| diag.error_code()
1067                    == Some(webui_parser::codes::STREAMING_WITHOUT_PROJECTION)),
1068            "a non-streaming build must not warn about projection"
1069        );
1070    }
1071
1072    #[test]
1073    fn test_build_keeps_scriptless_component_dormant_until_client_use() {
1074        let app = create_app_dir(&[
1075            (
1076                "index.html",
1077                "<html><body><demo-card></demo-card></body></html>",
1078            ),
1079            ("demo-card.html", "<p>{{name}} {{serverTitle}}</p>"),
1080        ]);
1081        // An empty manifest fragment (no scripted components exist in this
1082        // app, so strict coverage trivially passes) still turns on
1083        // `InitialStateStrategy::Components`, letting scriptless components'
1084        // Rust-derived dormant surface (`None` hydration) take effect.
1085        let manifest_json =
1086            projection::test_support::build_valid_manifest_json(app.path(), &[], &[], &[]);
1087        let manifest = projection::test_support::write_manifest(
1088            app.path(),
1089            "webui-projection.json",
1090            &manifest_json,
1091        );
1092        let mut options = default_options(app.path());
1093        options.plugin = Some(Plugin::WebUI);
1094        options.projection_manifests = vec![manifest.into()];
1095        let result = build(options).unwrap();
1096        assert_eq!(
1097            result.protocol.initial_state_strategy,
1098            webui_protocol::InitialStateStrategy::Components as i32
1099        );
1100
1101        assert!(result
1102            .component_templates
1103            .iter()
1104            .any(|template| template.tag_name == "demo-card"));
1105        let component = result
1106            .protocol
1107            .components
1108            .get("demo-card")
1109            .expect("scriptless component metadata should be retained");
1110        assert!(component.hydration_keys.is_empty());
1111        assert_eq!(component.navigation_keys, ["name", "serverTitle"]);
1112        assert!(component.template_json.contains(r#""th":1"#));
1113
1114        let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
1115        let state = serde_json::json!({ "name": "Server rendered" });
1116        let protocol = Protocol::new(result.protocol.clone());
1117        let mut writer = StringWriter { buf: String::new() };
1118        handler
1119            .render(
1120                &protocol,
1121                &state,
1122                &RenderOptions::new("index.html", "/"),
1123                &mut writer,
1124            )
1125            .unwrap();
1126        assert!(writer.buf.contains("Server rendered"));
1127        assert!(writer.buf.contains(r#""state":{}"#));
1128        assert!(writer.buf.contains(r#""templates":{"demo-card":"#));
1129    }
1130
1131    #[test]
1132    fn test_build_treats_sibling_component_script_as_scripted() {
1133        let app = create_app_dir(&[
1134            ("index.html", "<demo-card></demo-card>"),
1135            (
1136                "demo-card.html",
1137                r#"<button @click="{onClick()}">{{name}}</button>"#,
1138            ),
1139            (
1140                "demo-card.ts",
1141                "import { WebUIElement } from '@microsoft/webui-framework';\n\
1142                 class DemoCard extends WebUIElement { onClick() {} }\n\
1143                 DemoCard.define('demo-card');",
1144            ),
1145        ]);
1146        let mut options = default_options(app.path());
1147        options.plugin = Some(Plugin::WebUI);
1148        let result = build(options).unwrap();
1149
1150        let template = result
1151            .component_templates
1152            .iter()
1153            .find(|template| template.tag_name == "demo-card")
1154            .unwrap();
1155        assert!(template.template_json.contains(r#""eg":[["click""#));
1156    }
1157
1158    #[test]
1159    fn test_build_populates_component_hydration_keys_from_script() {
1160        let app = create_app_dir(&[
1161            ("index.html", "<demo-card></demo-card>"),
1162            ("demo-card.html", "<p>{{name}}</p>"),
1163            (
1164                "demo-card.ts",
1165                "import { WebUIElement, attr, observable } from '@microsoft/webui-framework';\n\
1166                 class DemoCard extends WebUIElement {\n\
1167                 @observable name = '';\n\
1168                 @attr({ attribute: 'cta-href' }) ctaHref = '/x';\n\
1169                 @observable count = 0;\n\
1170                 }\n\
1171                 DemoCard.define('demo-card');",
1172            ),
1173        ]);
1174        // Rust performs no JavaScript/TypeScript analysis: the exact keys
1175        // come from a bundler-produced projection manifest, not from
1176        // scanning `demo-card.ts`.
1177        let manifest_json = projection::test_support::build_valid_manifest_json(
1178            app.path(),
1179            &[("demo-card.ts", "source")],
1180            &[("demo-card.js", "output")],
1181            &[(
1182                "demo-card",
1183                "demo-card.ts",
1184                &["demo-card.js"],
1185                &["count", "ctaHref", "name"],
1186                &["count", "ctaHref", "name"],
1187            )],
1188        );
1189        let manifest = projection::test_support::write_manifest(
1190            app.path(),
1191            "webui-projection.json",
1192            &manifest_json,
1193        );
1194        let mut options = default_options(app.path());
1195        options.plugin = Some(Plugin::WebUI);
1196        options.projection_manifests = vec![manifest.into()];
1197        let result = build(options).unwrap();
1198
1199        assert_eq!(
1200            result.protocol.initial_state_strategy,
1201            webui_protocol::InitialStateStrategy::Components as i32
1202        );
1203        let component = result.protocol.components.get("demo-card").unwrap();
1204        assert_eq!(
1205            component.hydration_mode,
1206            webui_protocol::StateProjectionMode::Keys as i32
1207        );
1208        assert_eq!(component.hydration_keys, vec!["count", "ctaHref", "name"]);
1209        assert_eq!(
1210            component.navigation_mode,
1211            Some(webui_protocol::StateProjectionMode::Keys as i32)
1212        );
1213        assert_eq!(component.navigation_keys, vec!["count", "ctaHref", "name"]);
1214    }
1215
1216    #[test]
1217    fn test_build_scripted_component_without_manifest_falls_back_to_full_state() {
1218        let app = create_app_dir(&[
1219            (
1220                "index.html",
1221                "<html><body><demo-card></demo-card></body></html>",
1222            ),
1223            ("demo-card.html", "<p>{{name}}</p>"),
1224            (
1225                "demo-card.ts",
1226                "import { WebUIElement, observable } from '@microsoft/webui-framework';\n\
1227                 class DemoCard extends WebUIElement {\n\
1228                 @observable name = '';\n\
1229                 }\n\
1230                 DemoCard.define('demo-card');",
1231            ),
1232        ]);
1233        let mut options = default_options(app.path());
1234        options.plugin = Some(Plugin::WebUI);
1235        // No `projection_manifests` supplied: without a manifest, Rust never
1236        // analyzes `demo-card.ts` and must assume the full, unproven surface.
1237        let result = build(options).unwrap();
1238
1239        assert_eq!(
1240            result.protocol.initial_state_strategy,
1241            webui_protocol::InitialStateStrategy::Full as i32
1242        );
1243        let component = result.protocol.components.get("demo-card").unwrap();
1244        assert_eq!(
1245            component.hydration_mode,
1246            webui_protocol::StateProjectionMode::All as i32
1247        );
1248        assert!(component.hydration_keys.is_empty());
1249        assert_eq!(
1250            component.navigation_mode,
1251            Some(webui_protocol::StateProjectionMode::All as i32)
1252        );
1253
1254        let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
1255        let state = serde_json::json!({ "name": "Visible" });
1256        let protocol = Protocol::new(result.protocol.clone());
1257        let mut writer = StringWriter { buf: String::new() };
1258        handler
1259            .render(
1260                &protocol,
1261                &state,
1262                &RenderOptions::new("index.html", "/"),
1263                &mut writer,
1264            )
1265            .unwrap();
1266        assert!(writer.buf.contains("Visible"));
1267        // Full-state fallback serializes the entire supplied state object,
1268        // not a narrowed key subset.
1269        assert!(writer.buf.contains(r#""state":{"name":"Visible"}"#));
1270    }
1271
1272    #[test]
1273    fn test_build_rejects_scriptless_event_component() {
1274        let app = create_app_dir(&[
1275            ("index.html", "<demo-card></demo-card>"),
1276            (
1277                "demo-card.html",
1278                r#"<button @click="{onClick()}">Click</button>"#,
1279            ),
1280        ]);
1281        let mut options = default_options(app.path());
1282        options.plugin = Some(Plugin::WebUI);
1283        let err = build(options).expect_err("scriptless events should fail");
1284
1285        let message = err.chain_message();
1286        assert!(
1287            message.contains("scriptless-event-handler"),
1288            "msg: {message}"
1289        );
1290    }
1291
1292    #[test]
1293    fn test_build_returns_actionable_error_for_invalid_w_ref() {
1294        let app = create_app_dir(&[
1295            ("index.html", "<my-card>Hello</my-card>"),
1296            ("my-card.html", r#"<div><input w-ref="myInput" /></div>"#),
1297        ]);
1298        let mut options = default_options(app.path());
1299        options.plugin = Some(Plugin::WebUI);
1300
1301        // Authoring mistakes are returned (not panicked) so every consumer —
1302        // CLI, Node, FFI, WASM — can surface the message its own way.
1303        let Err(err) = build(options) else {
1304            panic!("non-braced w-ref must fail the build");
1305        };
1306        let msg = err.chain_message();
1307        assert!(msg.contains("invalid w-ref binding"), "msg: {msg}");
1308        assert!(
1309            msg.contains("component <my-card> · element <input>"),
1310            "msg: {msg}"
1311        );
1312        assert!(msg.contains("help:"), "msg: {msg}");
1313    }
1314
1315    #[test]
1316    fn test_build_returns_actionable_error_for_invalid_event_handler() {
1317        let app = create_app_dir(&[
1318            ("index.html", "<my-btn>x</my-btn>"),
1319            (
1320                "my-btn.html",
1321                r#"<div><button @click="e.preventDefault()">x</button></div>"#,
1322            ),
1323        ]);
1324        let mut options = default_options(app.path());
1325        options.plugin = Some(Plugin::WebUI);
1326
1327        let Err(err) = build(options) else {
1328            panic!("invalid @event handler must fail the build");
1329        };
1330        let msg = err.chain_message();
1331        assert!(msg.contains("invalid @click handler"), "msg: {msg}");
1332        assert!(
1333            msg.contains("component <my-btn> · element <button>"),
1334            "msg: {msg}"
1335        );
1336    }
1337
1338    #[test]
1339    fn test_error_chain_message_has_no_duplicate_source() {
1340        let err = WebUIError::Parse {
1341            context: "Failed to parse index.html".to_string(),
1342            source: webui_parser::ParserError::Directive("Invalid for each: x".to_string()),
1343        };
1344        // Each layer's Display describes only its own level — the source is not
1345        // embedded — so anyhow's `{:#}` / chain walks never repeat it.
1346        assert_eq!(err.to_string(), "Failed to parse index.html");
1347        // The flat message includes the source exactly once (no repetition).
1348        assert_eq!(
1349            err.chain_message(),
1350            "Failed to parse index.html: Directive error: Invalid for each: x"
1351        );
1352    }
1353
1354    #[test]
1355    fn test_build_with_directives() {
1356        let html = r#"<h1>Hello</h1>
1357<for each="item in items">
1358    <p>{{item.name}}</p>
1359</for>
1360<if condition="show">
1361    <p>Visible</p>
1362</if>"#;
1363        let app = create_app_dir(&[("index.html", html)]);
1364        let result = build(default_options(app.path())).unwrap();
1365
1366        let index = &result.protocol.fragments["index.html"].fragments;
1367        assert!(index
1368            .iter()
1369            .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(_)))));
1370        assert!(index
1371            .iter()
1372            .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
1373    }
1374
1375    #[test]
1376    fn test_build_with_component_css() {
1377        let app = create_app_dir(&[
1378            ("index.html", "<my-card>Hello</my-card>"),
1379            ("my-card.html", "<div><slot></slot></div>"),
1380            ("my-card.css", ".card { color: red; }"),
1381            ("my-card.ts", "export {};"),
1382        ]);
1383        let result = build(default_options(app.path())).unwrap();
1384
1385        assert_eq!(result.css_files.len(), 1);
1386        assert_eq!(result.css_files[0].0, "my-card.css");
1387        assert!(result.css_files[0].1.contains("color: red"));
1388        assert_eq!(result.stats.css_file_count, 1);
1389    }
1390
1391    #[test]
1392    fn test_build_strips_non_legal_css_comments_from_output_file() {
1393        let app = create_app_dir(&[
1394            ("index.html", "<my-card>Hello</my-card>"),
1395            ("my-card.html", "<div><slot></slot></div>"),
1396            (
1397                "my-card.css",
1398                "/* remove var(--ignored) */ .card { color: var(--textColor); }",
1399            ),
1400        ]);
1401        let result = build(default_options(app.path())).unwrap();
1402
1403        assert_eq!(result.css_files[0].1, " .card { color: var(--textColor); }");
1404        assert_eq!(result.protocol.tokens, vec!["textColor"]);
1405    }
1406
1407    #[test]
1408    fn test_build_preserves_legal_css_comments_by_default() {
1409        let app = create_app_dir(&[
1410            ("index.html", "<my-card>Hello</my-card>"),
1411            ("my-card.html", "<div><slot></slot></div>"),
1412            (
1413                "my-card.css",
1414                "/*! @license MIT */ .card { color: red; } /* remove */",
1415            ),
1416        ]);
1417        let result = build(default_options(app.path())).unwrap();
1418
1419        assert_eq!(
1420            result.css_files[0].1,
1421            "/*! @license MIT */ .card { color: red; } "
1422        );
1423    }
1424
1425    #[test]
1426    fn test_build_legal_comments_none_strips_legal_css_comments() {
1427        let app = create_app_dir(&[
1428            ("index.html", "<my-card>Hello</my-card>"),
1429            ("my-card.html", "<div><slot></slot></div>"),
1430            ("my-card.css", "/*! @license MIT */ .card { color: red; }"),
1431        ]);
1432        let mut options = default_options(app.path());
1433        options.legal_comments = LegalComments::None;
1434        let result = build(options).unwrap();
1435
1436        assert_eq!(result.css_files[0].1, " .card { color: red; }");
1437    }
1438
1439    #[test]
1440    fn test_build_with_css_hashed_filename_template() {
1441        let app = create_app_dir(&[
1442            ("index.html", "<my-card>Hello</my-card>"),
1443            ("my-card.html", "<div><slot></slot></div>"),
1444            ("my-card.css", ".card { color: red; }"),
1445        ]);
1446        let mut options = default_options(app.path());
1447        options.css_file_name_template = "[name]-[hash].[ext]".to_string();
1448        let result = build(options).unwrap();
1449
1450        assert_eq!(result.css_files.len(), 1);
1451        let generated = &result.css_files[0].0;
1452        assert!(generated.starts_with("my-card-"));
1453        assert!(generated.ends_with(".css"));
1454        assert_eq!(generated.len(), "my-card-".len() + 8 + ".css".len());
1455        assert_eq!(
1456            result
1457                .protocol
1458                .components
1459                .get("my-card")
1460                .map(|c| c.css_href.as_str()),
1461            Some(generated.as_str())
1462        );
1463    }
1464
1465    #[test]
1466    fn test_css_public_base_prefixes_css_href_only() {
1467        let app = create_app_dir(&[
1468            ("index.html", "<my-card>Hello</my-card>"),
1469            ("my-card.html", "<div><slot></slot></div>"),
1470            ("my-card.css", ".card { color: red; }"),
1471        ]);
1472        let mut options = default_options(app.path());
1473        options.css_file_name_template = "[name]-[hash].[ext]".to_string();
1474        options.css_public_base = Some("https://cdn.example.com/assets".to_string());
1475        let result = build(options).unwrap();
1476
1477        let filename = &result.css_files[0].0;
1478        assert!(filename.starts_with("my-card-"));
1479        let expected_href = format!("https://cdn.example.com/assets/{filename}");
1480        assert_eq!(
1481            result
1482                .protocol
1483                .components
1484                .get("my-card")
1485                .map(|c| c.css_href.as_str()),
1486            Some(expected_href.as_str())
1487        );
1488    }
1489
1490    #[test]
1491    fn test_css_public_base_emits_parser_and_handler_links() {
1492        let app = create_app_dir(&[
1493            (
1494                "index.html",
1495                "<html><head></head><body><my-card>Hello</my-card></body></html>",
1496            ),
1497            ("my-card.html", "<div><slot></slot></div>"),
1498            ("my-card.css", ".card { color: red; }"),
1499        ]);
1500        let mut options = default_options(app.path());
1501        options.css_file_name_template = "[name]-[hash].[ext]".to_string();
1502        options.css_public_base = Some("https://cdn.example.com/assets".to_string());
1503        let result = build(options).unwrap();
1504
1505        let filename = &result.css_files[0].0;
1506        let expected_href = format!("https://cdn.example.com/assets/{filename}");
1507        let component_html = result.protocol.fragments["my-card"]
1508            .fragments
1509            .iter()
1510            .filter_map(|fragment| match fragment.fragment.as_ref() {
1511                Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
1512                _ => None,
1513            })
1514            .collect::<String>();
1515        assert!(
1516            component_html.contains(&format!(
1517                r#"<link rel="stylesheet" href="{expected_href}">"#
1518            )),
1519            "parser-generated component template should use CDN href: {component_html}"
1520        );
1521
1522        let handler = WebUIHandler::new();
1523        let protocol = Protocol::new(result.protocol.clone());
1524        let mut writer = StringWriter { buf: String::new() };
1525        handler
1526            .render(
1527                &protocol,
1528                &serde_json::json!({}),
1529                &RenderOptions::new("index.html", "/"),
1530                &mut writer,
1531            )
1532            .unwrap();
1533        assert!(
1534            writer.buf.contains(&format!(
1535                r#"<link rel="preload" href="{expected_href}" as="style" data-webui-ssr-preload="style">"#
1536            )),
1537            "handler head preload should use CDN href: {}",
1538            writer.buf
1539        );
1540    }
1541
1542    #[test]
1543    fn test_css_public_base_emits_webui_plugin_template_links() {
1544        let app = create_app_dir(&[
1545            ("index.html", "<my-card>Hello</my-card>"),
1546            ("my-card.html", "<div><slot></slot></div>"),
1547            ("my-card.css", ".card { color: red; }"),
1548            ("my-card.ts", "export {};"),
1549        ]);
1550        let mut options = default_options(app.path());
1551        options.plugin = Some(Plugin::WebUI);
1552        options.css_file_name_template = "[name]-[hash].[ext]".to_string();
1553        options.css_public_base = Some("https://cdn.example.com/assets".to_string());
1554        let result = build(options).unwrap();
1555
1556        let filename = &result.css_files[0].0;
1557        let expected_href = format!("https://cdn.example.com/assets/{filename}");
1558        let template = &result.protocol.components["my-card"].template_json;
1559        assert!(
1560            template.contains(&format!(r#"href=\"{expected_href}\""#)),
1561            "plugin component template should use CDN href: {template}"
1562        );
1563    }
1564
1565    #[test]
1566    fn test_css_public_base_emits_fast_plugin_template_links() {
1567        let app = create_app_dir(&[
1568            ("index.html", "<my-card>Hello</my-card>"),
1569            ("my-card.html", "<div><slot></slot></div>"),
1570            ("my-card.css", ".card { color: red; }"),
1571        ]);
1572        let mut options = default_options(app.path());
1573        options.plugin = Some(Plugin::FastV3);
1574        options.css_file_name_template = "[name]-[hash].[ext]".to_string();
1575        options.css_public_base = Some("https://cdn.example.com/assets".to_string());
1576        let result = build(options).unwrap();
1577
1578        let filename = &result.css_files[0].0;
1579        let expected_href = format!("https://cdn.example.com/assets/{filename}");
1580        let template = &result.protocol.components["my-card"].template;
1581        assert!(
1582            template.contains(&format!(r#"href="{expected_href}""#)),
1583            "FAST component template should use CDN href: {template}"
1584        );
1585    }
1586
1587    #[test]
1588    fn test_invalid_css_template_is_rejected() {
1589        let app = create_app_dir(&[
1590            ("index.html", "<my-card>Hello</my-card>"),
1591            ("my-card.html", "<div><slot></slot></div>"),
1592            ("my-card.css", ".card { color: red; }"),
1593        ]);
1594        let mut options = default_options(app.path());
1595        options.css_file_name_template = "[name]-[bogus].[ext]".to_string();
1596        let result = build(options);
1597
1598        assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
1599    }
1600
1601    #[test]
1602    fn test_css_filename_collision_is_rejected() {
1603        let app = create_app_dir(&[
1604            ("index.html", "<card-a>A</card-a><card-b>B</card-b>"),
1605            ("card-a.html", "<div><slot></slot></div>"),
1606            ("card-a.css", ".x { color: red; }"),
1607            ("card-b.html", "<div><slot></slot></div>"),
1608            ("card-b.css", ".x { color: red; }"),
1609        ]);
1610        let mut options = default_options(app.path());
1611        options.css_file_name_template = "[hash].[ext]".to_string();
1612        let result = build(options);
1613
1614        assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
1615    }
1616
1617    #[test]
1618    fn test_build_returns_static_component_asset_files() {
1619        let app = create_app_dir(&[
1620            ("index.html", "<app-shell></app-shell>"),
1621            ("app-shell.html", "<div></div>"),
1622            (
1623                "lazy-panel.html",
1624                r#"<if condition="ready"><p>{{title}}</p></if>"#,
1625            ),
1626            ("lazy-panel.ts", "export {};"),
1627        ]);
1628        let mut options = default_options(app.path());
1629        options.plugin = Some(Plugin::WebUI);
1630        options.component_asset_roots = vec!["lazy-panel".to_string()];
1631
1632        let result = build(options).unwrap();
1633
1634        assert_eq!(result.component_asset_files.len(), 1);
1635        assert_eq!(result.component_asset_files[0].name, "lazy-panel.webui.js");
1636        assert!(result.component_asset_files[0]
1637            .content
1638            .contains(r#""type":"webui-component-asset""#));
1639        assert!(result.component_asset_files[0]
1640            .content
1641            .contains(r#""templateFunctions":{"lazy-panel":"#));
1642        assert!(result.protocol.fragments.contains_key("lazy-panel"));
1643        assert!(
1644            !result
1645                .protocol
1646                .fragments
1647                .keys()
1648                .any(|key| key.starts_with("__webui_asset_root_")),
1649            "synthetic asset root fragments must not be serialized"
1650        );
1651    }
1652
1653    #[test]
1654    fn test_component_asset_filename_collision_with_css_is_rejected() {
1655        let app = create_app_dir(&[
1656            ("index.html", "<app-shell></app-shell>"),
1657            ("app-shell.html", "<div></div>"),
1658            ("lazy-panel.html", "<p>Lazy</p>"),
1659            ("lazy-panel.css", ".panel { color: red; }"),
1660        ]);
1661        let mut options = default_options(app.path());
1662        options.plugin = Some(Plugin::WebUI);
1663        options.component_asset_roots = vec!["lazy-panel".to_string()];
1664        options.css_file_name_template = "[name]".to_string();
1665
1666        let result = build(options);
1667
1668        assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
1669    }
1670
1671    #[test]
1672    fn test_build_to_disk_rejects_protocol_name_collision_before_writing() {
1673        let app = create_app_dir(&[
1674            ("index.html", "<app-shell></app-shell>"),
1675            ("app-shell.html", "<div></div>"),
1676            ("lazy-panel.html", "<p>Lazy</p>"),
1677        ]);
1678        let out = TempDir::new().unwrap();
1679        let mut options = default_options(app.path());
1680        options.plugin = Some(Plugin::WebUI);
1681        options.component_asset_roots = vec!["lazy-panel".to_string()];
1682        options.css_file_name_template = "protocol.bin".to_string();
1683
1684        let result = build_to_disk(options, out.path());
1685
1686        assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
1687        assert!(!out.path().join("protocol.bin").exists());
1688    }
1689
1690    #[test]
1691    fn test_css_href_set_for_light_dom_link_strategy() {
1692        let app = create_app_dir(&[
1693            ("index.html", "<has-css>A</has-css><no-css>B</no-css>"),
1694            ("has-css.html", "<p><slot></slot></p>"),
1695            ("has-css.css", ".yes { color: green; }"),
1696            ("no-css.html", "<p><slot></slot></p>"),
1697        ]);
1698        let mut options = default_options(app.path());
1699        options.dom = DomStrategy::Light;
1700        let result = build(options).unwrap();
1701
1702        let href = result
1703            .protocol
1704            .components
1705            .get("has-css")
1706            .map(|c| c.css_href.as_str())
1707            .unwrap_or("");
1708        assert_eq!(
1709            href, "has-css.css",
1710            "Light×Link component with CSS should have css_href"
1711        );
1712
1713        let no_href = result
1714            .protocol
1715            .components
1716            .get("no-css")
1717            .map(|c| c.css_href.as_str())
1718            .unwrap_or("");
1719        assert!(
1720            no_href.is_empty(),
1721            "Component without CSS should have empty css_href"
1722        );
1723    }
1724
1725    #[test]
1726    fn test_shadow_dom_link_strategy_sets_css_href() {
1727        // Shadow×Link: css_href is always set for Link-strategy components.
1728        // The handler uses protocol.css_strategy + dom_strategy to decide
1729        // whether to emit preload (Shadow) or stylesheet (Light) in <head>.
1730        let app = create_app_dir(&[
1731            ("index.html", "<my-card>A</my-card>"),
1732            ("my-card.html", "<p><slot></slot></p>"),
1733            ("my-card.css", ".card { color: red; }"),
1734        ]);
1735        let result = build(default_options(app.path())).unwrap();
1736
1737        let comp = result.protocol.components.get("my-card").unwrap();
1738        assert_eq!(
1739            comp.css_href, "my-card.css",
1740            "Shadow×Link should set css_href"
1741        );
1742
1743        // Strategy fields on protocol
1744        assert_eq!(
1745            result.protocol.css_strategy(),
1746            webui_protocol::CssStrategy::Link,
1747        );
1748        assert_eq!(
1749            result.protocol.dom_strategy(),
1750            webui_protocol::DomStrategy::Shadow,
1751        );
1752
1753        // CSS file should still be emitted for the server to serve
1754        assert_eq!(
1755            result.css_files.len(),
1756            1,
1757            "CSS file should still be produced"
1758        );
1759    }
1760
1761    #[test]
1762    fn test_css_href_empty_for_style_strategy() {
1763        let app = create_app_dir(&[
1764            ("index.html", "<my-card>A</my-card>"),
1765            ("my-card.html", "<p><slot></slot></p>"),
1766            ("my-card.css", ".card { color: red; }"),
1767        ]);
1768        let mut options = default_options(app.path());
1769        options.css = CssStrategy::Style;
1770        let result = build(options).unwrap();
1771
1772        let href = result
1773            .protocol
1774            .components
1775            .get("my-card")
1776            .map(|c| c.css_href.as_str())
1777            .unwrap_or("");
1778        assert!(href.is_empty(), "Style-strategy should not set css_href");
1779    }
1780
1781    #[test]
1782    fn test_css_href_empty_for_module_strategy() {
1783        let app = create_app_dir(&[
1784            ("index.html", "<my-card>A</my-card>"),
1785            ("my-card.html", "<p><slot></slot></p>"),
1786            ("my-card.css", ".card { color: red; }"),
1787        ]);
1788        let mut options = default_options(app.path());
1789        options.css = CssStrategy::Module;
1790        let result = build(options).unwrap();
1791
1792        let href = result
1793            .protocol
1794            .components
1795            .get("my-card")
1796            .map(|c| c.css_href.as_str())
1797            .unwrap_or("");
1798        assert!(href.is_empty(), "Module-strategy should not set css_href");
1799        assert!(
1800            !result.protocol.components["my-card"].css.is_empty(),
1801            "Module-strategy should still populate css content"
1802        );
1803    }
1804
1805    #[test]
1806    fn test_build_to_disk_writes_files() {
1807        let app = create_app_dir(&[
1808            ("index.html", "<my-card>Hello</my-card>"),
1809            ("my-card.html", "<div><slot></slot></div>"),
1810            ("my-card.css", ".card { color: red; }"),
1811            ("my-card.ts", "export {};"),
1812        ]);
1813        let out = TempDir::new().unwrap();
1814
1815        let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
1816
1817        assert!(out.path().join("protocol.bin").exists());
1818        assert!(out.path().join("my-card.css").exists());
1819        assert_eq!(stats.css_file_count, 1);
1820        assert!(stats.fragment_count > 0);
1821    }
1822
1823    #[test]
1824    fn test_build_to_disk_creates_nested_output_dir() {
1825        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
1826        let out = TempDir::new().unwrap();
1827        let nested = out.path().join("nested").join("output");
1828
1829        let stats = build_to_disk(default_options(app.path()), &nested).unwrap();
1830        assert!(nested.join("protocol.bin").exists());
1831        assert!(stats.fragment_count > 0);
1832    }
1833
1834    #[test]
1835    fn test_build_missing_entry() {
1836        let app = create_app_dir(&[("other.html", "<h1>Not index</h1>")]);
1837        let result = build(default_options(app.path()));
1838
1839        assert!(result.is_err());
1840        let err = result.unwrap_err();
1841        assert!(matches!(err, WebUIError::Io { .. }));
1842    }
1843
1844    #[test]
1845    fn test_build_stats_populated() {
1846        let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
1847        let result = build(default_options(app.path())).unwrap();
1848
1849        assert!(result.stats.fragment_count > 0);
1850        assert!(result.stats.protocol_size_bytes > 0);
1851        assert_eq!(result.stats.css_file_count, 0);
1852    }
1853
1854    #[test]
1855    fn test_build_inline_css() {
1856        let app = create_app_dir(&[
1857            ("index.html", "<my-card>Hello</my-card>"),
1858            ("my-card.html", "<div><slot></slot></div>"),
1859            ("my-card.css", ".card { color: red; }"),
1860        ]);
1861        let mut options = default_options(app.path());
1862        options.css = CssStrategy::Style;
1863
1864        let result = build(options).unwrap();
1865        // Inline mode embeds CSS in <style> tags — no external CSS files
1866        assert!(result.css_files.is_empty());
1867        assert_eq!(result.stats.css_file_count, 0);
1868        assert!(result.stats.fragment_count > 0);
1869    }
1870
1871    #[test]
1872    fn test_build_module_css() {
1873        let app = create_app_dir(&[
1874            ("index.html", "<my-card>Hello</my-card>"),
1875            ("my-card.html", "<div><slot></slot></div>"),
1876            ("my-card.css", ".card { color: red; }"),
1877        ]);
1878        let mut options = default_options(app.path());
1879        options.css = CssStrategy::Module;
1880
1881        let result = build(options).unwrap();
1882        // Module mode emits <script type="importmap"> data URIs — no external CSS files
1883        assert!(result.css_files.is_empty());
1884        assert_eq!(result.stats.css_file_count, 0);
1885        assert!(result.stats.fragment_count > 0);
1886    }
1887
1888    #[test]
1889    fn test_build_custom_entry() {
1890        let app = create_app_dir(&[("page.html", "<h1>Custom</h1>")]);
1891        let mut options = default_options(app.path());
1892        options.entry = "page.html".to_string();
1893
1894        let result = build(options).unwrap();
1895        assert!(result.protocol.fragments.contains_key("page.html"));
1896        assert!(!result.protocol.fragments.contains_key("index.html"));
1897    }
1898
1899    #[test]
1900    fn test_build_protocol_roundtrip() {
1901        let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
1902        let result = build(default_options(app.path())).unwrap();
1903
1904        let restored = WebUIProtocol::from_protobuf(&result.protocol_bytes).unwrap();
1905        assert!(restored.fragments.contains_key("index.html"));
1906    }
1907
1908    #[test]
1909    fn protocol_is_available_from_facade() {
1910        let protocol = Protocol::new(WebUIProtocol::new(HashMap::new()));
1911        assert!(protocol.tokens().is_empty());
1912    }
1913
1914    #[test]
1915    fn test_inspect_bytes_valid() {
1916        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
1917        let result = build(default_options(app.path())).unwrap();
1918
1919        let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
1920        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1921        assert!(parsed.get("fragments").is_some());
1922    }
1923
1924    #[test]
1925    fn test_inspect_bytes_invalid() {
1926        let result = inspect_bytes(b"not a protobuf");
1927        assert!(result.is_err());
1928        assert!(matches!(result.unwrap_err(), WebUIError::Protocol(_)));
1929    }
1930
1931    #[test]
1932    fn test_inspect_file() {
1933        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
1934        let out = TempDir::new().unwrap();
1935        build_to_disk(default_options(app.path()), out.path()).unwrap();
1936
1937        let json_str = inspect(&out.path().join("protocol.bin")).unwrap();
1938        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1939        assert!(parsed["fragments"]["index.html"]["fragments"].is_array());
1940    }
1941
1942    #[test]
1943    fn test_inspect_missing_file() {
1944        let result = inspect(Path::new("/nonexistent/protocol.bin"));
1945        assert!(result.is_err());
1946        assert!(matches!(result.unwrap_err(), WebUIError::Io { .. }));
1947    }
1948
1949    #[test]
1950    fn test_build_with_components_local_path() {
1951        let app = create_app_dir(&[("index.html", "<ext-card>Hello</ext-card>")]);
1952        let ext_dir = TempDir::new().unwrap();
1953        fs::write(
1954            ext_dir.path().join("ext-card.html"),
1955            "<div class=\"card\"><slot></slot></div>",
1956        )
1957        .unwrap();
1958        fs::write(
1959            ext_dir.path().join("ext-card.css"),
1960            ".card { border: 1px solid #ccc; }",
1961        )
1962        .unwrap();
1963
1964        let mut options = default_options(app.path());
1965        options.components = vec![ext_dir.path().to_string_lossy().to_string()];
1966
1967        let result = build(options).unwrap();
1968        assert!(result.protocol.fragments.contains_key("index.html"));
1969        assert_eq!(result.css_files.len(), 1);
1970        assert!(result.css_files[0].1.contains("border"));
1971    }
1972
1973    #[test]
1974    fn test_build_with_scriptless_component_path_emits_dormant_template() {
1975        let app = create_app_dir(&[("index.html", "<ext-card></ext-card>")]);
1976        let ext_dir = TempDir::new().unwrap();
1977        fs::write(ext_dir.path().join("ext-card.html"), "<p>{{title}}</p>").unwrap();
1978
1979        let mut options = default_options(app.path());
1980        options.plugin = Some(Plugin::WebUI);
1981        options.components = vec![ext_dir.path().to_string_lossy().to_string()];
1982
1983        let result = build(options).unwrap();
1984        assert!(result
1985            .component_templates
1986            .iter()
1987            .any(|template| template.tag_name == "ext-card"));
1988        let component = result
1989            .protocol
1990            .components
1991            .get("ext-card")
1992            .expect("external scriptless component metadata should be retained");
1993        assert!(component.hydration_keys.is_empty());
1994        assert_eq!(component.navigation_keys, ["title"]);
1995        assert!(component.template_json.contains(r#""th":1"#));
1996    }
1997
1998    #[test]
1999    fn test_build_with_scripted_component_path_emits_client_artifact() {
2000        let app = create_app_dir(&[("index.html", "<ext-card></ext-card>")]);
2001        let ext_dir = TempDir::new().unwrap();
2002        fs::write(ext_dir.path().join("ext-card.html"), "<p>{{title}}</p>").unwrap();
2003        fs::write(ext_dir.path().join("ext-card.ts"), "export {};").unwrap();
2004
2005        let mut options = default_options(app.path());
2006        options.plugin = Some(Plugin::WebUI);
2007        options.components = vec![ext_dir.path().to_string_lossy().to_string()];
2008
2009        let result = build(options).unwrap();
2010        let template = &result.protocol.components["ext-card"].template_json;
2011        assert!(!template.is_empty(), "template should be emitted");
2012    }
2013
2014    #[test]
2015    fn test_build_rejects_projection_manifest_with_non_webui_plugin() {
2016        let app = create_app_dir(&[
2017            ("index.html", "<demo-card></demo-card>"),
2018            ("demo-card.html", "<p>{{name}}</p>"),
2019            ("demo-card.ts", "export {};"),
2020        ]);
2021        let manifest_json = projection::test_support::build_valid_manifest_json(
2022            app.path(),
2023            &[("demo-card.ts", "export {};")],
2024            &[("projection-bundle.js", "output")],
2025            &[(
2026                "demo-card",
2027                "demo-card.ts",
2028                &["projection-bundle.js"],
2029                &["name"],
2030                &["name"],
2031            )],
2032        );
2033        let manifest = projection::test_support::write_manifest(
2034            app.path(),
2035            "webui-projection.json",
2036            &manifest_json,
2037        );
2038        let mut options = default_options(app.path());
2039        // No plugin selected: default/FAST builds are incompatible with
2040        // projection manifests (`PROJ-B002`).
2041        options.projection_manifests = vec![manifest.into()];
2042        let err = build(options).expect_err("non-WebUI plugin + manifest should be rejected");
2043        assert!(
2044            err.chain_message().contains("PROJ-B002"),
2045            "msg: {}",
2046            err.chain_message()
2047        );
2048    }
2049
2050    #[test]
2051    fn test_build_rejects_missing_scripted_component_coverage() {
2052        let app = create_app_dir(&[
2053            ("index.html", "<demo-card></demo-card>"),
2054            ("demo-card.html", "<p>{{name}}</p>"),
2055            ("demo-card.ts", "export {};"),
2056        ]);
2057        // Manifest is valid but never mentions `demo-card`, the only
2058        // compiled scripted component in this build.
2059        let manifest_json =
2060            projection::test_support::build_valid_manifest_json(app.path(), &[], &[], &[]);
2061        let manifest = projection::test_support::write_manifest(
2062            app.path(),
2063            "webui-projection.json",
2064            &manifest_json,
2065        );
2066        let mut options = default_options(app.path());
2067        options.plugin = Some(Plugin::WebUI);
2068        options.projection_manifests = vec![manifest.into()];
2069        let err = build(options).expect_err("missing coverage should fail the build");
2070        let message = err.chain_message();
2071        assert!(message.contains("PROJ-B001"), "msg: {message}");
2072        assert!(message.contains("demo-card"), "msg: {message}");
2073    }
2074
2075    #[test]
2076    fn test_build_allows_unused_discovered_scripted_component_without_coverage() {
2077        let app = create_app_dir(&[
2078            ("index.html", "<demo-card></demo-card>"),
2079            ("demo-card.html", "<p>{{name}}</p>"),
2080            ("demo-card.ts", "export {};"),
2081            // `unused-card` is a scripted component discovered on disk but
2082            // never referenced from `index.html`, so it is not compiled
2083            // into the protocol and does not require manifest coverage.
2084            ("unused-card.html", "<p>{{title}}</p>"),
2085            ("unused-card.ts", "export {};"),
2086        ]);
2087        let manifest_json = projection::test_support::build_valid_manifest_json(
2088            app.path(),
2089            &[("demo-card.ts", "export {};")],
2090            &[("projection-bundle.js", "output")],
2091            &[(
2092                "demo-card",
2093                "demo-card.ts",
2094                &["projection-bundle.js"],
2095                &["name"],
2096                &["name"],
2097            )],
2098        );
2099        let manifest = projection::test_support::write_manifest(
2100            app.path(),
2101            "webui-projection.json",
2102            &manifest_json,
2103        );
2104        let mut options = default_options(app.path());
2105        options.plugin = Some(Plugin::WebUI);
2106        options.projection_manifests = vec![manifest.into()];
2107        let result = build(options).unwrap();
2108
2109        assert!(!result.protocol.fragments.contains_key("unused-card"));
2110        let component = result.protocol.components.get("demo-card").unwrap();
2111        assert_eq!(component.hydration_keys, vec!["name"]);
2112    }
2113
2114    #[test]
2115    fn test_build_merges_multiple_projection_manifest_fragments() {
2116        let app = create_app_dir(&[
2117            (
2118                "index.html",
2119                "<html><body><card-a></card-a><card-b></card-b></body></html>",
2120            ),
2121            ("card-a.html", "<p>{{alpha}}</p>"),
2122            ("card-a.ts", "export {};"),
2123            ("card-b.html", "<p>{{beta}}</p>"),
2124            ("card-b.ts", "export {};"),
2125        ]);
2126        let json_a = projection::test_support::build_valid_manifest_json(
2127            app.path(),
2128            &[("card-a.ts", "export {};")],
2129            &[("card-a.js", "output-a")],
2130            &[(
2131                "card-a",
2132                "card-a.ts",
2133                &["card-a.js"],
2134                &["alpha"],
2135                &["alpha"],
2136            )],
2137        );
2138        let manifest_a =
2139            projection::test_support::write_manifest(app.path(), "fragment-a.json", &json_a);
2140        let json_b = projection::test_support::build_valid_manifest_json(
2141            app.path(),
2142            &[("card-b.ts", "export {};")],
2143            &[("card-b.js", "output-b")],
2144            &[("card-b", "card-b.ts", &["card-b.js"], &["beta"], &["beta"])],
2145        );
2146        let manifest_b =
2147            projection::test_support::write_manifest(app.path(), "fragment-b.json", &json_b);
2148
2149        let mut options = default_options(app.path());
2150        options.plugin = Some(Plugin::WebUI);
2151        options.projection_manifests = vec![manifest_a.into(), manifest_b.into()];
2152        let result = build(options).unwrap();
2153
2154        assert_eq!(
2155            result.protocol.initial_state_strategy,
2156            webui_protocol::InitialStateStrategy::Components as i32
2157        );
2158        let card_a = result.protocol.components.get("card-a").unwrap();
2159        assert_eq!(card_a.hydration_keys, vec!["alpha"]);
2160        let card_b = result.protocol.components.get("card-b").unwrap();
2161        assert_eq!(card_b.hydration_keys, vec!["beta"]);
2162    }
2163
2164    #[test]
2165    fn test_build_hello_world_example() {
2166        let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
2167        let app_dir = manifest_dir.join("../../examples/app/hello-world/src");
2168
2169        let result = build(BuildOptions {
2170            app_dir,
2171            entry: "index.html".to_string(),
2172            ..BuildOptions::default()
2173        })
2174        .unwrap();
2175
2176        let index = &result.protocol.fragments["index.html"].fragments;
2177        assert!(index.iter().any(
2178            |f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(fl)) if fl.collection == "people")
2179        ));
2180        assert!(index
2181            .iter()
2182            .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
2183    }
2184
2185    // ── Security tests ───────────────────────────────────────────────
2186
2187    #[test]
2188    fn test_css_filename_sanitizes_path_separators() {
2189        // Even if a component tag somehow contains path separators,
2190        // the filename should be sanitized to prevent directory traversal.
2191        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
2192        let result = build(default_options(app.path())).unwrap();
2193
2194        // Verify all CSS filenames are plain file names (no path separators)
2195        for (filename, _) in &result.css_files {
2196            assert!(
2197                !filename.contains('/') && !filename.contains('\\'),
2198                "CSS filename contains path separator: {filename}"
2199            );
2200        }
2201    }
2202
2203    #[test]
2204    fn test_build_to_disk_css_stays_in_output_dir() {
2205        let app = create_app_dir(&[
2206            ("index.html", "<my-card>Hello</my-card>"),
2207            ("my-card.html", "<div><slot></slot></div>"),
2208            ("my-card.css", ".card { color: red; }"),
2209        ]);
2210        let out = TempDir::new().unwrap();
2211
2212        build_to_disk(default_options(app.path()), out.path()).unwrap();
2213
2214        // Verify CSS file is written inside out_dir
2215        let css_path = out.path().join("my-card.css");
2216        assert!(css_path.exists());
2217        let canonical = css_path.canonicalize().unwrap();
2218        let out_canonical = out.path().canonicalize().unwrap();
2219        assert!(
2220            canonical.starts_with(&out_canonical),
2221            "CSS file escaped output directory"
2222        );
2223    }
2224
2225    // ── Performance / edge case tests ────────────────────────────────
2226
2227    #[test]
2228    fn test_build_empty_html() {
2229        let app = create_app_dir(&[("index.html", "")]);
2230        let result = build(default_options(app.path())).unwrap();
2231
2232        assert!(result.protocol.fragments.contains_key("index.html"));
2233        assert_eq!(result.stats.fragment_count, 0);
2234        assert!(result.stats.protocol_size_bytes > 0);
2235    }
2236
2237    #[test]
2238    fn test_build_large_fragment_count() {
2239        // Verify stats are accurate with many fragments
2240        let mut html = String::with_capacity(2000);
2241        for i in 0..50 {
2242            html.push_str(&format!("<p>Item {i}</p>\n"));
2243        }
2244        let app = create_app_dir(&[("index.html", &html)]);
2245        let result = build(default_options(app.path())).unwrap();
2246
2247        assert!(result.stats.fragment_count > 0);
2248        assert_eq!(result.stats.css_file_count, 0);
2249    }
2250
2251    #[test]
2252    fn test_build_with_fast_plugin() {
2253        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
2254        let mut options = default_options(app.path());
2255        options.plugin = Some(Plugin::FastV3);
2256
2257        let result = build(options).unwrap();
2258        assert!(result.protocol.fragments.contains_key("index.html"));
2259        assert_eq!(
2260            result.protocol.initial_state_strategy,
2261            webui_protocol::InitialStateStrategy::Full as i32
2262        );
2263    }
2264
2265    #[test]
2266    fn test_build_to_disk_inline_mode_no_css_files() {
2267        let app = create_app_dir(&[
2268            ("index.html", "<my-card>Hello</my-card>"),
2269            ("my-card.html", "<div><slot></slot></div>"),
2270            ("my-card.css", ".card { color: red; }"),
2271        ]);
2272        let out = TempDir::new().unwrap();
2273        let mut options = default_options(app.path());
2274        options.css = CssStrategy::Style;
2275
2276        let stats = build_to_disk(options, out.path()).unwrap();
2277
2278        assert!(out.path().join("protocol.bin").exists());
2279        assert!(!out.path().join("my-card.css").exists());
2280        assert_eq!(stats.css_file_count, 0);
2281    }
2282
2283    #[test]
2284    fn test_build_stats_duration_is_nonzero() {
2285        let app = create_app_dir(&[("index.html", "<h1>Hello {{name}}</h1>")]);
2286        let result = build(default_options(app.path())).unwrap();
2287
2288        assert!(!result.stats.duration.is_zero());
2289    }
2290
2291    #[test]
2292    fn test_build_multiple_components_css() {
2293        let app = create_app_dir(&[
2294            ("index.html", "<card-a>A</card-a><card-b>B</card-b>"),
2295            ("card-a.html", "<div><slot></slot></div>"),
2296            ("card-a.css", ".a { color: red; }"),
2297            ("card-b.html", "<span><slot></slot></span>"),
2298            ("card-b.css", ".b { color: blue; }"),
2299        ]);
2300        let result = build(default_options(app.path())).unwrap();
2301
2302        assert_eq!(result.css_files.len(), 2);
2303        assert_eq!(result.stats.css_file_count, 2);
2304        let filenames: Vec<&str> = result.css_files.iter().map(|(n, _)| n.as_str()).collect();
2305        assert!(filenames.contains(&"card-a.css"));
2306        assert!(filenames.contains(&"card-b.css"));
2307    }
2308
2309    #[test]
2310    fn test_build_unused_component_css_not_emitted() {
2311        // card-b is registered but not referenced in index.html
2312        let app = create_app_dir(&[
2313            ("index.html", "<card-a>A</card-a>"),
2314            ("card-a.html", "<div><slot></slot></div>"),
2315            ("card-a.css", ".a { color: red; }"),
2316            ("card-b.html", "<span><slot></slot></span>"),
2317            ("card-b.css", ".b { color: blue; }"),
2318        ]);
2319        let result = build(default_options(app.path())).unwrap();
2320
2321        assert_eq!(result.css_files.len(), 1);
2322        assert_eq!(result.css_files[0].0, "card-a.css");
2323    }
2324
2325    #[test]
2326    fn test_inspect_roundtrip_preserves_content() {
2327        let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
2328        let result = build(default_options(app.path())).unwrap();
2329
2330        let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
2331        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
2332
2333        let fragments = &parsed["fragments"]["index.html"]["fragments"];
2334        assert!(fragments.is_array());
2335        assert!(!fragments.as_array().unwrap().is_empty());
2336    }
2337
2338    #[test]
2339    fn test_build_nonexistent_app_dir() {
2340        let options = BuildOptions {
2341            app_dir: PathBuf::from("/nonexistent/path/that/does/not/exist"),
2342            ..BuildOptions::default()
2343        };
2344        let result = build(options);
2345        assert!(result.is_err());
2346    }
2347
2348    #[test]
2349    fn test_build_allows_fallback_chain_when_all_theme_tokens_exist() {
2350        let app = create_app_dir(&[
2351            ("index.html", "<my-card></my-card>"),
2352            ("my-card.html", "<div>Card</div>"),
2353            (
2354                "my-card.css",
2355                ":host { color: var(--token-a, var(--token-b, var(--token-c))); }",
2356            ),
2357        ]);
2358        let mut options = default_options(app.path());
2359        options.theme = Some(TokenFile {
2360            themes: HashMap::from([(
2361                "light".to_string(),
2362                HashMap::from([
2363                    ("token-a".to_string(), "red".to_string()),
2364                    ("token-b".to_string(), "blue".to_string()),
2365                    ("token-c".to_string(), "green".to_string()),
2366                ]),
2367            )]),
2368        });
2369
2370        let result = build(options).expect("all fallback tokens are defined");
2371        assert!(result.protocol.tokens.contains(&"token-a".to_string()));
2372        assert!(result.protocol.tokens.contains(&"token-b".to_string()));
2373        assert!(result.protocol.tokens.contains(&"token-c".to_string()));
2374    }
2375
2376    #[test]
2377    fn test_build_allows_literal_fallback_token_absent_from_theme() {
2378        let app = create_app_dir(&[
2379            ("index.html", "<my-card></my-card>"),
2380            ("my-card.html", "<div>Card</div>"),
2381            ("my-card.css", ":host { color: var(--brand, #000); }"),
2382        ]);
2383        let mut options = default_options(app.path());
2384        options.theme = Some(TokenFile {
2385            themes: HashMap::from([("light".to_string(), HashMap::new())]),
2386        });
2387
2388        // The CSS literal fallback (`#000`) means `--brand` is not required from
2389        // the theme, so the build must succeed — but the token is still hoisted
2390        // so the runtime resolves it when a theme provides it.
2391        let result = build(options).expect("literal fallback should not fail the build");
2392        assert!(result.protocol.tokens.contains(&"brand".to_string()));
2393        // `--brand` is absent from every theme and only used with a literal
2394        // fallback, so it is surfaced as a non-fatal typo advisory.
2395        assert_eq!(result.warnings.len(), 1, "warnings: {:?}", result.warnings);
2396        assert!(result.warnings[0].body().contains("--brand"));
2397    }
2398
2399    #[test]
2400    fn test_build_ancestor_inline_style_definition_after_comment_satisfies_descendant() {
2401        // `--foo-bar` is defined in the entry's inline <style> after a signal
2402        // comment and consumed inside a component. Custom properties inherit
2403        // through Shadow DOM, so the ancestor definition must satisfy the
2404        // descendant and the token must not be required from the theme.
2405        let app = create_app_dir(&[
2406            (
2407                "index.html",
2408                "<html><head><style>:root {\n  /*{{{tokens.light}}}*/\n  --foo-bar: 100px;\n}</style></head><body><my-card></my-card></body></html>",
2409            ),
2410            ("my-card.html", "<div>Card</div>"),
2411            ("my-card.css", ":host { padding: var(--foo-bar); }"),
2412        ]);
2413        let mut options = default_options(app.path());
2414        options.theme = Some(TokenFile {
2415            themes: HashMap::from([("light".to_string(), HashMap::new())]),
2416        });
2417
2418        let result = build(options)
2419            .expect("ancestor inline-style definition (after a comment) should satisfy the child");
2420        assert!(
2421            !result.protocol.tokens.contains(&"foo-bar".to_string()),
2422            "ancestor-defined token should not be hoisted: {:?}",
2423            result.protocol.tokens
2424        );
2425    }
2426
2427    #[test]
2428    fn test_build_no_warning_when_literal_fallback_token_is_themed() {
2429        let app = create_app_dir(&[
2430            ("index.html", "<my-card></my-card>"),
2431            ("my-card.html", "<div>Card</div>"),
2432            ("my-card.css", ":host { color: var(--brand, #000); }"),
2433        ]);
2434        let mut options = default_options(app.path());
2435        options.theme = Some(TokenFile {
2436            themes: HashMap::from([(
2437                "light".to_string(),
2438                HashMap::from([("brand".to_string(), "#123456".to_string())]),
2439            )]),
2440        });
2441
2442        let result = build(options).expect("themed literal-fallback token builds");
2443        assert!(
2444            result.warnings.is_empty(),
2445            "a themed token must not warn: {:?}",
2446            result.warnings
2447        );
2448    }
2449
2450    #[test]
2451    fn test_build_without_theme_produces_no_warnings() {
2452        let app = create_app_dir(&[
2453            ("index.html", "<my-card></my-card>"),
2454            ("my-card.html", "<div>Card</div>"),
2455            ("my-card.css", ":host { color: var(--brand, #000); }"),
2456        ]);
2457        let result = build(default_options(app.path())).unwrap();
2458        assert!(result.warnings.is_empty());
2459    }
2460
2461    #[test]
2462    fn test_build_excludes_tokens_defined_by_ancestor_component_css() {
2463        let app = create_app_dir(&[
2464            ("index.html", "<component-a></component-a>"),
2465            ("component-a.html", "<component-b></component-b>"),
2466            ("component-a.css", ":host { --brand-color: red; }"),
2467            ("component-b.html", "<div>Child</div>"),
2468            ("component-b.css", ":host { color: var(--brand-color); }"),
2469        ]);
2470        let mut options = default_options(app.path());
2471        options.theme = Some(TokenFile {
2472            themes: HashMap::from([("light".to_string(), HashMap::new())]),
2473        });
2474
2475        let result =
2476            build(options).expect("ancestor component custom property should satisfy child");
2477        assert!(
2478            !result.protocol.tokens.contains(&"brand-color".to_string()),
2479            "ancestor-defined token should not be exposed: {:?}",
2480            result.protocol.tokens
2481        );
2482    }
2483
2484    #[test]
2485    fn test_build_does_not_use_sibling_component_definitions_for_theme_validation() {
2486        let app = create_app_dir(&[
2487            (
2488                "index.html",
2489                "<component-a></component-a><component-b></component-b>",
2490            ),
2491            ("component-a.html", "<div>A</div>"),
2492            ("component-a.css", ":host { --brand-color: red; }"),
2493            ("component-b.html", "<div>B</div>"),
2494            ("component-b.css", ":host { color: var(--brand-color); }"),
2495        ]);
2496        let mut options = default_options(app.path());
2497        options.theme = Some(TokenFile {
2498            themes: HashMap::from([("light".to_string(), HashMap::new())]),
2499        });
2500
2501        let Err(err) = build(options) else {
2502            panic!("sibling custom property must not satisfy component-b");
2503        };
2504        let message = err.chain_message();
2505        assert!(message.contains("missing theme token"), "msg: {message}");
2506        assert!(message.contains("missing-theme-token"), "msg: {message}");
2507        assert!(message.contains("--brand-color"), "msg: {message}");
2508    }
2509
2510    #[test]
2511    fn test_build_rejects_missing_theme_token_after_local_definition_filter() {
2512        let app = create_app_dir(&[
2513            ("index.html", "<my-card></my-card>"),
2514            ("my-card.html", "<div>Card</div>"),
2515            (
2516                "my-card.css",
2517                ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c))); }",
2518            ),
2519        ]);
2520        let mut options = default_options(app.path());
2521        options.theme = Some(TokenFile {
2522            themes: HashMap::from([(
2523                "light".to_string(),
2524                HashMap::from([("token-b".to_string(), "green".to_string())]),
2525            )]),
2526        });
2527
2528        let Err(err) = build(options) else {
2529            panic!("missing --token-c in the theme must fail the build");
2530        };
2531        let message = err.chain_message();
2532        assert!(message.contains("missing-theme-token"), "msg: {message}");
2533        assert!(message.contains("--token-c"), "msg: {message}");
2534        // The error demands the missing `--token-c`, never the locally-defined
2535        // `--token-a` (which only appears in the source snippet as context).
2536        assert!(!message.contains("add --token-a"), "msg: {message}");
2537    }
2538
2539    #[test]
2540    fn test_build_to_disk_returns_accurate_stats() {
2541        let app = create_app_dir(&[
2542            ("index.html", "<my-card>Hello</my-card><p>{{name}}</p>"),
2543            ("my-card.html", "<div><slot></slot></div>"),
2544            ("my-card.css", ".card { color: red; }"),
2545        ]);
2546        let out = TempDir::new().unwrap();
2547
2548        let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
2549
2550        assert!(stats.fragment_count > 0);
2551        assert_eq!(stats.css_file_count, 1);
2552        assert!(stats.component_count > 0);
2553        assert!(stats.protocol_size_bytes > 0);
2554        assert!(!stats.duration.is_zero());
2555    }
2556}