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 chunking;
24pub mod component_asset_output;
25mod component_assets;
26mod error;
27mod module_preload;
28mod projection;
29pub mod server;
30pub mod streaming;
31mod style_bundle;
32
33pub use component_assets::{render_component_assets, ComponentAssetFile, ComponentAssetGraph};
34pub use error::WebUIError;
35
36// Re-export core types from downstream crates
37pub use webui_handler::route_handler::{encode_inventory, get_needed_components, parse_inventory};
38pub use webui_handler::Result as HandlerResult;
39pub use webui_handler::{
40    plugin::HandlerPlugin, BoundaryDescriptor, BoundaryInstanceId, BoundaryKey, BoundaryMode,
41    FlushWriter, HandlerError, Protocol, RenderOptions, ResponseWriter, SessionOptions,
42    SpanInstanceId, StreamStatus, StreamStep, StreamingResponse, StreamingSession, StreamingState,
43    WebUIHandler,
44};
45pub use webui_parser::plugin::{ComponentTemplateArtifact, StateSurface};
46pub use webui_parser::CssStrategy;
47pub use webui_parser::DomStrategy;
48pub use webui_parser::LegalComments;
49pub use webui_parser::ParserError;
50pub use webui_parser::ParserOptions;
51pub use webui_parser::Plugin;
52pub use webui_parser::DEFAULT_CSS_FILE_NAME_TEMPLATE;
53pub use webui_parser::{
54    AssetFileNameTemplate, AssetFileNameTemplateError, DEFAULT_ASSET_FILE_NAME_TEMPLATE,
55};
56pub use webui_parser::{CssFallbackChain, CssTokenAnalysis};
57pub use webui_parser::{Diagnostic, Severity};
58pub use webui_protocol::WebUIProtocol;
59pub use webui_tokens::{load_token_file, resolve_theme_path, TokenFile};
60
61use std::collections::HashSet;
62use std::ffi::{OsStr, OsString};
63use std::fs;
64use std::path::Path;
65use std::time::Instant;
66use webui_discovery::{DiscoveryPlugin, FastDiscoveryPlugin, WebUIDiscoveryPlugin};
67use webui_parser::plugin::fast_v2::FastV2ParserPlugin;
68use webui_parser::plugin::fast_v3::FastV3ParserPlugin;
69use webui_parser::plugin::webui::WebUIParserPlugin;
70use webui_parser::plugin::ParserPluginArtifacts;
71use webui_parser::HtmlParser;
72
73/// Projection metadata validated once for reuse across many protocol builds.
74#[derive(Debug, Clone)]
75pub struct PreparedProjectionManifests {
76    snapshot: std::sync::Arc<projection::ProjectionSnapshot>,
77}
78
79#[derive(Debug)]
80enum ProjectionBarrierState {
81    Waiting,
82    Ready(PreparedProjectionManifests),
83    Failed(String),
84}
85
86/// A reusable projection source that waits for an orchestrated client build.
87#[doc(hidden)]
88#[derive(Debug, Clone)]
89pub struct PendingProjectionManifests {
90    state: std::sync::Arc<(std::sync::Mutex<ProjectionBarrierState>, std::sync::Condvar)>,
91}
92
93/// Completes a pending projection source exactly once.
94#[doc(hidden)]
95pub struct ProjectionManifestCompleter {
96    state: std::sync::Arc<(std::sync::Mutex<ProjectionBarrierState>, std::sync::Condvar)>,
97    completed: bool,
98}
99
100/// One projection manifest supplied to a build.
101#[derive(Debug, Clone)]
102pub enum ProjectionManifestSource {
103    /// Read and validate a manifest from disk.
104    Path(std::path::PathBuf),
105    /// Validate an already parsed/transported manifest JSON object.
106    ///
107    /// `manifest_path` is its logical disk location and anchors the manifest's
108    /// relative `root` plus stale input/output checks.
109    Inline {
110        /// Logical path the manifest would occupy on disk.
111        manifest_path: std::path::PathBuf,
112        /// Canonical manifest JSON.
113        json: String,
114    },
115    /// Reuse metadata that has already passed schema, hash, merge, and stale
116    /// validation.
117    Prepared(PreparedProjectionManifests),
118    /// Wait for an orchestrator to validate the completed client bundle.
119    #[doc(hidden)]
120    Pending(PendingProjectionManifests),
121}
122
123impl From<std::path::PathBuf> for ProjectionManifestSource {
124    fn from(path: std::path::PathBuf) -> Self {
125        Self::Path(path)
126    }
127}
128
129/// Validate and merge projection sources once for repeated builds.
130///
131/// This is intended for orchestrators such as `webui-press` that build many
132/// page protocols against one completed client bundle.
133pub fn prepare_projection_manifests(
134    sources: &[ProjectionManifestSource],
135) -> Result<PreparedProjectionManifests, WebUIError> {
136    let snapshot = projection::load_and_merge(sources)?
137        .unwrap_or_else(|| std::sync::Arc::new(projection::ProjectionSnapshot::default()));
138    Ok(PreparedProjectionManifests { snapshot })
139}
140
141/// Resolve preload hints for compiler-generated entries using exact output
142/// identities and host-provided served URLs.
143///
144/// This is an internal orchestration hook for hosts such as `webui-press`.
145/// Resolution consumes the manifest's pre-sorted closures and performs no
146/// graph traversal or sorting.
147#[doc(hidden)]
148#[must_use]
149pub fn resolve_generated_module_preloads(
150    projection: &PreparedProjectionManifests,
151    existing_hrefs: &[String],
152    entry_outputs: &[&Path],
153    output_urls: &std::collections::BTreeMap<std::path::PathBuf, String>,
154) -> (Vec<String>, Vec<Diagnostic>) {
155    let mut resolved = module_preload::resolve_exact(
156        &projection.snapshot.entry_closures,
157        existing_hrefs,
158        entry_outputs,
159        output_urls,
160    );
161    (
162        std::mem::take(&mut resolved.hrefs),
163        std::mem::take(&mut resolved.warnings),
164    )
165}
166
167/// Return the exact physical outputs needed to map generated preload URLs.
168#[doc(hidden)]
169#[must_use]
170pub fn generated_module_preload_outputs(
171    projection: &PreparedProjectionManifests,
172    entry_outputs: &[&Path],
173) -> Vec<std::path::PathBuf> {
174    module_preload::exact_output_identities(&projection.snapshot.entry_closures, entry_outputs)
175}
176
177/// Create a pending projection source and its one-shot completer.
178#[doc(hidden)]
179#[must_use]
180pub fn projection_manifest_barrier() -> (ProjectionManifestSource, ProjectionManifestCompleter) {
181    let state = std::sync::Arc::new((
182        std::sync::Mutex::new(ProjectionBarrierState::Waiting),
183        std::sync::Condvar::new(),
184    ));
185    (
186        ProjectionManifestSource::Pending(PendingProjectionManifests {
187            state: std::sync::Arc::clone(&state),
188        }),
189        ProjectionManifestCompleter {
190            state,
191            completed: false,
192        },
193    )
194}
195
196impl PendingProjectionManifests {
197    fn wait(&self) -> Result<PreparedProjectionManifests, WebUIError> {
198        let (lock, ready) = self.state.as_ref();
199        let mut state = lock.lock().map_err(|_| {
200            WebUIError::InvalidBuildOptions("projection synchronization lock poisoned".to_string())
201        })?;
202        loop {
203            match &*state {
204                ProjectionBarrierState::Waiting => {
205                    state = ready.wait(state).map_err(|_| {
206                        WebUIError::InvalidBuildOptions(
207                            "projection synchronization lock poisoned".to_string(),
208                        )
209                    })?;
210                }
211                ProjectionBarrierState::Ready(projection) => {
212                    return Ok(projection.clone());
213                }
214                ProjectionBarrierState::Failed(message) => {
215                    return Err(WebUIError::InvalidBuildOptions(message.clone()));
216                }
217            }
218        }
219    }
220}
221
222impl ProjectionManifestCompleter {
223    /// Wake waiting builds with validated metadata or a producer failure.
224    pub fn complete(mut self, result: std::result::Result<PreparedProjectionManifests, String>) {
225        let (lock, ready) = self.state.as_ref();
226        if let Ok(mut state) = lock.lock() {
227            *state = match result {
228                Ok(projection) => ProjectionBarrierState::Ready(projection),
229                Err(message) => ProjectionBarrierState::Failed(message),
230            };
231            self.completed = true;
232            ready.notify_all();
233        }
234    }
235}
236
237impl Drop for ProjectionManifestCompleter {
238    fn drop(&mut self) {
239        if self.completed {
240            return;
241        }
242        let (lock, ready) = self.state.as_ref();
243        if let Ok(mut state) = lock.lock() {
244            *state = ProjectionBarrierState::Failed(
245                "projection producer terminated before completing metadata".to_string(),
246            );
247            ready.notify_all();
248        }
249    }
250}
251
252/// Options for building a WebUI application.
253#[derive(Debug, Clone)]
254pub struct BuildOptions {
255    /// Path to the application folder containing templates.
256    pub app_dir: std::path::PathBuf,
257    /// Entry HTML file name (e.g., `"index.html"`).
258    pub entry: String,
259    /// CSS delivery strategy for component stylesheets.
260    pub css: CssStrategy,
261    /// Fallback DOM strategy for components without an authored Shadow root.
262    pub dom: DomStrategy,
263    /// Whether to merge component stylesheets into bundled chunks.
264    ///
265    /// Composes with [`BuildOptions::css`] instead of replacing it: bundling
266    /// decides how stylesheets are grouped, the strategy decides how they reach
267    /// the page. Stylesheets reached from more than one CSS tree are split into
268    /// their own chunk so they are downloaded and cached once.
269    pub css_bundle: bool,
270    /// Framework plugin to load.
271    pub plugin: Option<Plugin>,
272    /// Additional component sources (npm packages or local paths).
273    pub components: Vec<String>,
274    /// Additional root components to compile for static component asset emission.
275    ///
276    /// These roots are parsed into the protocol so their templates, styles, and
277    /// dependency closures can be emitted later, but they are not connected to
278    /// the entry fragment and therefore are not rendered during initial SSR.
279    pub component_asset_roots: Vec<String>,
280    /// Whether to serialize an esbuild-compatible component asset metafile.
281    pub metafile: bool,
282    /// Emitted Link-mode CSS filename template using `[name]`, `[hash]`, and `[ext]`.
283    pub css_file_name_template: String,
284    /// Optional URL/base-path prefix for Link-mode `css_href` values.
285    /// When set, emitted protocol hrefs become `<base>/<filename>`.
286    pub css_public_base: Option<String>,
287    /// Legal comment preservation strategy.
288    pub legal_comments: LegalComments,
289    /// Optional loaded design-token theme used to validate discovered CSS tokens.
290    ///
291    /// When present, every discovered CSS token that remains unresolved after
292    /// local and ancestor CSS definitions are removed must exist in every theme,
293    /// unless the `var()` usage supplies a literal CSS fallback (e.g.
294    /// `var(--x, 16px)`), which provides its own value. Missing required tokens
295    /// fail the build as parser diagnostics.
296    pub theme: Option<TokenFile>,
297    /// Bundler-neutral state projection manifest fragments supplied as disk
298    /// paths or inline JSON with a logical manifest path.
299    ///
300    /// Empty (the default) means projection is disabled: the build emits
301    /// [`webui_protocol::InitialStateStrategy::Full`] and every scripted
302    /// component keeps a full-state (`All`) partial-navigation surface. No
303    /// JavaScript/TypeScript analysis ever runs in Rust.
304    ///
305    /// When non-empty, every manifest is loaded, hash-validated against the
306    /// files it declares, merged by component tag, and used to narrow each
307    /// scripted component that is actually compiled into the protocol down to
308    /// an exact hydration/navigation key surface. Every such scripted
309    /// component must have exactly one manifest entry, or the build fails
310    /// with [`WebUIError::Projection`] (`PROJ-B001`). Supplying manifests with
311    /// a `plugin` other than [`Plugin::WebUI`] fails the build immediately
312    /// (`PROJ-B002`): only the WebUI plugin produces protocol fields
313    /// compatible with per-component key encoding.
314    ///
315    /// Manifests are read only at build time. The runtime handler never
316    /// opens a manifest file; the compiled protocol is fully self-contained.
317    pub projection_manifests: Vec<ProjectionManifestSource>,
318}
319
320impl Default for BuildOptions {
321    fn default() -> Self {
322        Self {
323            app_dir: std::path::PathBuf::from("."),
324            entry: "index.html".to_string(),
325            css: CssStrategy::Link,
326            dom: DomStrategy::Shadow,
327            css_bundle: false,
328            plugin: None,
329            components: Vec::new(),
330            component_asset_roots: Vec::new(),
331            metafile: false,
332            css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
333            css_public_base: None,
334            legal_comments: LegalComments::default(),
335            theme: None,
336            projection_manifests: Vec::new(),
337        }
338    }
339}
340
341/// Statistics about a completed build.
342#[derive(Debug, Clone)]
343pub struct BuildStats {
344    /// Total wall-clock build time.
345    pub duration: std::time::Duration,
346    /// Total number of protocol fragments.
347    pub fragment_count: usize,
348    /// Number of registered components.
349    pub component_count: usize,
350    /// Number of CSS files produced.
351    pub css_file_count: usize,
352    /// Size of the serialized protocol in bytes.
353    pub protocol_size_bytes: usize,
354    /// Number of unique CSS tokens discovered.
355    pub token_count: usize,
356}
357
358/// Result of a successful build.
359#[derive(Debug)]
360pub struct BuildResult {
361    /// The compiled WebUI protocol.
362    pub protocol: WebUIProtocol,
363    /// Serialized protocol bytes (protobuf binary).
364    pub protocol_bytes: Vec<u8>,
365    /// Component CSS files: `(filename, content)` — only components referenced in the protocol.
366    pub css_files: Vec<(String, String)>,
367    /// Static component asset files: `(filename, ESM module content)`.
368    ///
369    /// Populated when [`BuildOptions::component_asset_roots`] is non-empty.
370    pub component_asset_files: Vec<ComponentAssetFile>,
371    /// Esbuild-compatible component asset metafile JSON when requested.
372    pub metafile: Option<String>,
373    /// Component client template payloads.
374    /// Includes templates for all components encountered during parsing,
375    /// including route-referenced components.
376    pub component_templates: Vec<ComponentTemplateArtifact>,
377    /// Non-fatal build advisories as warning-severity [`Diagnostic`]s.
378    ///
379    /// Includes parser advisories and CSS token warnings. Carries the same
380    /// structured location/snippet/`help:` data as errors; the entry point
381    /// decides how to present (and color) them.
382    pub warnings: Vec<Diagnostic>,
383    /// Build statistics.
384    pub stats: BuildStats,
385}
386
387/// Advisory for a streaming entry compiled without a state-projection
388/// manifest.
389///
390/// Cold: constructed at most once per build, and only for streaming entries.
391#[cold]
392#[inline(never)]
393fn streaming_without_projection_warning(entry: &str, boundary_count: usize) -> Diagnostic {
394    let plural = if boundary_count == 1 { "" } else { "s" };
395    Diagnostic::warning(format!(
396        "{entry} declares {boundary_count} streaming boundary checkpoint{plural} \
397         but this build has no state-projection manifest"
398    ))
399    .code(webui_parser::codes::STREAMING_WITHOUT_PROJECTION)
400    .help(
401        "every checkpoint will serialize the whole state object instead of its own \
402         components' keys; pass the client build's `webui-projection.json` through \
403         `BuildOptions::projection_manifests` to send boundary-local state only",
404    )
405}
406
407/// Build a WebUI application from an app directory.
408///
409/// Parses templates, discovers components, and produces a compiled protocol
410/// with build statistics.
411///
412/// # Errors
413///
414/// Returns [`WebUIError`] if the app directory is invalid, templates fail
415/// to parse, theme tokens are incomplete, or the protocol cannot be serialized.
416#[must_use = "BuildResult contains the compiled protocol and statistics"]
417pub fn build(options: BuildOptions) -> Result<BuildResult, WebUIError> {
418    let started = Instant::now();
419
420    let raw = build_protocol_inner(&options)?;
421
422    let protocol_bytes = raw.protocol.to_protobuf()?;
423
424    let stats = BuildStats {
425        duration: started.elapsed(),
426        fragment_count: raw.fragment_count,
427        component_count: raw.component_count,
428        css_file_count: raw.css_files.len(),
429        protocol_size_bytes: protocol_bytes.len(),
430        token_count: raw.token_count,
431    };
432
433    Ok(BuildResult {
434        protocol: raw.protocol,
435        protocol_bytes,
436        css_files: raw.css_files,
437        component_asset_files: raw.component_asset_files,
438        metafile: raw.metafile,
439        component_templates: raw.component_templates,
440        warnings: raw.warnings,
441        stats,
442    })
443}
444
445/// Build a WebUI application and write output files to disk.
446///
447/// Writes `protocol.bin`, any external CSS files, and static component assets
448/// to `out_dir`.
449/// Creates `out_dir` if it does not exist. Component dependencies publish before
450/// roots, unchanged component inputs keep their modification times, and existing
451/// files outside the current build are left alone.
452///
453/// # Errors
454///
455/// Returns [`WebUIError`] on build failure or if output files cannot be written.
456pub fn build_to_disk(options: BuildOptions, out_dir: &Path) -> Result<BuildStats, WebUIError> {
457    let result = build(options)?;
458    validate_output_file_names(OsStr::new("protocol.bin"), &result)?;
459
460    fs::create_dir_all(out_dir).map_err(|source| WebUIError::Io {
461        context: format!("Failed to create {}", out_dir.display()),
462        source,
463    })?;
464
465    fs::write(out_dir.join("protocol.bin"), &result.protocol_bytes).map_err(|source| {
466        WebUIError::Io {
467            context: format!("Failed to write protocol.bin to {}", out_dir.display()),
468            source,
469        }
470    })?;
471
472    for (name, content) in &result.css_files {
473        fs::write(out_dir.join(name), content).map_err(|source| WebUIError::Io {
474            context: format!("Failed to write {name} to {}", out_dir.display()),
475            source,
476        })?;
477    }
478    if !result.component_asset_files.is_empty() {
479        component_asset_output::publish(out_dir, &result.component_asset_files)?;
480    }
481
482    Ok(result.stats)
483}
484
485/// Inspect a compiled WebUI protocol file and return its JSON representation.
486pub fn inspect(protocol_path: &Path) -> Result<String, WebUIError> {
487    let bytes = fs::read(protocol_path).map_err(|source| WebUIError::Io {
488        context: format!("Failed to read {}", protocol_path.display()),
489        source,
490    })?;
491    inspect_bytes(&bytes)
492}
493
494/// Inspect raw protocol bytes and return their JSON representation.
495pub fn inspect_bytes(protocol_bytes: &[u8]) -> Result<String, WebUIError> {
496    let protocol = WebUIProtocol::from_protobuf(protocol_bytes)?;
497    protocol
498        .to_json_pretty()
499        .map_err(|e| WebUIError::Serialization(e.to_string()))
500}
501
502/// Internal intermediate build output before stats are computed.
503struct RawBuildOutput {
504    protocol: WebUIProtocol,
505    css_files: Vec<(String, String)>,
506    component_asset_files: Vec<ComponentAssetFile>,
507    metafile: Option<String>,
508    component_templates: Vec<ComponentTemplateArtifact>,
509    warnings: Vec<Diagnostic>,
510    fragment_count: usize,
511    component_count: usize,
512    token_count: usize,
513}
514
515/// Internal build logic shared by `build()` and `build_to_disk()`.
516fn build_protocol_inner(options: &BuildOptions) -> Result<RawBuildOutput, WebUIError> {
517    let projection_enabled = !options.projection_manifests.is_empty();
518    if projection_enabled && options.plugin != Some(Plugin::WebUI) {
519        return Err(projection::incompatible_plugin_error());
520    }
521    if !options.component_asset_roots.is_empty() && options.plugin != Some(Plugin::WebUI) {
522        return Err(WebUIError::InvalidBuildOptions(
523            "component assets require --plugin webui because generated entries use the WebUI Framework registration runtime"
524                .to_string(),
525        ));
526    }
527    if options.css_bundle && options.css == CssStrategy::Module {
528        return Err(css_bundle_module_error());
529    }
530    let parser_options = ParserOptions::try_new(
531        options.css,
532        options.dom,
533        &options.css_file_name_template,
534        options.css_public_base.as_deref(),
535        options.legal_comments,
536    )
537    .map_err(|e| WebUIError::InvalidBuildOptions(e.to_string()))?;
538    let css_link_options = parser_options.css_link_options.clone();
539
540    let mut parser = match options.plugin {
541        Some(Plugin::Fast | Plugin::FastV2) => {
542            HtmlParser::with_plugin_options(Box::new(FastV2ParserPlugin::new()), parser_options)
543        }
544        Some(Plugin::FastV3) => {
545            HtmlParser::with_plugin_options(Box::new(FastV3ParserPlugin::new()), parser_options)
546        }
547        Some(Plugin::WebUI) => {
548            HtmlParser::with_plugin_options(Box::new(WebUIParserPlugin::new()), parser_options)
549        }
550        None => HtmlParser::with_options(parser_options),
551    };
552    let discovery_plugin: Box<dyn DiscoveryPlugin> = match options.plugin {
553        Some(Plugin::Fast | Plugin::FastV2 | Plugin::FastV3) => {
554            Box::new(FastDiscoveryPlugin::new())
555        }
556        Some(Plugin::WebUI) | None => Box::new(WebUIDiscoveryPlugin::new()),
557    };
558
559    let app_components = discovery_plugin
560        .discover_local(&options.app_dir)
561        .map_err(|error| {
562            WebUIError::ComponentDiscovery(format!(
563                "Failed to discover components from {}: {error}",
564                options.app_dir.display()
565            ))
566        })?;
567    for component in &app_components {
568        parser
569            .component_registry_mut()
570            .register_component(webui_parser::ComponentRegistration {
571                tag_name: &component.tag_name,
572                html_content: &component.html_content,
573                css_content: component.css_content.as_deref(),
574                is_client_owned: component.is_client_owned,
575            })
576            .map_err(|source| WebUIError::ComponentRegistration {
577                context: format!(
578                    "Failed to register component '{}' from {}",
579                    component.tag_name, component.source
580                ),
581                source,
582            })?;
583    }
584
585    // Discover and register external component sources
586    for source in &options.components {
587        let result = webui_discovery::discover_source_with_plugin(
588            source,
589            &options.app_dir,
590            discovery_plugin.as_ref(),
591        )
592        .map_err(|e| {
593            WebUIError::ComponentDiscovery(format!(
594                "Failed to discover components from {source}: {e}"
595            ))
596        })?;
597        for comp in &result.components {
598            // Script presence marks client ownership. Rust never analyzes the
599            // source; exact state surfaces come from projection manifests.
600            parser
601                .component_registry_mut()
602                .register_component(webui_parser::ComponentRegistration {
603                    tag_name: &comp.tag_name,
604                    html_content: &comp.html_content,
605                    css_content: comp.css_content.as_deref(),
606                    is_client_owned: comp.is_client_owned,
607                })
608                .map_err(|source| WebUIError::ComponentRegistration {
609                    context: format!(
610                        "Failed to register component '{}' from {}",
611                        comp.tag_name, comp.source
612                    ),
613                    source,
614                })?;
615        }
616    }
617
618    let component_count = parser.component_registry().len();
619
620    // Parse entry HTML
621    let entry_path = options.app_dir.join(&options.entry);
622    let html_content = fs::read_to_string(&entry_path).map_err(|source| WebUIError::Io {
623        context: format!("Failed to read {}", entry_path.display()),
624        source,
625    })?;
626    parser
627        .parse(&options.entry, &html_content)
628        .map_err(|source| WebUIError::Parse {
629            context: format!("Failed to parse {}", options.entry),
630            source,
631        })?;
632
633    // Read before synthetic asset roots parse because module entry sources are
634    // entry-local. Boundary declarations live in the fragment graph itself.
635    let module_entry_srcs = parser.module_entry_srcs().to_vec();
636
637    let synthetic_asset_fragments =
638        parse_component_asset_roots(&mut parser, &options.component_asset_roots)?;
639
640    let component_render_css = parser.component_registry().render_policy_css(
641        parser
642            .component_registry()
643            .get_all()
644            .filter(|component| parser.has_fragment(&component.tag_name))
645            .map(|component| component.tag_name.as_str()),
646    );
647    let css_snapshot: Vec<(String, String)> = parser
648        .component_registry()
649        .get_all()
650        .filter(|component| parser.has_fragment(&component.tag_name))
651        .filter_map(|component| {
652            component
653                .css_content
654                .as_ref()
655                .map(|css| (component.tag_name.clone(), css.clone()))
656        })
657        .collect();
658    let component_shadow_dom_usage: Vec<(String, bool)> = parser
659        .component_shadow_dom_usage()
660        .map(|(tag_name, uses_shadow_dom)| (tag_name.to_string(), uses_shadow_dom))
661        .collect();
662    let component_work_policies: Vec<(String, u8)> = parser
663        .component_work_policies()
664        .map(|(tag_name, policy)| (tag_name.to_string(), policy))
665        .collect();
666
667    // Collect CSS token analysis before consuming the parser.
668    let token_analysis = parser.token_analysis();
669    let mut warnings: Vec<Diagnostic> = parser.take_warnings();
670    if let Some(theme) = options.theme.as_ref() {
671        token_analysis
672            .validate_theme_tokens(theme)
673            .map_err(|source| WebUIError::Parse {
674                context: "Failed to validate theme tokens".to_string(),
675                source,
676            })?;
677        // Advisory: a token used only as `var(--x, <literal>)` and absent from
678        // every theme is often a misspelling. The literal keeps the build green,
679        // so this is a warning (with a "did you mean …?" suggestion), not an
680        // error.
681        warnings.extend(token_analysis.theme_token_warnings(theme));
682    }
683    let token_count = token_analysis.protocol_tokens.len();
684
685    let component_templates =
686        match parser
687            .take_plugin_artifacts()
688            .map_err(|source| WebUIError::Parse {
689                context: format!(
690                    "Failed to compile component templates for {}",
691                    options.entry
692                ),
693                source,
694            })? {
695            ParserPluginArtifacts::None => Vec::new(),
696            ParserPluginArtifacts::ComponentTemplates(templates) => templates,
697        };
698    // Build protocol (consumes parser)
699    let mut fragment_records = parser.into_fragment_records();
700    for fragment_id in synthetic_asset_fragments {
701        fragment_records.remove(&fragment_id);
702    }
703    let boundary_count = fragment_records
704        .values()
705        .flat_map(|list| list.fragments.iter())
706        .filter(|fragment| {
707            matches!(
708                fragment.fragment.as_ref(),
709                Some(webui_protocol::web_ui_fragment::Fragment::Boundary(boundary))
710                    if boundary.phase() == webui_protocol::BoundaryPhase::Start
711            )
712        })
713        .count();
714    // Resolve projection only after template compilation. Ordinary path/inline
715    // builds pay the same validation cost, while orchestrators can overlap
716    // parser work with an in-flight client bundle through a pending source.
717    let merged_manifest = projection::load_and_merge(&options.projection_manifests)?;
718    let mut protocol = WebUIProtocol::with_tokens(fragment_records, token_analysis.protocol_tokens);
719    protocol.initial_state_strategy = if merged_manifest.is_some() {
720        webui_protocol::InitialStateStrategy::Components as i32
721    } else {
722        webui_protocol::InitialStateStrategy::Full as i32
723    };
724
725    // Advisory: without a projection manifest every streaming checkpoint falls
726    // back to serializing the entire state object, so the cost is
727    // O(boundaries x full state) rather than O(boundaries x that boundary's
728    // own keys). The build still works, so this is a warning.
729    if boundary_count > 0 && merged_manifest.is_none() {
730        warnings.push(streaming_without_projection_warning(
731            &options.entry,
732            boundary_count,
733        ));
734    }
735
736    // Resolve `modulepreload` hints once, at build time. The shared chunks an
737    // entry statically imports are named only inside that entry's bytes, so
738    // the preload scanner cannot find them; hinting them removes a serialized
739    // round trip. The handler writes the result verbatim.
740    if let Some(merged) = &merged_manifest {
741        let mut preloads = module_preload::resolve(&merged.entry_closures, &module_entry_srcs);
742        protocol.module_preloads = std::mem::take(&mut preloads.hrefs);
743        warnings.append(&mut preloads.warnings);
744    }
745
746    // Record CSS delivery and per-component Shadow DOM ownership.
747    protocol.set_css_strategy(match options.css {
748        CssStrategy::Link => webui_protocol::CssStrategy::Link,
749        CssStrategy::Style => webui_protocol::CssStrategy::Style,
750        CssStrategy::Module => webui_protocol::CssStrategy::Module,
751    });
752    for (tag_name, uses_shadow_dom) in component_shadow_dom_usage {
753        if !protocol.fragments.contains_key(&tag_name) {
754            continue;
755        }
756        protocol
757            .components
758            .entry(tag_name)
759            .or_default()
760            .uses_shadow_dom = uses_shadow_dom;
761    }
762    for (tag_name, work_policy) in component_work_policies {
763        if protocol.fragments.contains_key(&tag_name) {
764            protocol.components.entry(tag_name).or_default().work_policy = i32::from(work_policy);
765        }
766    }
767    protocol.component_render_css = component_render_css;
768
769    // Process component CSS in a single pass: retain compiled Style/Module CSS,
770    // set Link-strategy css_href, and collect external CSS files.
771    let stores_css = options.css != CssStrategy::Link;
772    let mut css_files: Vec<(String, String)> = Vec::new();
773    let mut emitted_names: HashSet<String> = HashSet::new();
774    let mut bundle_inputs: Vec<(String, String)> = Vec::new();
775    for (tag, css) in css_snapshot {
776        if !protocol.fragments.contains_key(&tag) {
777            continue;
778        }
779        if options.css_bundle {
780            bundle_inputs.push((tag.clone(), css.clone()));
781        }
782        if stores_css {
783            protocol.components.entry(tag).or_default().css = css;
784        } else {
785            let resolved = css_link_options.resolve(&tag, &css);
786            if !emitted_names.insert(resolved.filename.clone()) {
787                return Err(WebUIError::InvalidBuildOptions(format!(
788                    "CSS filename collision for Link strategy: '{}'. Adjust the asset filename template to include [name] or another unique component-specific segment.",
789                    resolved.filename
790                )));
791            }
792            protocol.components.entry(tag).or_default().css_href = resolved.href;
793            css_files.push((resolved.filename, css));
794        }
795    }
796
797    // CSS order is load-bearing, so compute this independently from the sorted
798    // component-asset reachability sets.
799    protocol.populate_style_closures(&[options.entry.as_str()]);
800
801    if options.css_bundle {
802        // Chunks are planned from the closures above, so this has to run after
803        // them and after per-component resources exist to plan over.
804        let by_tag = bundle_inputs
805            .iter()
806            .map(|(tag, css)| (tag.as_str(), css.as_str()))
807            .collect();
808        let chunk_files = style_bundle::bundle_style_chunks(
809            &mut protocol,
810            options.entry.as_str(),
811            &by_tag,
812            &css_link_options,
813        )?;
814        if !stores_css {
815            // Keep component files as compatibility and independently-loaded
816            // component fallbacks. Current handlers render chunk links, so these
817            // files add no requests to the normal bundled path.
818            for (filename, css) in chunk_files {
819                if emitted_names.insert(filename.clone()) {
820                    css_files.push((filename, css));
821                    continue;
822                }
823                // A single-component chunk resolves to that component's own
824                // filename and bytes, so the file is already written.
825                let identical = css_files
826                    .iter()
827                    .any(|(name, existing)| *name == filename && *existing == css);
828                if !identical {
829                    return Err(WebUIError::InvalidBuildOptions(format!(
830                        "CSS filename collision between bundled chunk '{filename}' and a component stylesheet of the same name. Adjust the asset filename template to include [hash] or another unique segment."
831                    )));
832                }
833            }
834        }
835    }
836
837    // Store compiled client templates in the protocol so any host server can
838    // query them. Scriptless templates retain navigation metadata but contribute
839    // no initial hydration state.
840    for artifact in &component_templates {
841        let component = protocol
842            .components
843            .entry(artifact.tag_name.clone())
844            .or_default();
845        component.template = artifact.template.clone();
846        component.template_json = artifact.template_json.clone();
847        component.template_functions = artifact.template_functions.clone();
848        // Manifest keys are the initial hydration surface. Navigation is the
849        // union of manifest client keys and Rust-compiled template roots.
850        // Scriptless components are never governed by a manifest: they keep
851        // their Rust-derived surface regardless of projection.
852        let manifest_entry = if artifact.is_scripted {
853            merged_manifest
854                .as_ref()
855                .and_then(|merged| merged.components.get(&artifact.tag_name))
856        } else {
857            None
858        };
859        let (hydration_mode, hydration_keys) = match manifest_entry {
860            Some(entry) => encode_state_surface(&StateSurface::Keys(entry.hydration_keys.clone())),
861            None => encode_state_surface(&artifact.hydration),
862        };
863        component.hydration_mode = hydration_mode;
864        component.hydration_keys = hydration_keys;
865        let (navigation_mode, navigation_keys) = match manifest_entry {
866            Some(entry) => {
867                let union =
868                    projection::union_keys(&entry.navigation_keys, &artifact.template_roots);
869                encode_state_surface(&StateSurface::Keys(union))
870            }
871            None => encode_state_surface(&artifact.navigation),
872        };
873        component.navigation_mode = Some(navigation_mode);
874        component.navigation_keys = navigation_keys;
875    }
876
877    let mut component_asset_graph = component_assets::render_component_assets(
878        &protocol,
879        &options.entry,
880        &options.component_asset_roots,
881        options.metafile,
882    )?;
883    component_asset_graph.retain_entry_protocol(&mut protocol)?;
884    // Strict projection coverage applies to scripted components retained in the
885    // entry protocol. Asset-only roots use their static template payloads and
886    // never participate in protocol projection.
887    if let Some(merged) = &merged_manifest {
888        let entry_scripted_tags: Vec<&str> = component_templates
889            .iter()
890            .filter(|artifact| {
891                artifact.is_scripted && protocol.fragments.contains_key(&artifact.tag_name)
892            })
893            .map(|artifact| artifact.tag_name.as_str())
894            .collect();
895        projection::validate_coverage(&merged.components, &entry_scripted_tags)?;
896    }
897    let fragment_count = protocol
898        .fragments
899        .values()
900        .map(|fragments| fragments.fragments.len())
901        .sum();
902    let component_asset_files = component_asset_graph.files;
903    validate_generated_file_names(&css_files, &component_asset_files)?;
904
905    Ok(RawBuildOutput {
906        protocol,
907        css_files,
908        component_asset_files,
909        metafile: component_asset_graph.metafile,
910        component_templates,
911        warnings,
912        fragment_count,
913        component_count,
914        token_count,
915    })
916}
917
918fn encode_state_surface(surface: &StateSurface) -> (i32, Vec<String>) {
919    match surface {
920        StateSurface::None => (webui_protocol::StateProjectionMode::None as i32, Vec::new()),
921        StateSurface::Keys(keys) => (
922            webui_protocol::StateProjectionMode::Keys as i32,
923            keys.clone(),
924        ),
925        StateSurface::All => (webui_protocol::StateProjectionMode::All as i32, Vec::new()),
926    }
927}
928
929fn parse_component_asset_roots(
930    parser: &mut HtmlParser,
931    roots: &[String],
932) -> Result<Vec<String>, WebUIError> {
933    let mut synthetic_fragments = Vec::with_capacity(roots.len());
934    for (index, root) in roots.iter().enumerate() {
935        let root = root.trim();
936        if parser.has_fragment(root) {
937            continue;
938        }
939        // Compile the requested lazy root without connecting it to the entry
940        // graph, so it can be emitted as a static asset but stays out of SSR.
941        let mut synthetic = String::with_capacity(root.len() * 2 + 5);
942        synthetic.push('<');
943        synthetic.push_str(root);
944        synthetic.push_str("></");
945        synthetic.push_str(root);
946        synthetic.push('>');
947
948        let fragment_id = format!("__webui_asset_root_{index}");
949        parser
950            .parse(&fragment_id, &synthetic)
951            .map_err(|source| WebUIError::Parse {
952                context: format!("Failed to parse component asset root <{root}>"),
953                source,
954            })?;
955        synthetic_fragments.push(fragment_id);
956    }
957    Ok(synthetic_fragments)
958}
959
960fn validate_generated_file_names(
961    css_files: &[(String, String)],
962    component_asset_files: &[ComponentAssetFile],
963) -> Result<(), WebUIError> {
964    let mut names = HashSet::with_capacity(css_files.len() + component_asset_files.len());
965    for (name, _) in css_files {
966        names.insert(name.as_str());
967    }
968    for file in component_asset_files {
969        if !names.insert(file.name.as_str()) {
970            return Err(WebUIError::InvalidBuildOptions(format!(
971                "output filename collision for '{}'. Adjust the asset filename template to include [ext] or another unique asset-type segment.",
972                file.name
973            )));
974        }
975    }
976    Ok(())
977}
978
979fn validate_output_file_names(
980    protocol_name: &OsStr,
981    result: &BuildResult,
982) -> Result<(), WebUIError> {
983    let mut names =
984        HashSet::with_capacity(1 + result.css_files.len() + result.component_asset_files.len());
985    names.insert(protocol_name.to_os_string());
986    for (name, _) in &result.css_files {
987        let file_name = OsString::from(name);
988        if !names.insert(file_name.clone()) {
989            return Err(output_file_collision_error(&file_name));
990        }
991    }
992    for file in &result.component_asset_files {
993        let file_name = OsString::from(&file.name);
994        if !names.insert(file_name.clone()) {
995            return Err(output_file_collision_error(&file_name));
996        }
997    }
998    Ok(())
999}
1000
1001fn output_file_collision_error(name: &OsStr) -> WebUIError {
1002    WebUIError::InvalidBuildOptions(format!(
1003        "output filename collision for '{}'. Adjust the asset filename template to include [ext] or another unique asset-type segment.",
1004        name.to_string_lossy()
1005    ))
1006}
1007
1008#[cold]
1009#[inline(never)]
1010fn css_bundle_module_error() -> WebUIError {
1011    WebUIError::InvalidBuildOptions(
1012        "CSS bundling is not supported with the Module strategy. Module builds already inline every stylesheet as a data URI, so there are no requests to merge, and their import-map specifiers are resolved per component before chunks exist. Use --css link (or --css style) with --css-bundle, or drop --css-bundle.".to_string(),
1013    )
1014}
1015
1016#[cfg(test)]
1017#[allow(clippy::disallowed_methods)]
1018mod tests {
1019    use super::*;
1020    use std::collections::HashMap;
1021    use std::path::PathBuf;
1022    use tempfile::TempDir;
1023    use webui_handler::plugin::webui::WebUIHydrationPlugin;
1024    use webui_protocol::web_ui_fragment::Fragment;
1025
1026    struct StringWriter {
1027        buf: String,
1028    }
1029
1030    impl ResponseWriter for StringWriter {
1031        fn write(&mut self, content: &str) -> webui_handler::Result<()> {
1032            self.buf.push_str(content);
1033            Ok(())
1034        }
1035
1036        fn end(&mut self) -> webui_handler::Result<()> {
1037            Ok(())
1038        }
1039    }
1040
1041    fn create_app_dir(files: &[(&str, &str)]) -> TempDir {
1042        let dir = TempDir::new().unwrap();
1043        for (name, content) in files {
1044            let path = dir.path().join(name);
1045            if let Some(parent) = path.parent() {
1046                fs::create_dir_all(parent).unwrap();
1047            }
1048            fs::write(&path, content).unwrap();
1049        }
1050        dir
1051    }
1052
1053    fn default_options(app_dir: &Path) -> BuildOptions {
1054        BuildOptions {
1055            app_dir: app_dir.to_path_buf(),
1056            ..BuildOptions::default()
1057        }
1058    }
1059
1060    fn component_payload<'a>(files: &'a [ComponentAssetFile], tag: &str) -> &'a ComponentAssetFile {
1061        let mut prefix = String::with_capacity(tag.len() + 12);
1062        prefix.push_str("components/");
1063        prefix.push_str(tag);
1064        prefix.push('.');
1065        files
1066            .iter()
1067            .find(|file| file.name.starts_with(&prefix))
1068            .unwrap()
1069    }
1070
1071    fn light_options(app_dir: &Path) -> BuildOptions {
1072        BuildOptions {
1073            dom: DomStrategy::Light,
1074            ..default_options(app_dir)
1075        }
1076    }
1077
1078    mod fast;
1079
1080    #[test]
1081    fn test_build_simple_html() {
1082        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
1083        let result = build(default_options(app.path())).unwrap();
1084
1085        assert!(result.protocol.fragments.contains_key("index.html"));
1086        assert!(result.stats.fragment_count > 0);
1087        assert!(result.stats.protocol_size_bytes > 0);
1088        assert!(!result.stats.duration.is_zero());
1089        assert_eq!(
1090            result.protocol.initial_state_strategy,
1091            webui_protocol::InitialStateStrategy::Full as i32
1092        );
1093    }
1094
1095    #[test]
1096    fn light_build_uses_light_unless_component_authors_shadow() {
1097        let app = create_app_dir(&[
1098            (
1099                "index.html",
1100                "<my-card></my-card><shadow-card></shadow-card>",
1101            ),
1102            ("my-card.html", "<p>content</p>"),
1103            (
1104                "shadow-card.html",
1105                r#"<template shadowrootmode="open"><p>shadow</p></template>"#,
1106            ),
1107        ]);
1108
1109        let result = build(BuildOptions {
1110            app_dir: app.path().to_path_buf(),
1111            dom: DomStrategy::Light,
1112            ..BuildOptions::default()
1113        })
1114        .unwrap();
1115        assert!(!result.protocol.component_uses_shadow_dom("my-card"));
1116        assert!(result.protocol.component_uses_shadow_dom("shadow-card"));
1117        let light_html: String = result.protocol.fragments["my-card"]
1118            .fragments
1119            .iter()
1120            .filter_map(|fragment| match fragment.fragment.as_ref() {
1121                Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
1122                _ => None,
1123            })
1124            .collect();
1125        assert!(!light_html.contains("shadowrootmode"));
1126
1127        let shadow_html: String = result.protocol.fragments["shadow-card"]
1128            .fragments
1129            .iter()
1130            .filter_map(|fragment| match fragment.fragment.as_ref() {
1131                Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
1132                _ => None,
1133            })
1134            .collect();
1135        assert!(shadow_html.contains(r#"<template shadowrootmode="open">"#));
1136    }
1137
1138    #[test]
1139    fn build_defaults_unwrapped_components_to_shadow() {
1140        let app = create_app_dir(&[
1141            ("index.html", "<my-card></my-card>"),
1142            ("my-card.html", "<p>content</p>"),
1143        ]);
1144
1145        let result = build(default_options(app.path())).unwrap();
1146        assert!(result.protocol.component_uses_shadow_dom("my-card"));
1147        let html: String = result.protocol.fragments["my-card"]
1148            .fragments
1149            .iter()
1150            .filter_map(|fragment| match fragment.fragment.as_ref() {
1151                Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
1152                _ => None,
1153            })
1154            .collect();
1155        assert!(html.contains(r#"<template shadowrootmode="open">"#));
1156    }
1157
1158    #[test]
1159    fn build_rejects_slot_in_unwrapped_light_component() {
1160        let app = create_app_dir(&[
1161            ("index.html", "<my-card></my-card>"),
1162            ("my-card.html", "<div><slot></slot></div>"),
1163        ]);
1164
1165        let error = build(BuildOptions {
1166            app_dir: app.path().to_path_buf(),
1167            dom: DomStrategy::Light,
1168            ..BuildOptions::default()
1169        })
1170        .expect_err("unwrapped Light DOM slot must fail the build");
1171
1172        assert!(
1173            error.chain_message().contains("[light-dom-slot]"),
1174            "unexpected build error: {}",
1175            error.chain_message()
1176        );
1177    }
1178
1179    #[test]
1180    fn pending_projection_unblocks_after_template_compilation() {
1181        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
1182        let (source, completer) = projection_manifest_barrier();
1183        let options = BuildOptions {
1184            app_dir: app.path().to_path_buf(),
1185            plugin: Some(Plugin::WebUI),
1186            projection_manifests: vec![source],
1187            ..BuildOptions::default()
1188        };
1189        let handle = std::thread::spawn(move || build(options));
1190        let prepared = prepare_projection_manifests(&[]).unwrap();
1191        completer.complete(Ok(prepared));
1192        let result = handle.join().unwrap().unwrap();
1193        assert_eq!(
1194            result.protocol.initial_state_strategy,
1195            webui_protocol::InitialStateStrategy::Components as i32
1196        );
1197    }
1198
1199    /// Streaming entries pay O(boundaries x full state) without a projection
1200    /// manifest, so the build must say so.
1201    #[test]
1202    fn streaming_entry_without_projection_manifest_warns() {
1203        let app = create_app_dir(&[(
1204            "index.html",
1205            concat!(
1206                "<html><body>",
1207                r#"<boundary name="a"><p>{{one}}</p></boundary>"#,
1208                r#"<boundary name="b"><p>{{two}}</p></boundary>"#,
1209                "</body></html>",
1210            ),
1211        )]);
1212        let mut options = default_options(app.path());
1213        options.plugin = Some(Plugin::WebUI);
1214
1215        let result = build(options).unwrap();
1216
1217        assert_eq!(
1218            result.protocol.initial_state_strategy,
1219            webui_protocol::InitialStateStrategy::Full as i32
1220        );
1221        let boundary_names = |protocol: &WebUIProtocol| {
1222            protocol.fragments["index.html"]
1223                .fragments
1224                .iter()
1225                .filter_map(|fragment| match fragment.fragment.as_ref() {
1226                    Some(webui_protocol::web_ui_fragment::Fragment::Boundary(boundary))
1227                        if boundary.phase() == webui_protocol::BoundaryPhase::Start =>
1228                    {
1229                        Some(boundary.name.clone())
1230                    }
1231                    _ => None,
1232                })
1233                .collect::<Vec<_>>()
1234        };
1235        assert_eq!(boundary_names(&result.protocol), ["a", "b"]);
1236        let decoded = WebUIProtocol::from_protobuf(&result.protocol_bytes).unwrap();
1237        assert_eq!(boundary_names(&decoded), ["a", "b"]);
1238        let warning = result
1239            .warnings
1240            .iter()
1241            .find(|diag| {
1242                diag.error_code() == Some(webui_parser::codes::STREAMING_WITHOUT_PROJECTION)
1243            })
1244            .expect("a streaming build without projection must warn");
1245        assert_eq!(warning.severity(), webui_parser::Severity::Warning);
1246        assert!(
1247            warning.to_string().contains("2 streaming boundary"),
1248            "warning should report the boundary count: {warning}"
1249        );
1250        assert!(
1251            warning
1252                .help_text()
1253                .is_some_and(|help| help.contains("projection_manifests")),
1254            "warning should point at the fix: {warning}"
1255        );
1256    }
1257
1258    #[test]
1259    fn streaming_entry_with_projection_manifest_does_not_warn() {
1260        let app = create_app_dir(&[(
1261            "index.html",
1262            concat!(
1263                "<html><body>",
1264                r#"<boundary name="a"><p>{{one}}</p></boundary>"#,
1265                "</body></html>",
1266            ),
1267        )]);
1268        let manifest_json =
1269            projection::test_support::build_valid_manifest_json(app.path(), &[], &[], &[]);
1270        let manifest = projection::test_support::write_manifest(
1271            app.path(),
1272            "webui-projection.json",
1273            &manifest_json,
1274        );
1275        let mut options = default_options(app.path());
1276        options.plugin = Some(Plugin::WebUI);
1277        options.projection_manifests = vec![manifest.into()];
1278
1279        let result = build(options).unwrap();
1280
1281        assert_eq!(
1282            result.protocol.initial_state_strategy,
1283            webui_protocol::InitialStateStrategy::Components as i32
1284        );
1285        assert!(
1286            !result
1287                .warnings
1288                .iter()
1289                .any(|diag| diag.error_code()
1290                    == Some(webui_parser::codes::STREAMING_WITHOUT_PROJECTION)),
1291            "a projected streaming build must not warn"
1292        );
1293    }
1294
1295    /// The advisory is streaming-specific: ordinary entries emit one bootstrap
1296    /// block, so full state there is not a per-checkpoint multiplier.
1297    #[test]
1298    fn non_streaming_entry_without_projection_manifest_does_not_warn() {
1299        let app = create_app_dir(&[("index.html", "<html><body><p>{{one}}</p></body></html>")]);
1300        let mut options = default_options(app.path());
1301        options.plugin = Some(Plugin::WebUI);
1302
1303        let result = build(options).unwrap();
1304
1305        assert!(
1306            !result
1307                .warnings
1308                .iter()
1309                .any(|diag| diag.error_code()
1310                    == Some(webui_parser::codes::STREAMING_WITHOUT_PROJECTION)),
1311            "a non-streaming build must not warn about projection"
1312        );
1313    }
1314
1315    #[test]
1316    fn test_build_keeps_scriptless_component_dormant_until_client_use() {
1317        let app = create_app_dir(&[
1318            (
1319                "index.html",
1320                "<html><body><demo-card></demo-card></body></html>",
1321            ),
1322            ("demo-card.html", "<p>{{name}} {{serverTitle}}</p>"),
1323        ]);
1324        // An empty manifest fragment (no scripted components exist in this
1325        // app, so strict coverage trivially passes) still turns on
1326        // `InitialStateStrategy::Components`, letting scriptless components'
1327        // Rust-derived dormant surface (`None` hydration) take effect.
1328        let manifest_json =
1329            projection::test_support::build_valid_manifest_json(app.path(), &[], &[], &[]);
1330        let manifest = projection::test_support::write_manifest(
1331            app.path(),
1332            "webui-projection.json",
1333            &manifest_json,
1334        );
1335        let mut options = default_options(app.path());
1336        options.plugin = Some(Plugin::WebUI);
1337        options.projection_manifests = vec![manifest.into()];
1338        let result = build(options).unwrap();
1339        assert_eq!(
1340            result.protocol.initial_state_strategy,
1341            webui_protocol::InitialStateStrategy::Components as i32
1342        );
1343
1344        assert!(result
1345            .component_templates
1346            .iter()
1347            .any(|template| template.tag_name == "demo-card"));
1348        let component = result
1349            .protocol
1350            .components
1351            .get("demo-card")
1352            .expect("scriptless component metadata should be retained");
1353        assert!(component.hydration_keys.is_empty());
1354        assert_eq!(component.navigation_keys, ["name", "serverTitle"]);
1355        assert!(component.template_json.contains(r#""th":1"#));
1356
1357        let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
1358        let state = serde_json::json!({ "name": "Server rendered" });
1359        let protocol = Protocol::new(result.protocol.clone());
1360        let mut writer = StringWriter { buf: String::new() };
1361        handler
1362            .render(
1363                &protocol,
1364                &state,
1365                &RenderOptions::new("index.html", "/"),
1366                &mut writer,
1367            )
1368            .unwrap();
1369        assert!(writer.buf.contains("Server rendered"));
1370        assert!(writer.buf.contains(r#""state":{}"#));
1371        assert!(writer.buf.contains(r#""templates":{"demo-card":"#));
1372    }
1373
1374    #[test]
1375    fn test_build_treats_sibling_component_script_as_scripted() {
1376        let app = create_app_dir(&[
1377            ("index.html", "<demo-card></demo-card>"),
1378            (
1379                "demo-card.html",
1380                r#"<button @click="{onClick()}">{{name}}</button>"#,
1381            ),
1382            (
1383                "demo-card.ts",
1384                "import { WebUIElement } from '@microsoft/webui-framework';\n\
1385                 class DemoCard extends WebUIElement { onClick() {} }\n\
1386                 DemoCard.define('demo-card');",
1387            ),
1388        ]);
1389        let mut options = default_options(app.path());
1390        options.plugin = Some(Plugin::WebUI);
1391        let result = build(options).unwrap();
1392
1393        let template = result
1394            .component_templates
1395            .iter()
1396            .find(|template| template.tag_name == "demo-card")
1397            .unwrap();
1398        assert!(template.template_json.contains(r#""eg":[["click""#));
1399    }
1400
1401    #[test]
1402    fn test_build_populates_component_hydration_keys_from_script() {
1403        let app = create_app_dir(&[
1404            ("index.html", "<demo-card></demo-card>"),
1405            ("demo-card.html", "<p>{{name}}</p>"),
1406            (
1407                "demo-card.ts",
1408                "import { WebUIElement, attr, observable } from '@microsoft/webui-framework';\n\
1409                 class DemoCard extends WebUIElement {\n\
1410                 @observable name = '';\n\
1411                 @attr({ attribute: 'cta-href' }) ctaHref = '/x';\n\
1412                 @observable count = 0;\n\
1413                 }\n\
1414                 DemoCard.define('demo-card');",
1415            ),
1416        ]);
1417        // Rust performs no JavaScript/TypeScript analysis: the exact keys
1418        // come from a bundler-produced projection manifest, not from
1419        // scanning `demo-card.ts`.
1420        let manifest_json = projection::test_support::build_valid_manifest_json(
1421            app.path(),
1422            &[("demo-card.ts", "source")],
1423            &[("demo-card.js", "output")],
1424            &[(
1425                "demo-card",
1426                "demo-card.ts",
1427                &["demo-card.js"],
1428                &["count", "ctaHref", "name"],
1429                &["count", "ctaHref", "name"],
1430            )],
1431        );
1432        let manifest = projection::test_support::write_manifest(
1433            app.path(),
1434            "webui-projection.json",
1435            &manifest_json,
1436        );
1437        let mut options = default_options(app.path());
1438        options.plugin = Some(Plugin::WebUI);
1439        options.projection_manifests = vec![manifest.into()];
1440        let result = build(options).unwrap();
1441
1442        assert_eq!(
1443            result.protocol.initial_state_strategy,
1444            webui_protocol::InitialStateStrategy::Components as i32
1445        );
1446        let component = result.protocol.components.get("demo-card").unwrap();
1447        assert_eq!(
1448            component.hydration_mode,
1449            webui_protocol::StateProjectionMode::Keys as i32
1450        );
1451        assert_eq!(component.hydration_keys, vec!["count", "ctaHref", "name"]);
1452        assert_eq!(
1453            component.navigation_mode,
1454            Some(webui_protocol::StateProjectionMode::Keys as i32)
1455        );
1456        assert_eq!(component.navigation_keys, vec!["count", "ctaHref", "name"]);
1457    }
1458
1459    #[test]
1460    fn test_build_scripted_component_without_manifest_falls_back_to_full_state() {
1461        let app = create_app_dir(&[
1462            (
1463                "index.html",
1464                "<html><body><demo-card></demo-card></body></html>",
1465            ),
1466            ("demo-card.html", "<p>{{name}}</p>"),
1467            (
1468                "demo-card.ts",
1469                "import { WebUIElement, observable } from '@microsoft/webui-framework';\n\
1470                 class DemoCard extends WebUIElement {\n\
1471                 @observable name = '';\n\
1472                 }\n\
1473                 DemoCard.define('demo-card');",
1474            ),
1475        ]);
1476        let mut options = default_options(app.path());
1477        options.plugin = Some(Plugin::WebUI);
1478        // No `projection_manifests` supplied: without a manifest, Rust never
1479        // analyzes `demo-card.ts` and must assume the full, unproven surface.
1480        let result = build(options).unwrap();
1481
1482        assert_eq!(
1483            result.protocol.initial_state_strategy,
1484            webui_protocol::InitialStateStrategy::Full as i32
1485        );
1486        let component = result.protocol.components.get("demo-card").unwrap();
1487        assert_eq!(
1488            component.hydration_mode,
1489            webui_protocol::StateProjectionMode::All as i32
1490        );
1491        assert!(component.hydration_keys.is_empty());
1492        assert_eq!(
1493            component.navigation_mode,
1494            Some(webui_protocol::StateProjectionMode::All as i32)
1495        );
1496
1497        let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
1498        let state = serde_json::json!({ "name": "Visible" });
1499        let protocol = Protocol::new(result.protocol.clone());
1500        let mut writer = StringWriter { buf: String::new() };
1501        handler
1502            .render(
1503                &protocol,
1504                &state,
1505                &RenderOptions::new("index.html", "/"),
1506                &mut writer,
1507            )
1508            .unwrap();
1509        assert!(writer.buf.contains("Visible"));
1510        // Full-state fallback serializes the entire supplied state object,
1511        // not a narrowed key subset.
1512        assert!(writer.buf.contains(r#""state":{"name":"Visible"}"#));
1513    }
1514
1515    #[test]
1516    fn test_build_rejects_scriptless_event_component() {
1517        let app = create_app_dir(&[
1518            ("index.html", "<demo-card></demo-card>"),
1519            (
1520                "demo-card.html",
1521                r#"<button @click="{onClick()}">Click</button>"#,
1522            ),
1523        ]);
1524        let mut options = default_options(app.path());
1525        options.plugin = Some(Plugin::WebUI);
1526        let err = build(options).expect_err("scriptless events should fail");
1527
1528        let message = err.chain_message();
1529        assert!(
1530            message.contains("scriptless-event-handler"),
1531            "msg: {message}"
1532        );
1533    }
1534
1535    #[test]
1536    fn test_build_returns_actionable_error_for_invalid_w_ref() {
1537        let app = create_app_dir(&[
1538            ("index.html", "<my-card>Hello</my-card>"),
1539            ("my-card.html", r#"<div><input w-ref="myInput" /></div>"#),
1540        ]);
1541        let mut options = default_options(app.path());
1542        options.plugin = Some(Plugin::WebUI);
1543
1544        // Authoring mistakes are returned (not panicked) so every consumer —
1545        // CLI, Node, FFI, WASM — can surface the message its own way.
1546        let Err(err) = build(options) else {
1547            panic!("non-braced w-ref must fail the build");
1548        };
1549        let msg = err.chain_message();
1550        assert!(msg.contains("invalid w-ref binding"), "msg: {msg}");
1551        assert!(
1552            msg.contains("component <my-card> · element <input>"),
1553            "msg: {msg}"
1554        );
1555        assert!(msg.contains("help:"), "msg: {msg}");
1556    }
1557
1558    #[test]
1559    fn test_build_returns_actionable_error_for_invalid_event_handler() {
1560        let app = create_app_dir(&[
1561            ("index.html", "<my-btn>x</my-btn>"),
1562            (
1563                "my-btn.html",
1564                r#"<div><button @click="e.preventDefault()">x</button></div>"#,
1565            ),
1566        ]);
1567        let mut options = default_options(app.path());
1568        options.plugin = Some(Plugin::WebUI);
1569
1570        let Err(err) = build(options) else {
1571            panic!("invalid @event handler must fail the build");
1572        };
1573        let msg = err.chain_message();
1574        assert!(msg.contains("invalid @click handler"), "msg: {msg}");
1575        assert!(
1576            msg.contains("component <my-btn> · element <button>"),
1577            "msg: {msg}"
1578        );
1579    }
1580
1581    #[test]
1582    fn test_error_chain_message_has_no_duplicate_source() {
1583        let err = WebUIError::Parse {
1584            context: "Failed to parse index.html".to_string(),
1585            source: webui_parser::ParserError::Directive("Invalid for each: x".to_string()),
1586        };
1587        // Each layer's Display describes only its own level — the source is not
1588        // embedded — so anyhow's `{:#}` / chain walks never repeat it.
1589        assert_eq!(err.to_string(), "Failed to parse index.html");
1590        // The flat message includes the source exactly once (no repetition).
1591        assert_eq!(
1592            err.chain_message(),
1593            "Failed to parse index.html: Directive error: Invalid for each: x"
1594        );
1595    }
1596
1597    #[test]
1598    fn test_build_with_directives() {
1599        let html = r#"<h1>Hello</h1>
1600<for each="item in items">
1601    <p>{{item.name}}</p>
1602</for>
1603<if condition="show">
1604    <p>Visible</p>
1605</if>"#;
1606        let app = create_app_dir(&[("index.html", html)]);
1607        let result = build(default_options(app.path())).unwrap();
1608
1609        let index = &result.protocol.fragments["index.html"].fragments;
1610        assert!(index
1611            .iter()
1612            .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(_)))));
1613        assert!(index
1614            .iter()
1615            .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
1616    }
1617
1618    #[test]
1619    fn named_for_builds_recursive_component_assets_and_styles() {
1620        for (css, expected_tokens) in [
1621            (":root { --accent: red; }", &[][..]),
1622            ("tree-view { --accent: red; }", &["accent"][..]),
1623        ] {
1624            let app = create_app_dir(&[
1625                ("index.html", "<tree-view></tree-view>"),
1626                (
1627                    "tree-view.html",
1628                    r#"<for id="tree-item" each="child in items"><span>{{child.name}}</span><token-leaf></token-leaf><for id="tree-item" each="child in child.children" /></for>"#,
1629                ),
1630                ("tree-view.css", css),
1631                ("token-leaf.html", "<b>leaf</b>"),
1632                ("token-leaf.css", "b { color: var(--accent); }"),
1633            ]);
1634            let result = build(BuildOptions {
1635                plugin: Some(Plugin::WebUI),
1636                css_bundle: true,
1637                component_asset_roots: vec!["tree-view".to_string()],
1638                ..light_options(app.path())
1639            })
1640            .unwrap();
1641            assert_eq!(result.protocol.tokens, expected_tokens, "{css}");
1642            assert!(!result.component_asset_files.is_empty());
1643            assert_eq!(
1644                result.protocol.style_closures["tree-view"].component_tags,
1645                ["tree-view", "token-leaf"]
1646            );
1647            let meta: serde_json::Value =
1648                serde_json::from_str(&result.protocol.components["tree-view"].template_json)
1649                    .unwrap();
1650            let blocks = meta["b"].as_array().unwrap();
1651            assert_eq!(
1652                blocks.len(),
1653                1,
1654                "recursive depth does not expand compiled metadata"
1655            );
1656            assert_eq!(meta["r"][0][2], blocks[0]["r"][0][2]);
1657            assert!(result.protocol_bytes.len() < 16_384);
1658        }
1659    }
1660
1661    #[test]
1662    fn test_build_with_component_css() {
1663        let app = create_app_dir(&[
1664            ("index.html", "<my-card>Hello</my-card>"),
1665            (
1666                "my-card.html",
1667                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
1668            ),
1669            ("my-card.css", ".card { color: red; }"),
1670            ("my-card.ts", "export {};"),
1671        ]);
1672        let result = build(default_options(app.path())).unwrap();
1673
1674        assert_eq!(result.css_files.len(), 1);
1675        assert_eq!(result.css_files[0].0, "my-card.css");
1676        assert!(result.css_files[0].1.contains("color: red"));
1677        assert_eq!(result.stats.css_file_count, 1);
1678    }
1679
1680    #[test]
1681    fn light_css_bytes_align_across_link_style_and_module() {
1682        let authored = " \n.ready{color:red}\n ";
1683        let expected = authored;
1684
1685        for strategy in [CssStrategy::Link, CssStrategy::Style, CssStrategy::Module] {
1686            let app = create_app_dir(&[
1687                ("index.html", "<my-card></my-card>"),
1688                ("my-card.html", "<p>content</p>"),
1689                ("my-card.css", authored),
1690            ]);
1691            let mut options = light_options(app.path());
1692            options.css = strategy;
1693            if strategy == CssStrategy::Link {
1694                options.css_file_name_template = "[name]-[hash].[ext]".to_string();
1695            }
1696            let result = build(options).unwrap();
1697
1698            let actual = match strategy {
1699                CssStrategy::Link => {
1700                    let expected_name = webui_parser::CssLinkOptions::try_new(
1701                        "[name]-[hash].[ext]".to_string(),
1702                        None,
1703                    )
1704                    .unwrap()
1705                    .resolve("my-card", expected)
1706                    .filename;
1707                    assert_eq!(result.css_files[0].0, expected_name);
1708                    result.css_files[0].1.clone()
1709                }
1710                CssStrategy::Module => result.protocol.components["my-card"].css.clone(),
1711                CssStrategy::Style => {
1712                    assert_eq!(result.protocol.components["my-card"].css, expected);
1713                    result.protocol.components["my-card"].css.clone()
1714                }
1715            };
1716            assert_eq!(actual, expected, "mismatch for {strategy:?}");
1717            if strategy != CssStrategy::Link {
1718                assert_eq!(
1719                    result.protocol.component_style_resource("my-card"),
1720                    Some(expected)
1721                );
1722            }
1723            assert_eq!(
1724                result
1725                    .protocol
1726                    .style_closure("index.html")
1727                    .expect("entry closure"),
1728                ["my-card"],
1729                "Light CSS must be installed by the Document closure"
1730            );
1731
1732            let protocol = Protocol::new(result.protocol.clone());
1733            let mut writer = StringWriter { buf: String::new() };
1734            WebUIHandler::new()
1735                .render(
1736                    &protocol,
1737                    &serde_json::json!({}),
1738                    &RenderOptions::new("index.html", "/"),
1739                    &mut writer,
1740                )
1741                .unwrap();
1742            match strategy {
1743                CssStrategy::Link => {
1744                    let href = &result.protocol.components["my-card"].css_href;
1745                    assert!(writer.buf.contains(&format!(
1746                        r#"<link rel="stylesheet" href="{href}" data-webui-resource="my-card" data-webui-strategy="link">"#
1747                    )));
1748                }
1749                CssStrategy::Style | CssStrategy::Module => {
1750                    let kind = if strategy == CssStrategy::Module {
1751                        "module"
1752                    } else {
1753                        "style"
1754                    };
1755                    assert!(writer.buf.contains(&format!(
1756                        r#"<style data-webui-resource="my-card" data-webui-strategy="{kind}">{expected}</style>"#
1757                    )));
1758                }
1759            }
1760        }
1761    }
1762
1763    #[test]
1764    fn build_precomputes_style_closures_with_authored_shadow_cut() {
1765        let app = create_app_dir(&[
1766            (
1767                "index.html",
1768                "<light-card></light-card><shadow-card><after-card></after-card></shadow-card>",
1769            ),
1770            ("light-card.html", "<nested-card></nested-card>"),
1771            ("light-card.css", ".light{}"),
1772            (
1773                "shadow-card.html",
1774                r#"<template shadowrootmode="open"><nested-card></nested-card></template>"#,
1775            ),
1776            ("shadow-card.css", ".shadow{}"),
1777            ("nested-card.html", "<span>nested</span>"),
1778            ("nested-card.css", ".nested{}"),
1779            ("after-card.html", "<span>after</span>"),
1780            ("after-card.css", ".after{}"),
1781        ]);
1782        let mut options = light_options(app.path());
1783        options.css = CssStrategy::Style;
1784        let result = build(options).unwrap();
1785
1786        assert_eq!(
1787            result
1788                .protocol
1789                .style_closure("index.html")
1790                .expect("entry closure"),
1791            ["light-card", "nested-card", "after-card"]
1792        );
1793        assert_eq!(
1794            result
1795                .protocol
1796                .style_closure("shadow-card")
1797                .expect("Shadow closure"),
1798            ["shadow-card", "nested-card"]
1799        );
1800    }
1801
1802    /// A fixture that exercises both bundling outcomes at once: two components
1803    /// used only by the entry merge, and one reached from a second CSS tree is
1804    /// split out so neither tree over-delivers.
1805    fn bundling_app() -> tempfile::TempDir {
1806        create_app_dir(&[
1807            (
1808                "index.html",
1809                "<a-card></a-card><b-card></b-card><shared-card></shared-card>\
1810                 <shadow-card></shadow-card>",
1811            ),
1812            ("a-card.html", "<span>a</span>"),
1813            ("a-card.css", ".a{color:red}"),
1814            ("b-card.html", "<span>b</span>"),
1815            ("b-card.css", ".b{color:green}"),
1816            ("shared-card.html", "<span>shared</span>"),
1817            ("shared-card.css", ".shared{color:blue}"),
1818            (
1819                "shadow-card.html",
1820                r#"<template shadowrootmode="open"><shared-card></shared-card></template>"#,
1821            ),
1822            ("shadow-card.css", ".shadow{color:black}"),
1823        ])
1824    }
1825
1826    /// Concatenate a closure's CSS the way the browser cascade sees it, once
1827    /// per component and once per chunk.
1828    fn closure_css(protocol: &WebUIProtocol, root: &str) -> (String, String) {
1829        let closure = protocol.style_closures.get(root).expect("closure");
1830        let unbundled = closure
1831            .component_tags
1832            .iter()
1833            .filter_map(|tag| protocol.components.get(tag))
1834            .map(|component| component.css.as_str())
1835            .collect::<Vec<_>>()
1836            .join("\n");
1837        let bundled = closure
1838            .style_chunks
1839            .iter()
1840            .filter_map(|index| protocol.style_chunk_resource(*index))
1841            .map(|(_, css)| css)
1842            .collect::<Vec<_>>()
1843            .join("\n");
1844        (unbundled, bundled)
1845    }
1846
1847    #[test]
1848    fn bundling_preserves_the_cascade_of_every_closure() {
1849        let app = bundling_app();
1850        let mut options = light_options(app.path());
1851        options.css = CssStrategy::Style;
1852        options.css_bundle = true;
1853        let result = build(options).unwrap();
1854
1855        let roots: Vec<String> = result.protocol.style_closures.keys().cloned().collect();
1856        assert!(roots.len() >= 2, "fixture must produce several CSS trees");
1857        let mut chunked = 0usize;
1858        for root in roots {
1859            let (unbundled, bundled) = closure_css(&result.protocol, &root);
1860            // A closure the runtime never installs as a unit keeps no chunks and
1861            // falls back to per-component delivery, which is the unbundled path.
1862            if bundled.is_empty() && !unbundled.is_empty() {
1863                continue;
1864            }
1865            chunked += 1;
1866            assert_eq!(
1867                bundled, unbundled,
1868                "chunked cascade diverged from the per-component cascade for `{root}`"
1869            );
1870        }
1871        assert!(chunked >= 2, "fixture must chunk several CSS trees");
1872    }
1873
1874    #[test]
1875    fn bundling_merges_entry_local_styles_and_splits_shared_ones() {
1876        let app = bundling_app();
1877        let mut options = light_options(app.path());
1878        options.css = CssStrategy::Style;
1879        options.css_bundle = true;
1880        let result = build(options).unwrap();
1881
1882        let members: Vec<&[String]> = result
1883            .protocol
1884            .style_chunks
1885            .iter()
1886            .map(|chunk| chunk.component_tags.as_slice())
1887            .collect();
1888        assert!(
1889            members.contains(&["a-card".to_string(), "b-card".to_string()].as_slice()),
1890            "entry-local components should merge: {members:?}"
1891        );
1892        assert!(
1893            members.contains(&["shared-card".to_string()].as_slice()),
1894            "a component reached from two CSS trees must stay alone: {members:?}"
1895        );
1896
1897        // The shared chunk is the same index in both trees, so it is fetched and
1898        // cached once.
1899        let entry = result.protocol.style_closure_chunks("index.html").unwrap();
1900        let shadow = result.protocol.style_closure_chunks("shadow-card").unwrap();
1901        let shared = entry
1902            .iter()
1903            .find(|index| shadow.contains(index))
1904            .expect("both trees should share a chunk");
1905        assert_eq!(
1906            result.protocol.style_chunk_members(*shared).unwrap(),
1907            ["shared-card"]
1908        );
1909    }
1910
1911    #[test]
1912    fn bundling_is_off_by_default_and_leaves_the_protocol_unchanged() {
1913        let app = bundling_app();
1914        let mut options = default_options(app.path());
1915        options.css = CssStrategy::Style;
1916        let result = build(options).unwrap();
1917
1918        assert!(result.protocol.style_chunks.is_empty());
1919        for closure in result.protocol.style_closures.values() {
1920            assert!(closure.style_chunks.is_empty());
1921        }
1922    }
1923
1924    #[test]
1925    fn bundling_link_emits_chunks_and_component_fallback_files() {
1926        let app = bundling_app();
1927        let mut options = light_options(app.path());
1928        options.css_bundle = true;
1929        let result = build(options).unwrap();
1930
1931        let names: Vec<&str> = result
1932            .css_files
1933            .iter()
1934            .map(|(name, _)| name.as_str())
1935            .collect();
1936        for (tag, component) in &result.protocol.components {
1937            if component.css_href.is_empty() {
1938                continue;
1939            }
1940            assert!(
1941                names.contains(&component.css_href.as_str()),
1942                "`{tag}` needs its independently loaded fallback file: {names:?}"
1943            );
1944        }
1945        for chunk in &result.protocol.style_chunks {
1946            assert!(!chunk.css_href.is_empty(), "Link chunks need an href");
1947            assert!(
1948                names.contains(&chunk.css_href.as_str()),
1949                "every rendered chunk needs an emitted file: {names:?}"
1950            );
1951            assert!(
1952                chunk.css.is_empty(),
1953                "Link chunks ship their CSS as a file, not inline"
1954            );
1955        }
1956    }
1957
1958    #[test]
1959    fn bundling_keeps_component_files_when_component_assets_need_them() {
1960        let app = create_app_dir(&[
1961            (
1962                "index.html",
1963                "<a-card></a-card><b-card></b-card><shared-card></shared-card>",
1964            ),
1965            ("a-card.html", "<span>a</span>"),
1966            ("a-card.css", ".a{color:red}"),
1967            ("b-card.html", "<span>b</span>"),
1968            ("b-card.css", ".b{color:green}"),
1969            ("shared-card.html", "<span>shared</span>"),
1970            ("shared-card.css", ".shared{color:blue}"),
1971            ("lazy-panel.html", "<a-card></a-card><p>lazy</p>"),
1972            ("lazy-panel.css", ".lazy{color:gray}"),
1973            ("lazy-panel.ts", "export {};"),
1974        ]);
1975        let mut options = light_options(app.path());
1976        options.plugin = Some(Plugin::WebUI);
1977        options.css_bundle = true;
1978        options.component_asset_roots = vec!["lazy-panel".to_string()];
1979        let result = build(options).unwrap();
1980
1981        let names: Vec<&str> = result
1982            .css_files
1983            .iter()
1984            .map(|(name, _)| name.as_str())
1985            .collect();
1986        assert!(
1987            names.iter().any(|name| name.starts_with("lazy-panel")),
1988            "static assets fetch a component's own stylesheet: {names:?}"
1989        );
1990        assert!(
1991            names.iter().any(|name| name.starts_with("_chunk-")),
1992            "bundled chunks are still emitted: {names:?}"
1993        );
1994        assert!(
1995            result.component_asset_files[0]
1996                .content
1997                .contains(r#""a-card":{"kind":"link","href":"a-card.css"}"#),
1998            "a deferred CSS tree needs the exact external component resource even when the entry Document uses a bundle: {}",
1999            result.component_asset_files[0].content
2000        );
2001    }
2002
2003    /// Render a built protocol and return the emitted HTML.
2004    fn render_html(protocol: &WebUIProtocol, route: &str) -> String {
2005        let protocol = Protocol::new(protocol.clone());
2006        let mut writer = StringWriter { buf: String::new() };
2007        WebUIHandler::new()
2008            .render(
2009                &protocol,
2010                &serde_json::json!({}),
2011                &RenderOptions::new("index.html", route),
2012                &mut writer,
2013            )
2014            .unwrap();
2015        writer.buf
2016    }
2017
2018    /// Every stylesheet URL the rendered page tells the browser to fetch.
2019    fn linked_stylesheets(html: &str) -> Vec<&str> {
2020        let mut hrefs = Vec::new();
2021        let mut rest = html;
2022        while let Some(start) = rest.find("href=\"") {
2023            rest = &rest[start + "href=\"".len()..];
2024            let Some(end) = rest.find('"') else { break };
2025            let href = &rest[..end];
2026            if href.ends_with(".css") {
2027                hrefs.push(href);
2028            }
2029            rest = &rest[end..];
2030        }
2031        hrefs
2032    }
2033
2034    /// The `data-webui-resource` values in emitted order.
2035    fn emitted_resources(html: &str) -> Vec<&str> {
2036        let mut resources = Vec::new();
2037        let mut rest = html;
2038        while let Some(start) = rest.find("data-webui-resource=\"") {
2039            rest = &rest[start + "data-webui-resource=\"".len()..];
2040            match rest.find('"') {
2041                Some(end) => {
2042                    resources.push(&rest[..end]);
2043                    rest = &rest[end..];
2044                }
2045                None => break,
2046            }
2047        }
2048        resources
2049    }
2050
2051    #[test]
2052    fn bundled_rendering_emits_one_resource_per_chunk() {
2053        for strategy in [CssStrategy::Link, CssStrategy::Style] {
2054            let app = bundling_app();
2055            let mut options = light_options(app.path());
2056            options.css = strategy;
2057            options.css_bundle = true;
2058            let result = build(options).unwrap();
2059
2060            let html = render_html(&result.protocol, "/");
2061            let resources = emitted_resources(&html);
2062            assert!(
2063                resources.contains(&"_chunk-a-card-2"),
2064                "{strategy:?} should deliver the merged chunk once: {resources:?}"
2065            );
2066            assert!(
2067                !resources.contains(&"a-card") && !resources.contains(&"b-card"),
2068                "{strategy:?} must not also deliver merged members: {resources:?}"
2069            );
2070        }
2071    }
2072
2073    #[test]
2074    fn bundling_never_merges_an_authored_shadow_stylesheet() {
2075        // `shadow-card` sits between two Light components that share its exact
2076        // consumer set, so ordering alone would let all three merge.
2077        let app = create_app_dir(&[
2078            (
2079                "index.html",
2080                "<a-card></a-card><shadow-card></shadow-card><b-card></b-card>",
2081            ),
2082            ("a-card.html", "<span>a</span>"),
2083            ("a-card.css", ".a{color:red}"),
2084            ("b-card.html", "<span>b</span>"),
2085            ("b-card.css", ".b{color:green}"),
2086            (
2087                "shadow-card.html",
2088                r#"<template shadowrootmode="open"><span>s</span></template>"#,
2089            ),
2090            ("shadow-card.css", ".shadow{color:black}"),
2091        ]);
2092        let mut options = light_options(app.path());
2093        options.css = CssStrategy::Style;
2094        options.css_bundle = true;
2095        let result = build(options).unwrap();
2096
2097        let chunk = result
2098            .protocol
2099            .style_chunks
2100            .iter()
2101            .find(|chunk| chunk.component_tags.iter().any(|tag| tag == "shadow-card"))
2102            .expect("shadow-card is styled, so it belongs to a chunk");
2103        // A single-member chunk is named after its member and carries its exact
2104        // bytes, which is what keeps the parse-time `css_href` and
2105        // `shadowrootadoptedstylesheets` references valid under bundling.
2106        assert_eq!(
2107            chunk.component_tags,
2108            vec!["shadow-card".to_string()],
2109            "an authored-Shadow stylesheet must stay in a chunk of its own"
2110        );
2111        assert_eq!(chunk.name, "shadow-card");
2112        assert_eq!(
2113            chunk.css, result.protocol.components["shadow-card"].css,
2114            "its chunk must carry the component's own bytes unchanged"
2115        );
2116    }
2117
2118    #[test]
2119    fn bundling_never_links_a_stylesheet_the_build_did_not_write() {
2120        // The rendered page is the contract: every stylesheet URL the browser is
2121        // told to fetch — document links and declarative Shadow roots alike —
2122        // must name a file the bundled build actually emitted.
2123        let app = bundling_app();
2124        let mut options = light_options(app.path());
2125        options.css = CssStrategy::Link;
2126        options.css_bundle = true;
2127        let result = build(options).unwrap();
2128
2129        let emitted: HashSet<&str> = result
2130            .css_files
2131            .iter()
2132            .map(|(name, _)| name.as_str())
2133            .collect();
2134        let html = render_html(&result.protocol, "/");
2135        let linked = linked_stylesheets(&html);
2136        assert!(
2137            !linked.is_empty(),
2138            "the page should link at least one stylesheet: {html}"
2139        );
2140        for href in &linked {
2141            let name = href.rsplit('/').next().unwrap_or(href);
2142            assert!(
2143                emitted.contains(name),
2144                "page links `{href}`, which the bundled build did not write: {emitted:?}"
2145            );
2146        }
2147    }
2148
2149    #[test]
2150    fn bundling_is_rejected_for_the_module_strategy() {
2151        let app = bundling_app();
2152        let mut options = light_options(app.path());
2153        options.css = CssStrategy::Module;
2154        options.css_bundle = true;
2155
2156        let Err(WebUIError::InvalidBuildOptions(message)) = build(options) else {
2157            panic!("Module builds resolve import-map specifiers per component");
2158        };
2159        assert!(
2160            message.contains("--css link"),
2161            "the error should name a strategy that works: {message}"
2162        );
2163    }
2164
2165    #[test]
2166    fn bundled_rendering_delivers_the_same_css_in_the_same_order() {
2167        // Style mode inlines the CSS, so the rendered document carries the whole
2168        // cascade and the two builds can be compared rule for rule.
2169        let extract = |html: &str| -> Vec<String> {
2170            let mut blocks = Vec::new();
2171            let mut rest = html;
2172            while let Some(start) = rest.find("data-webui-strategy=\"style\">") {
2173                rest = &rest[start + "data-webui-strategy=\"style\">".len()..];
2174                match rest.find("</style>") {
2175                    Some(end) => {
2176                        blocks.push(rest[..end].to_string());
2177                        rest = &rest[end..];
2178                    }
2179                    None => break,
2180                }
2181            }
2182            blocks
2183        };
2184
2185        let mut plan = Vec::new();
2186        for bundle in [false, true] {
2187            let app = bundling_app();
2188            let mut options = light_options(app.path());
2189            options.css = CssStrategy::Style;
2190            options.css_bundle = bundle;
2191            let result = build(options).unwrap();
2192            let html = render_html(&result.protocol, "/");
2193            plan.push(extract(&html).join("\n"));
2194        }
2195
2196        assert_eq!(
2197            plan[1], plan[0],
2198            "bundling must not change the delivered cascade"
2199        );
2200        assert!(!plan[0].is_empty(), "fixture must deliver CSS");
2201    }
2202
2203    #[test]
2204    fn bundled_rendering_never_repeats_a_chunk_across_css_trees() {
2205        let app = bundling_app();
2206        let mut options = light_options(app.path());
2207        options.css = CssStrategy::Style;
2208        options.css_bundle = true;
2209        let result = build(options).unwrap();
2210
2211        let html = render_html(&result.protocol, "/");
2212        let resources = emitted_resources(&html);
2213        // `shared-card` is reached by the document tree and by `shadow-card`'s
2214        // ShadowRoot. Each tree scopes styles separately, so it is delivered once
2215        // per tree and never twice within one.
2216        let mut counts: HashMap<&str, usize> = HashMap::new();
2217        for resource in &resources {
2218            *counts.entry(*resource).or_insert(0) += 1;
2219        }
2220        let shadow_start = html
2221            .find(r#"<template shadowrootmode="open""#)
2222            .expect("declarative Shadow root");
2223        let document_resources = emitted_resources(&html[..shadow_start]);
2224        let mut document_counts: HashMap<&str, usize> = HashMap::new();
2225        for resource in &document_resources {
2226            *document_counts.entry(*resource).or_insert(0) += 1;
2227        }
2228        for (resource, count) in &document_counts {
2229            assert_eq!(
2230                *count, 1,
2231                "`{resource}` was delivered {count} times in the document tree: {resources:?}"
2232            );
2233        }
2234    }
2235
2236    #[test]
2237    fn authored_shadow_css_bytes_are_not_lowered_or_trimmed() {
2238        let authored =
2239            " \n:host(.ready){color:red}:host-context(body){color:blue}::slotted(*){color:green}\n ";
2240
2241        for strategy in [CssStrategy::Link, CssStrategy::Style, CssStrategy::Module] {
2242            let app = create_app_dir(&[
2243                ("index.html", "<my-card></my-card>"),
2244                (
2245                    "my-card.html",
2246                    r#"<template shadowrootmode="open"><p>content</p></template>"#,
2247                ),
2248                ("my-card.css", authored),
2249            ]);
2250            let mut options = default_options(app.path());
2251            options.css = strategy;
2252            let result = build(options).unwrap();
2253
2254            match strategy {
2255                CssStrategy::Link => assert_eq!(result.css_files[0].1, authored),
2256                CssStrategy::Style | CssStrategy::Module => {
2257                    assert_eq!(result.protocol.components["my-card"].css, authored);
2258                    assert_eq!(
2259                        result.protocol.component_style_resource("my-card"),
2260                        Some(authored)
2261                    );
2262                }
2263            }
2264            assert_eq!(
2265                result
2266                    .protocol
2267                    .style_closure("my-card")
2268                    .expect("component closure"),
2269                ["my-card"],
2270                "the authored Shadow root owns its exact CSS resource"
2271            );
2272
2273            let protocol = Protocol::new(result.protocol.clone());
2274            let mut writer = StringWriter { buf: String::new() };
2275            WebUIHandler::new()
2276                .render(
2277                    &protocol,
2278                    &serde_json::json!({}),
2279                    &RenderOptions::new("index.html", "/"),
2280                    &mut writer,
2281                )
2282                .unwrap();
2283            match strategy {
2284                CssStrategy::Link => {
2285                    let href = &result.protocol.components["my-card"].css_href;
2286                    assert!(writer.buf.contains(&format!(
2287                        r#"<template shadowrootmode="open"><link rel="stylesheet" href="{href}" data-webui-resource="my-card" data-webui-strategy="link">"#
2288                    )));
2289                }
2290                CssStrategy::Style | CssStrategy::Module => {
2291                    let kind = if strategy == CssStrategy::Module {
2292                        "module"
2293                    } else {
2294                        "style"
2295                    };
2296                    assert!(writer.buf.contains(&format!(
2297                        r#"<style data-webui-resource="my-card" data-webui-strategy="{kind}">{authored}</style>"#
2298                    )));
2299                    let template_start = writer
2300                        .buf
2301                        .find(r#"<template shadowrootmode="open""#)
2302                        .expect("declarative Shadow root");
2303                    let style_start = writer
2304                        .buf
2305                        .find(&format!(
2306                            r#"<style data-webui-resource="my-card" data-webui-strategy="{kind}">"#
2307                        ))
2308                        .expect("tree-local style");
2309                    let content_start = writer.buf.find("<p>content</p>").expect("component body");
2310                    assert!(template_start < style_start && style_start < content_start);
2311                }
2312            }
2313        }
2314    }
2315
2316    #[test]
2317    fn test_build_strips_non_legal_css_comments_from_output_file() {
2318        let app = create_app_dir(&[
2319            ("index.html", "<my-card>Hello</my-card>"),
2320            (
2321                "my-card.html",
2322                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2323            ),
2324            (
2325                "my-card.css",
2326                "/* remove var(--ignored) */ .card { color: var(--textColor); }",
2327            ),
2328        ]);
2329        let result = build(default_options(app.path())).unwrap();
2330
2331        assert_eq!(result.css_files[0].1, " .card { color: var(--textColor); }");
2332        assert_eq!(result.protocol.tokens, vec!["textColor"]);
2333    }
2334
2335    #[test]
2336    fn test_build_preserves_legal_css_comments_by_default() {
2337        let app = create_app_dir(&[
2338            ("index.html", "<my-card>Hello</my-card>"),
2339            (
2340                "my-card.html",
2341                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2342            ),
2343            (
2344                "my-card.css",
2345                "/*! @license MIT */ .card { color: red; } /* remove */",
2346            ),
2347        ]);
2348        let result = build(default_options(app.path())).unwrap();
2349
2350        assert_eq!(
2351            result.css_files[0].1,
2352            "/*! @license MIT */ .card { color: red; } "
2353        );
2354    }
2355
2356    #[test]
2357    fn test_build_legal_comments_none_strips_legal_css_comments() {
2358        let app = create_app_dir(&[
2359            ("index.html", "<my-card>Hello</my-card>"),
2360            (
2361                "my-card.html",
2362                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2363            ),
2364            ("my-card.css", "/*! @license MIT */ .card { color: red; }"),
2365        ]);
2366        let mut options = default_options(app.path());
2367        options.legal_comments = LegalComments::None;
2368        let result = build(options).unwrap();
2369
2370        assert_eq!(result.css_files[0].1, " .card { color: red; }");
2371    }
2372
2373    #[test]
2374    fn test_build_with_css_hashed_filename_template() {
2375        let app = create_app_dir(&[
2376            ("index.html", "<my-card>Hello</my-card>"),
2377            (
2378                "my-card.html",
2379                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2380            ),
2381            ("my-card.css", ".card { color: red; }"),
2382        ]);
2383        let mut options = default_options(app.path());
2384        options.css_file_name_template = "[name]-[hash].[ext]".to_string();
2385        let result = build(options).unwrap();
2386
2387        assert_eq!(result.css_files.len(), 1);
2388        let generated = &result.css_files[0].0;
2389        assert!(generated.starts_with("my-card-"));
2390        assert!(generated.ends_with(".css"));
2391        assert_eq!(generated.len(), "my-card-".len() + 8 + ".css".len());
2392        assert_eq!(
2393            result
2394                .protocol
2395                .components
2396                .get("my-card")
2397                .map(|c| c.css_href.as_str()),
2398            Some(generated.as_str())
2399        );
2400    }
2401
2402    #[test]
2403    fn test_css_public_base_prefixes_css_href_only() {
2404        let app = create_app_dir(&[
2405            ("index.html", "<my-card>Hello</my-card>"),
2406            (
2407                "my-card.html",
2408                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2409            ),
2410            ("my-card.css", ".card { color: red; }"),
2411        ]);
2412        let mut options = default_options(app.path());
2413        options.css_file_name_template = "[name]-[hash].[ext]".to_string();
2414        options.css_public_base = Some("https://cdn.example.com/assets".to_string());
2415        let result = build(options).unwrap();
2416
2417        let filename = &result.css_files[0].0;
2418        assert!(filename.starts_with("my-card-"));
2419        let expected_href = format!("https://cdn.example.com/assets/{filename}");
2420        assert_eq!(
2421            result
2422                .protocol
2423                .components
2424                .get("my-card")
2425                .map(|c| c.css_href.as_str()),
2426            Some(expected_href.as_str())
2427        );
2428    }
2429
2430    #[test]
2431    fn test_css_public_base_emits_protocol_handler_and_payload_resources() {
2432        let app = create_app_dir(&[
2433            (
2434                "index.html",
2435                "<html><head></head><body><my-card>Hello</my-card></body></html>",
2436            ),
2437            (
2438                "my-card.html",
2439                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2440            ),
2441            ("my-card.css", ".card { color: red; }"),
2442        ]);
2443        let mut options = default_options(app.path());
2444        options.css_file_name_template = "[name]-[hash].[ext]".to_string();
2445        options.css_public_base = Some("https://cdn.example.com/assets".to_string());
2446        let result = build(options).unwrap();
2447
2448        let filename = &result.css_files[0].0;
2449        let expected_href = format!("https://cdn.example.com/assets/{filename}");
2450        assert_eq!(
2451            result.protocol.component_style_resource("my-card"),
2452            Some(expected_href.as_str())
2453        );
2454        assert_eq!(
2455            result
2456                .protocol
2457                .style_closure("my-card")
2458                .expect("component closure"),
2459            ["my-card"]
2460        );
2461
2462        let handler = WebUIHandler::new();
2463        let protocol = Protocol::new(result.protocol.clone());
2464        let mut writer = StringWriter { buf: String::new() };
2465        handler
2466            .render(
2467                &protocol,
2468                &serde_json::json!({}),
2469                &RenderOptions::new("index.html", "/"),
2470                &mut writer,
2471            )
2472            .unwrap();
2473        assert!(
2474            writer.buf.contains(&format!(
2475                r#"<link rel="stylesheet" href="{}" data-webui-resource="my-card" data-webui-strategy="link">"#,
2476                expected_href.replace('/', "&#x2F;")
2477            )),
2478            "handler Shadow-root resource should use CDN href: {}",
2479            writer.buf
2480        );
2481        let payload = protocol
2482            .render_component_templates(&["my-card"], "")
2483            .expect("component resource payload");
2484        assert_eq!(
2485            payload["componentStyles"]["resources"]["my-card"]["href"],
2486            expected_href
2487        );
2488    }
2489
2490    #[test]
2491    fn test_css_public_base_keeps_webui_template_style_free() {
2492        let app = create_app_dir(&[
2493            ("index.html", "<my-card>Hello</my-card>"),
2494            (
2495                "my-card.html",
2496                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2497            ),
2498            ("my-card.css", ".card { color: red; }"),
2499            ("my-card.ts", "export {};"),
2500        ]);
2501        let mut options = default_options(app.path());
2502        options.plugin = Some(Plugin::WebUI);
2503        options.css_file_name_template = "[name]-[hash].[ext]".to_string();
2504        options.css_public_base = Some("https://cdn.example.com/assets".to_string());
2505        let result = build(options).unwrap();
2506
2507        let filename = &result.css_files[0].0;
2508        let expected_href = format!("https://cdn.example.com/assets/{filename}");
2509        let template = &result.protocol.components["my-card"].template_json;
2510        assert_eq!(
2511            result.protocol.component_style_resource("my-card"),
2512            Some(expected_href.as_str())
2513        );
2514        assert!(
2515            !template.contains(&expected_href),
2516            "WebUI templates must not duplicate handler-owned CSS links: {template}"
2517        );
2518    }
2519
2520    #[test]
2521    fn test_invalid_css_template_is_rejected() {
2522        let app = create_app_dir(&[
2523            ("index.html", "<my-card>Hello</my-card>"),
2524            (
2525                "my-card.html",
2526                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2527            ),
2528            ("my-card.css", ".card { color: red; }"),
2529        ]);
2530        let mut options = default_options(app.path());
2531        options.css_file_name_template = "[name]-[bogus].[ext]".to_string();
2532        let result = build(options);
2533
2534        assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
2535    }
2536
2537    #[test]
2538    fn test_css_filename_collision_is_rejected() {
2539        let app = create_app_dir(&[
2540            ("index.html", "<card-a>A</card-a><card-b>B</card-b>"),
2541            (
2542                "card-a.html",
2543                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2544            ),
2545            ("card-a.css", ".x { color: red; }"),
2546            (
2547                "card-b.html",
2548                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2549            ),
2550            ("card-b.css", ".x { color: red; }"),
2551        ]);
2552        let mut options = default_options(app.path());
2553        options.css_file_name_template = "[hash].[ext]".to_string();
2554        let result = build(options);
2555
2556        assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
2557    }
2558
2559    #[test]
2560    fn test_build_returns_static_component_asset_files() {
2561        let app = create_app_dir(&[
2562            ("index.html", "<app-shell></app-shell>"),
2563            ("app-shell.html", "<div></div>"),
2564            (
2565                "lazy-panel.html",
2566                r#"<if condition="ready"><p>{{title}}</p></if>"#,
2567            ),
2568            ("lazy-panel.ts", "export {};"),
2569        ]);
2570        let mut options = default_options(app.path());
2571        options.plugin = Some(Plugin::WebUI);
2572        options.component_asset_roots = vec!["lazy-panel".to_string()];
2573
2574        let result = build(options).unwrap();
2575
2576        assert_eq!(result.component_asset_files.len(), 2);
2577        assert_eq!(result.component_asset_files[0].name, "lazy-panel.webui.js");
2578        assert!(result.component_asset_files[0]
2579            .content
2580            .contains(r#""type":"webui-component-asset""#));
2581        assert!(result.component_asset_files[0]
2582            .content
2583            .contains(r#""version":4"#));
2584        assert!(result.component_asset_files[0]
2585            .content
2586            .contains(r#""root":"lazy-panel""#));
2587        assert!(result.component_asset_files[1]
2588            .content
2589            .contains(r#""templateFunctions":{"lazy-panel":"#));
2590        let root = &result.component_asset_files[0].content;
2591        let payload = &result.component_asset_files[1].content;
2592        for field in ["kind", "components", "requiredComponents"] {
2593            assert!(!root.contains(&format!("\"{field}\":")));
2594            assert!(!payload.contains(&format!("\"{field}\":")));
2595        }
2596        for field in ["type", "version\":4", "externalComponents", "imports"] {
2597            assert!(!payload.contains(&format!("\"{field}")));
2598        }
2599        assert!(!root.contains("\"templates\":"));
2600        assert!(!result.protocol.fragments.contains_key("lazy-panel"));
2601        assert!(
2602            !result
2603                .protocol
2604                .fragments
2605                .keys()
2606                .any(|key| key.starts_with("__webui_asset_root_")),
2607            "synthetic asset root fragments must not be serialized"
2608        );
2609    }
2610
2611    #[test]
2612    fn component_assets_emit_enhanced_style_catalog_for_all_strategies() {
2613        for (strategy, kind) in [
2614            (CssStrategy::Link, "link"),
2615            (CssStrategy::Style, "style"),
2616            (CssStrategy::Module, "module"),
2617        ] {
2618            let app = create_app_dir(&[
2619                ("index.html", "<app-shell></app-shell>"),
2620                ("app-shell.html", "<p>Entry</p>"),
2621                ("lazy-panel.html", "<p>Lazy</p>"),
2622                ("lazy-panel.css", ".lazy{color:red}"),
2623            ]);
2624            let mut options = default_options(app.path());
2625            options.plugin = Some(Plugin::WebUI);
2626            options.css = strategy;
2627            options.component_asset_roots = vec!["lazy-panel".to_string()];
2628
2629            let result = build(options).unwrap();
2630            let root = &result.component_asset_files[0].content;
2631            let component = &result.component_asset_files[1].content;
2632            assert!(root.contains(r#""version":4"#));
2633            assert!(component.contains(&format!(
2634                r#""componentStyles":{{"version":1,"strategy":"{kind}""#
2635            )));
2636            assert!(component.contains(&format!(r#""lazy-panel":{{"kind":"{kind}""#)));
2637            assert!(component.contains(r#""closures":{"lazy-panel":["lazy-panel"]}"#));
2638            if strategy == CssStrategy::Module {
2639                assert!(component
2640                    .contains(r#""lazy-panel":{"kind":"module","specifier":"lazy-panel","css":"#,));
2641            }
2642            if strategy == CssStrategy::Link {
2643                assert!(component.contains(r#""href":"lazy-panel.css""#));
2644                assert!(!component.contains("import.meta.url"));
2645            }
2646        }
2647    }
2648
2649    #[test]
2650    fn test_build_emits_semantic_component_modules_and_keeps_them_out_of_protocol() {
2651        let app = create_app_dir(&[
2652            ("index.html", "<app-shell></app-shell>"),
2653            ("app-shell.html", "<entry-badge></entry-badge>"),
2654            ("entry-badge.html", "<span>Entry</span>"),
2655            ("entry-badge.css", ":host { display: inline-block; }"),
2656            (
2657                "lazy-panel.html",
2658                "<entry-badge></entry-badge><shared-detail></shared-detail><panel-only></panel-only>",
2659            ),
2660            (
2661                "secondary-panel.html",
2662                "<entry-badge></entry-badge><shared-detail></shared-detail><secondary-only></secondary-only>",
2663            ),
2664            ("shared-detail.html", "<p>Shared</p>"),
2665            ("shared-detail.css", ":host { display: block; }"),
2666            ("lazy-panel.css", ":host { color: green; }"),
2667            ("secondary-panel.css", ":host { color: blue; }"),
2668            ("panel-only.html", "<p>Panel</p>"),
2669            ("secondary-only.html", "<p>Secondary</p>"),
2670        ]);
2671        let mut options = default_options(app.path());
2672        options.plugin = Some(Plugin::WebUI);
2673        options.component_asset_roots =
2674            vec!["lazy-panel".to_string(), "secondary-panel".to_string()];
2675
2676        let result = build(options).unwrap();
2677
2678        for tag in [
2679            "lazy-panel",
2680            "secondary-panel",
2681            "shared-detail",
2682            "panel-only",
2683            "secondary-only",
2684        ] {
2685            assert!(
2686                !result.protocol.fragments.contains_key(tag),
2687                "asset-only fragment <{tag}> must not be serialized"
2688            );
2689            assert!(
2690                !result.protocol.components.contains_key(tag),
2691                "asset-only component <{tag}> must not be serialized"
2692            );
2693        }
2694
2695        assert!(result.protocol.fragments.contains_key("app-shell"));
2696        assert!(result.protocol.components.contains_key("entry-badge"));
2697        assert_eq!(
2698            result.protocol.component_asset_style_preloads,
2699            vec![
2700                webui_protocol::ComponentAssetStylePreload {
2701                    root: "lazy-panel".to_string(),
2702                    style_hrefs: vec![
2703                        "lazy-panel.css".to_string(),
2704                        "shared-detail.css".to_string(),
2705                    ],
2706                },
2707                webui_protocol::ComponentAssetStylePreload {
2708                    root: "secondary-panel".to_string(),
2709                    style_hrefs: vec![
2710                        "secondary-panel.css".to_string(),
2711                        "shared-detail.css".to_string(),
2712                    ],
2713                },
2714            ]
2715        );
2716
2717        assert_eq!(result.component_asset_files.len(), 7);
2718        assert_eq!(result.component_asset_files[0].name, "lazy-panel.webui.js");
2719        assert_eq!(
2720            result.component_asset_files[1].name,
2721            "secondary-panel.webui.js"
2722        );
2723        let lazy = &result.component_asset_files[0].content;
2724        assert!(lazy.contains(r#""version":4"#));
2725        assert!(lazy.contains(r#""root":"lazy-panel""#));
2726        assert!(lazy.contains(r#""externalComponents":["entry-badge"]"#));
2727        assert!(lazy.contains(r#""entry-badge":{"kind":"link","href":"entry-badge.css"}"#));
2728        assert!(!lazy.contains(
2729            r#""entry-badge":{"kind":"link","href":new URL("entry-badge.css",import.meta.url).href}"#
2730        ));
2731        for tag in ["lazy-panel", "panel-only", "shared-detail"] {
2732            let payload = component_payload(&result.component_asset_files, tag);
2733            assert!(lazy.contains(&payload.name));
2734        }
2735        assert!(!lazy.contains("@microsoft/webui-framework/component-asset-runtime.js"));
2736        assert!(!lazy.contains("export const preload"));
2737        assert!(!lazy.contains("export const create"));
2738        assert!(!lazy.contains(r#""templates":{"entry-badge":"#));
2739        assert!(!lazy.contains(r#""templates":{"shared-detail":"#));
2740
2741        let shared = component_payload(&result.component_asset_files, "shared-detail");
2742        assert!(!shared.content.contains(r#""kind":"component""#));
2743        assert!(shared.content.contains(r#""templates":{"shared-detail":"#));
2744        assert!(shared.content.contains(r#""href":"shared-detail.css""#));
2745        assert!(!shared.content.contains("import.meta.url"));
2746    }
2747
2748    #[test]
2749    fn build_to_disk_creates_component_asset_parent_directories() {
2750        let app = create_app_dir(&[
2751            ("index.html", "<app-shell></app-shell>"),
2752            ("app-shell.html", "<p>Entry</p>"),
2753            ("lazy-panel.html", "<p>Lazy</p>"),
2754        ]);
2755        let out = tempfile::tempdir().unwrap();
2756        let mut options = default_options(app.path());
2757        options.plugin = Some(Plugin::WebUI);
2758        options.component_asset_roots = vec!["lazy-panel".to_string()];
2759
2760        build_to_disk(options, out.path()).unwrap();
2761
2762        assert!(out.path().join("lazy-panel.webui.js").is_file());
2763        assert!(fs::read_dir(out.path().join("components"))
2764            .unwrap()
2765            .any(|entry| entry
2766                .unwrap()
2767                .file_name()
2768                .to_string_lossy()
2769                .starts_with("lazy-panel.")));
2770    }
2771
2772    #[test]
2773    fn build_to_disk_preserves_noop_inputs_and_leaves_older_payloads() {
2774        let app = create_app_dir(&[
2775            ("index.html", "<main>Entry</main>"),
2776            ("lazy-panel.html", "<p>Initial</p>"),
2777        ]);
2778        let out = tempfile::tempdir().unwrap();
2779        let options = || {
2780            let mut options = default_options(app.path());
2781            options.plugin = Some(Plugin::WebUI);
2782            options.component_asset_roots = vec!["lazy-panel".to_string()];
2783            options
2784        };
2785        build_to_disk(options(), out.path()).unwrap();
2786        let root = out.path().join("lazy-panel.webui.js");
2787        let before = fs::metadata(&root).unwrap().modified().unwrap();
2788        build_to_disk(options(), out.path()).unwrap();
2789        assert_eq!(before, fs::metadata(&root).unwrap().modified().unwrap());
2790
2791        for generation in 0..5 {
2792            fs::write(
2793                app.path().join("lazy-panel.html"),
2794                format!("<p>{generation}</p>"),
2795            )
2796            .unwrap();
2797            build_to_disk(options(), out.path()).unwrap();
2798        }
2799        assert_eq!(
2800            fs::read_dir(out.path().join("components")).unwrap().count(),
2801            6
2802        );
2803    }
2804
2805    #[test]
2806    fn test_component_asset_graph_emits_each_component_once() {
2807        let app = create_app_dir(&[
2808            ("index.html", "<app-shell></app-shell>"),
2809            ("app-shell.html", "<p>Entry</p>"),
2810            (
2811                "root-a.html",
2812                "<shared-ab></shared-ab><shared-all></shared-all><only-a></only-a>",
2813            ),
2814            (
2815                "root-b.html",
2816                "<shared-ab></shared-ab><shared-all></shared-all><only-b></only-b>",
2817            ),
2818            ("root-c.html", "<shared-all></shared-all><only-c></only-c>"),
2819            ("shared-ab.html", "<p>AB</p>"),
2820            ("shared-all.html", "<p>All</p>"),
2821            ("only-a.html", "<p>A</p>"),
2822            ("only-b.html", "<p>B</p>"),
2823            ("only-c.html", "<p>C</p>"),
2824        ]);
2825        let mut options = default_options(app.path());
2826        options.plugin = Some(Plugin::WebUI);
2827        options.component_asset_roots = vec![
2828            "root-a".to_string(),
2829            "root-b".to_string(),
2830            "root-c".to_string(),
2831        ];
2832
2833        let result = build(options).unwrap();
2834        assert_eq!(result.component_asset_files.len(), 11);
2835        assert_eq!(result.component_asset_files[0].name, "root-a.webui.js");
2836        assert_eq!(result.component_asset_files[1].name, "root-b.webui.js");
2837        assert_eq!(result.component_asset_files[2].name, "root-c.webui.js");
2838        let only_a = component_payload(&result.component_asset_files, "only-a");
2839        assert!(result.component_asset_files[0]
2840            .content
2841            .contains(&only_a.name));
2842        assert!(
2843            component_payload(&result.component_asset_files, "shared-ab")
2844                .content
2845                .contains(r#""templates":{"shared-ab":"#)
2846        );
2847        assert!(
2848            component_payload(&result.component_asset_files, "shared-all")
2849                .content
2850                .contains(r#""templates":{"shared-all":"#)
2851        );
2852    }
2853
2854    #[test]
2855    fn test_component_asset_graph_is_root_order_independent_with_stable_imports() {
2856        let app = create_app_dir(&[
2857            ("index.html", "<app-shell></app-shell>"),
2858            ("app-shell.html", "<p>Entry</p>"),
2859            ("root-a.html", "<shared-detail></shared-detail>"),
2860            ("root-b.html", "<shared-detail></shared-detail>"),
2861            ("shared-detail.html", "<p>Shared</p>"),
2862        ]);
2863        let build_files = |roots: Vec<String>| {
2864            let mut options = default_options(app.path());
2865            options.plugin = Some(Plugin::WebUI);
2866            options.component_asset_roots = roots;
2867            options.css_file_name_template = "[name]-[hash].[ext]".to_string();
2868            build(options).unwrap().component_asset_files
2869        };
2870
2871        let forward = build_files(vec!["root-a".to_string(), "root-b".to_string()]);
2872        let reverse = build_files(vec!["root-b".to_string(), "root-a".to_string()]);
2873
2874        assert_eq!(forward, reverse);
2875        let shared_name = &component_payload(&forward, "shared-detail").name;
2876        assert!(forward[0].content.contains(shared_name));
2877        assert!(forward[1].content.contains(shared_name));
2878    }
2879
2880    #[test]
2881    fn test_component_asset_style_preloads_use_final_css_hrefs() {
2882        let app = create_app_dir(&[
2883            ("index.html", "<app-shell></app-shell>"),
2884            ("app-shell.html", "<p>Entry</p>"),
2885            ("lazy-panel.html", "<p>Lazy</p>"),
2886            ("lazy-panel.css", ":host { display: block; }"),
2887        ]);
2888        let mut options = default_options(app.path());
2889        options.plugin = Some(Plugin::WebUI);
2890        options.component_asset_roots = vec!["lazy-panel".to_string()];
2891        options.css_file_name_template = "[name]-[hash].[ext]".to_string();
2892        options.css_public_base = Some("https://cdn.example.com/webui".to_string());
2893
2894        let result = build(options).unwrap();
2895        let preload = &result.protocol.component_asset_style_preloads[0];
2896
2897        assert_eq!(preload.root, "lazy-panel");
2898        assert_eq!(preload.style_hrefs.len(), 1);
2899        assert!(preload.style_hrefs[0].starts_with("https://cdn.example.com/webui/lazy-panel-"));
2900        assert!(preload.style_hrefs[0].ends_with(".css"));
2901    }
2902
2903    #[test]
2904    fn test_light_component_asset_styles_are_retained_for_document_links() {
2905        let app = create_app_dir(&[
2906            ("index.html", "<app-shell></app-shell>"),
2907            ("app-shell.html", "<p>Entry</p>"),
2908            ("z-lazy-panel.html", "<a-lazy-child></a-lazy-child>"),
2909            ("z-lazy-panel.css", ".z-lazy-panel { display: block; }"),
2910            ("a-lazy-child.html", "<p>Child</p>"),
2911            ("a-lazy-child.css", ".a-lazy-child { color: green; }"),
2912        ]);
2913        let mut options = default_options(app.path());
2914        options.plugin = Some(Plugin::WebUI);
2915        options.dom = DomStrategy::Light;
2916        options.component_asset_roots = vec!["z-lazy-panel".to_string()];
2917
2918        let result = build(options).unwrap();
2919
2920        assert_eq!(
2921            result.protocol.component_asset_style_preloads,
2922            vec![webui_protocol::ComponentAssetStylePreload {
2923                root: "z-lazy-panel".to_string(),
2924                style_hrefs: vec![
2925                    "z-lazy-panel.css".to_string(),
2926                    "a-lazy-child.css".to_string(),
2927                ],
2928            }]
2929        );
2930    }
2931
2932    #[test]
2933    fn test_metafile_describes_static_import_graph() {
2934        let app = create_app_dir(&[
2935            ("index.html", "<app-shell></app-shell>"),
2936            ("app-shell.html", "<p>Entry</p>"),
2937            ("root-a.html", "<shared-detail></shared-detail>"),
2938            ("root-b.html", "<shared-detail></shared-detail>"),
2939            ("shared-detail.html", "<p>Shared</p>"),
2940        ]);
2941        let mut options = default_options(app.path());
2942        options.plugin = Some(Plugin::WebUI);
2943        options.component_asset_roots = vec!["root-a".to_string(), "root-b".to_string()];
2944        options.metafile = true;
2945
2946        let result = build(options).unwrap();
2947        let metafile = result.metafile.as_deref().unwrap();
2948        let value: serde_json::Value = serde_json::from_str(metafile).unwrap();
2949        assert!(value["inputs"]
2950            .get("webui:component/shared-detail")
2951            .is_some());
2952        assert_eq!(
2953            value["outputs"]["root-a.webui.js"]["entryPoint"],
2954            "webui:component/root-a"
2955        );
2956        let shared = component_payload(&result.component_asset_files, "shared-detail");
2957        assert_eq!(
2958            value["outputs"]["root-a.webui.js"]["imports"][1]["path"],
2959            shared.name
2960        );
2961        assert_eq!(
2962            value["outputs"]["root-a.webui.js"]["imports"][1]["kind"],
2963            "import-statement"
2964        );
2965        assert_eq!(
2966            value["inputs"]["webui:component/root-a"]["imports"][0]["kind"],
2967            "import-statement"
2968        );
2969        assert_eq!(
2970            value["outputs"]["root-a.webui.js"]["exports"],
2971            serde_json::json!(["default"])
2972        );
2973        assert_eq!(
2974            value["outputs"][&shared.name]["bytes"],
2975            shared.content.len()
2976        );
2977    }
2978
2979    #[test]
2980    fn test_component_assets_reject_routes_with_stable_diagnostic() {
2981        let app = create_app_dir(&[
2982            (
2983                "index.html",
2984                r#"<route path="/" component="app-shell" exact />"#,
2985            ),
2986            ("app-shell.html", "<p>Entry</p>"),
2987            ("lazy-panel.html", "<p>Lazy</p>"),
2988        ]);
2989        let mut options = default_options(app.path());
2990        options.plugin = Some(Plugin::WebUI);
2991        options.component_asset_roots = vec!["lazy-panel".to_string()];
2992
2993        let error = build(options).unwrap_err();
2994        match error {
2995            WebUIError::ComponentAssets(diagnostic) => {
2996                assert_eq!(
2997                    diagnostic.error_code(),
2998                    Some(webui_parser::codes::COMPONENT_ASSETS_WITH_ROUTES)
2999                );
3000                assert!(diagnostic.help_text().is_some());
3001            }
3002            other => panic!("expected route diagnostic, received {other}"),
3003        }
3004    }
3005
3006    #[test]
3007    fn test_component_asset_input_name_is_independent_from_css_template() {
3008        let app = create_app_dir(&[
3009            ("index.html", "<app-shell></app-shell>"),
3010            ("app-shell.html", "<div></div>"),
3011            ("lazy-panel.html", "<p>Lazy</p>"),
3012            ("lazy-panel.css", ".panel { color: red; }"),
3013        ]);
3014        let mut options = default_options(app.path());
3015        options.plugin = Some(Plugin::WebUI);
3016        options.component_asset_roots = vec!["lazy-panel".to_string()];
3017        options.css_file_name_template = "[name]".to_string();
3018
3019        let result = build(options).unwrap();
3020
3021        assert!(result
3022            .component_asset_files
3023            .iter()
3024            .any(|file| file.name == "lazy-panel.webui.js"));
3025        assert!(result
3026            .css_files
3027            .iter()
3028            .any(|(name, _)| name == "lazy-panel"));
3029    }
3030
3031    #[test]
3032    fn test_build_to_disk_rejects_protocol_name_collision_before_writing() {
3033        let app = create_app_dir(&[
3034            ("index.html", "<app-shell></app-shell>"),
3035            ("app-shell.html", "<div></div>"),
3036            ("lazy-panel.html", "<p>Lazy</p>"),
3037            ("lazy-panel.css", ".panel { color: red; }"),
3038        ]);
3039        let out = TempDir::new().unwrap();
3040        let mut options = default_options(app.path());
3041        options.plugin = Some(Plugin::WebUI);
3042        options.component_asset_roots = vec!["lazy-panel".to_string()];
3043        options.css_file_name_template = "protocol.bin".to_string();
3044
3045        let result = build_to_disk(options, out.path());
3046
3047        assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
3048        assert!(!out.path().join("protocol.bin").exists());
3049    }
3050
3051    #[test]
3052    fn test_css_href_set_for_light_dom_link_strategy() {
3053        let app = create_app_dir(&[
3054            ("index.html", "<has-css>A</has-css><no-css>B</no-css>"),
3055            ("has-css.html", "<p>styled</p>"),
3056            ("has-css.css", ".yes { color: green; }"),
3057            ("no-css.html", "<p>plain</p>"),
3058        ]);
3059        let options = default_options(app.path());
3060        let result = build(options).unwrap();
3061
3062        let href = result
3063            .protocol
3064            .components
3065            .get("has-css")
3066            .map(|c| c.css_href.as_str())
3067            .unwrap_or("");
3068        assert_eq!(
3069            href, "has-css.css",
3070            "Light×Link component with CSS should have css_href"
3071        );
3072
3073        let no_href = result
3074            .protocol
3075            .components
3076            .get("no-css")
3077            .map(|c| c.css_href.as_str())
3078            .unwrap_or("");
3079        assert!(
3080            no_href.is_empty(),
3081            "Component without CSS should have empty css_href"
3082        );
3083    }
3084
3085    #[test]
3086    fn test_plugin_free_build_persists_component_shadow_usage() {
3087        let app = create_app_dir(&[
3088            (
3089                "index.html",
3090                "<light-card></light-card><shadow-card></shadow-card>",
3091            ),
3092            ("light-card.html", "<p>light</p>"),
3093            (
3094                "shadow-card.html",
3095                "<template shadowrootmode=\"open\"><slot></slot></template>",
3096            ),
3097        ]);
3098        let mut options = default_options(app.path());
3099        options.dom = DomStrategy::Light;
3100        options.plugin = None;
3101        let result = build(options).unwrap();
3102
3103        assert!(!result.protocol.component_uses_shadow_dom("light-card"));
3104        assert!(result.protocol.component_uses_shadow_dom("shadow-card"));
3105        assert!(!result.protocol.components["light-card"].uses_shadow_dom);
3106        assert!(result.protocol.components["shadow-card"].uses_shadow_dom);
3107
3108        let entry_html: String = result.protocol.fragments["index.html"]
3109            .fragments
3110            .iter()
3111            .filter_map(|fragment| match fragment.fragment.as_ref() {
3112                Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
3113                _ => None,
3114            })
3115            .collect();
3116        assert!(entry_html.contains("<light-card>"));
3117        assert!(entry_html.contains("<shadow-card>"));
3118        assert!(!entry_html.contains("<shadow-card data-wl"));
3119    }
3120
3121    #[test]
3122    fn test_authored_shadow_under_light_matches_ssr_protocol_and_webui_metadata() {
3123        let app = create_app_dir(&[
3124            ("index.html", "<shadow-card>projected</shadow-card>"),
3125            (
3126                "shadow-card.html",
3127                "<!-- lead --><template shadowrootmode=\"open\"><slot></slot></template><!-- tail -->",
3128            ),
3129        ]);
3130        let mut options = default_options(app.path());
3131        options.plugin = Some(Plugin::WebUI);
3132        let result = build(options).unwrap();
3133
3134        assert!(result.protocol.component_uses_shadow_dom("shadow-card"));
3135        assert!(result.protocol.components["shadow-card"]
3136            .template_json
3137            .contains("\"sd\":1"));
3138
3139        let component_html: String = result.protocol.fragments["shadow-card"]
3140            .fragments
3141            .iter()
3142            .filter_map(|fragment| match fragment.fragment.as_ref() {
3143                Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
3144                _ => None,
3145            })
3146            .collect();
3147        assert_eq!(
3148            component_html,
3149            "<template shadowrootmode=\"open\"><slot></slot></template>"
3150        );
3151        let entry_html: String = result.protocol.fragments["index.html"]
3152            .fragments
3153            .iter()
3154            .filter_map(|fragment| match fragment.fragment.as_ref() {
3155                Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
3156                _ => None,
3157            })
3158            .collect();
3159        assert!(!entry_html.contains("data-wl"));
3160    }
3161
3162    #[test]
3163    fn test_shadow_dom_link_strategy_sets_css_href() {
3164        // Shadow×Link: css_href is always set for Link-strategy components.
3165        // The handler uses protocol.css_strategy + component Shadow metadata
3166        // to decide preload (Shadow) vs stylesheet (Light) in <head>.
3167        let app = create_app_dir(&[
3168            ("index.html", "<my-card>A</my-card>"),
3169            (
3170                "my-card.html",
3171                r#"<template shadowrootmode="open"><p><slot></slot></p></template>"#,
3172            ),
3173            ("my-card.css", ".card { color: red; }"),
3174        ]);
3175        let result = build(default_options(app.path())).unwrap();
3176
3177        let comp = result.protocol.components.get("my-card").unwrap();
3178        assert_eq!(
3179            comp.css_href, "my-card.css",
3180            "Shadow×Link should set css_href"
3181        );
3182
3183        // Strategy fields on protocol
3184        assert_eq!(
3185            result.protocol.css_strategy(),
3186            webui_protocol::CssStrategy::Link,
3187        );
3188
3189        // CSS file should still be emitted for the server to serve
3190        assert_eq!(
3191            result.css_files.len(),
3192            1,
3193            "CSS file should still be produced"
3194        );
3195    }
3196
3197    #[test]
3198    fn test_css_href_empty_for_style_strategy() {
3199        let app = create_app_dir(&[
3200            ("index.html", "<my-card>A</my-card>"),
3201            (
3202                "my-card.html",
3203                r#"<template shadowrootmode="open"><p><slot></slot></p></template>"#,
3204            ),
3205            ("my-card.css", ".card { color: red; }"),
3206        ]);
3207        let mut options = default_options(app.path());
3208        options.css = CssStrategy::Style;
3209        let result = build(options).unwrap();
3210
3211        let href = result
3212            .protocol
3213            .components
3214            .get("my-card")
3215            .map(|c| c.css_href.as_str())
3216            .unwrap_or("");
3217        assert!(href.is_empty(), "Style-strategy should not set css_href");
3218    }
3219
3220    #[test]
3221    fn test_css_href_empty_for_module_strategy() {
3222        let app = create_app_dir(&[
3223            ("index.html", "<my-card>A</my-card>"),
3224            (
3225                "my-card.html",
3226                r#"<template shadowrootmode="open"><p><slot></slot></p></template>"#,
3227            ),
3228            ("my-card.css", ".card { color: red; }"),
3229        ]);
3230        let mut options = default_options(app.path());
3231        options.css = CssStrategy::Module;
3232        let result = build(options).unwrap();
3233
3234        let href = result
3235            .protocol
3236            .components
3237            .get("my-card")
3238            .map(|c| c.css_href.as_str())
3239            .unwrap_or("");
3240        assert!(href.is_empty(), "Module-strategy should not set css_href");
3241        assert!(
3242            !result.protocol.components["my-card"].css.is_empty(),
3243            "Module-strategy should still populate css content"
3244        );
3245    }
3246
3247    #[test]
3248    fn test_build_to_disk_writes_files() {
3249        let app = create_app_dir(&[
3250            ("index.html", "<my-card>Hello</my-card>"),
3251            (
3252                "my-card.html",
3253                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
3254            ),
3255            ("my-card.css", ".card { color: red; }"),
3256            ("my-card.ts", "export {};"),
3257        ]);
3258        let out = TempDir::new().unwrap();
3259
3260        let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
3261
3262        assert!(out.path().join("protocol.bin").exists());
3263        assert!(out.path().join("my-card.css").exists());
3264        assert_eq!(stats.css_file_count, 1);
3265        assert!(stats.fragment_count > 0);
3266    }
3267
3268    #[test]
3269    fn test_build_to_disk_creates_nested_output_dir() {
3270        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
3271        let out = TempDir::new().unwrap();
3272        let nested = out.path().join("nested").join("output");
3273
3274        let stats = build_to_disk(default_options(app.path()), &nested).unwrap();
3275        assert!(nested.join("protocol.bin").exists());
3276        assert!(stats.fragment_count > 0);
3277    }
3278
3279    #[test]
3280    fn test_build_missing_entry() {
3281        let app = create_app_dir(&[("other.html", "<h1>Not index</h1>")]);
3282        let result = build(default_options(app.path()));
3283
3284        assert!(result.is_err());
3285        let err = result.unwrap_err();
3286        assert!(matches!(err, WebUIError::Io { .. }));
3287    }
3288
3289    #[test]
3290    fn test_build_stats_populated() {
3291        let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
3292        let result = build(default_options(app.path())).unwrap();
3293
3294        assert!(result.stats.fragment_count > 0);
3295        assert!(result.stats.protocol_size_bytes > 0);
3296        assert_eq!(result.stats.css_file_count, 0);
3297    }
3298
3299    #[test]
3300    fn test_build_inline_css() {
3301        let app = create_app_dir(&[
3302            ("index.html", "<my-card>Hello</my-card>"),
3303            (
3304                "my-card.html",
3305                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
3306            ),
3307            ("my-card.css", ".card { color: red; }"),
3308        ]);
3309        let mut options = default_options(app.path());
3310        options.css = CssStrategy::Style;
3311
3312        let result = build(options).unwrap();
3313        // Inline mode embeds CSS in <style> tags — no external CSS files
3314        assert!(result.css_files.is_empty());
3315        assert_eq!(result.stats.css_file_count, 0);
3316        assert!(result.stats.fragment_count > 0);
3317    }
3318
3319    #[test]
3320    fn test_build_module_css() {
3321        let app = create_app_dir(&[
3322            ("index.html", "<my-card>Hello</my-card>"),
3323            (
3324                "my-card.html",
3325                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
3326            ),
3327            ("my-card.css", ".card { color: red; }"),
3328        ]);
3329        let mut options = default_options(app.path());
3330        options.css = CssStrategy::Module;
3331
3332        let result = build(options).unwrap();
3333        // Module mode emits <script type="importmap"> data URIs — no external CSS files
3334        assert!(result.css_files.is_empty());
3335        assert_eq!(result.stats.css_file_count, 0);
3336        assert!(result.stats.fragment_count > 0);
3337    }
3338
3339    #[test]
3340    fn test_build_custom_entry() {
3341        let app = create_app_dir(&[("page.html", "<h1>Custom</h1>")]);
3342        let mut options = default_options(app.path());
3343        options.entry = "page.html".to_string();
3344
3345        let result = build(options).unwrap();
3346        assert!(result.protocol.fragments.contains_key("page.html"));
3347        assert!(!result.protocol.fragments.contains_key("index.html"));
3348    }
3349
3350    #[test]
3351    fn test_build_protocol_roundtrip() {
3352        let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
3353        let result = build(default_options(app.path())).unwrap();
3354
3355        let restored = WebUIProtocol::from_protobuf(&result.protocol_bytes).unwrap();
3356        assert!(restored.fragments.contains_key("index.html"));
3357    }
3358
3359    #[test]
3360    fn protocol_is_available_from_facade() {
3361        let protocol = Protocol::new(WebUIProtocol::new(HashMap::new()));
3362        assert!(protocol.tokens().is_empty());
3363    }
3364
3365    #[test]
3366    fn test_inspect_bytes_valid() {
3367        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
3368        let result = build(default_options(app.path())).unwrap();
3369
3370        let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
3371        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
3372        assert!(parsed.get("fragments").is_some());
3373    }
3374
3375    #[test]
3376    fn test_inspect_bytes_invalid() {
3377        let result = inspect_bytes(b"not a protobuf");
3378        assert!(result.is_err());
3379        assert!(matches!(result.unwrap_err(), WebUIError::Protocol(_)));
3380    }
3381
3382    #[test]
3383    fn test_inspect_file() {
3384        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
3385        let out = TempDir::new().unwrap();
3386        build_to_disk(default_options(app.path()), out.path()).unwrap();
3387
3388        let json_str = inspect(&out.path().join("protocol.bin")).unwrap();
3389        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
3390        assert!(parsed["fragments"]["index.html"]["fragments"].is_array());
3391    }
3392
3393    #[test]
3394    fn test_inspect_missing_file() {
3395        let result = inspect(Path::new("/nonexistent/protocol.bin"));
3396        assert!(result.is_err());
3397        assert!(matches!(result.unwrap_err(), WebUIError::Io { .. }));
3398    }
3399
3400    #[test]
3401    fn test_build_with_components_local_path() {
3402        let app = create_app_dir(&[("index.html", "<ext-card>Hello</ext-card>")]);
3403        let ext_dir = TempDir::new().unwrap();
3404        fs::write(
3405            ext_dir.path().join("ext-card.html"),
3406            r#"<template shadowrootmode="open"><div class="card"><slot></slot></div></template>"#,
3407        )
3408        .unwrap();
3409        fs::write(
3410            ext_dir.path().join("ext-card.css"),
3411            ".card { border: 1px solid #ccc; }",
3412        )
3413        .unwrap();
3414
3415        let mut options = default_options(app.path());
3416        options.components = vec![ext_dir.path().to_string_lossy().to_string()];
3417
3418        let result = build(options).unwrap();
3419        assert!(result.protocol.fragments.contains_key("index.html"));
3420        assert_eq!(result.css_files.len(), 1);
3421        assert!(result.css_files[0].1.contains("border"));
3422    }
3423
3424    #[test]
3425    fn test_build_with_scriptless_component_path_emits_dormant_template() {
3426        let app = create_app_dir(&[("index.html", "<ext-card></ext-card>")]);
3427        let ext_dir = TempDir::new().unwrap();
3428        fs::write(ext_dir.path().join("ext-card.html"), "<p>{{title}}</p>").unwrap();
3429
3430        let mut options = default_options(app.path());
3431        options.plugin = Some(Plugin::WebUI);
3432        options.components = vec![ext_dir.path().to_string_lossy().to_string()];
3433
3434        let result = build(options).unwrap();
3435        assert!(result
3436            .component_templates
3437            .iter()
3438            .any(|template| template.tag_name == "ext-card"));
3439        let component = result
3440            .protocol
3441            .components
3442            .get("ext-card")
3443            .expect("external scriptless component metadata should be retained");
3444        assert!(component.hydration_keys.is_empty());
3445        assert_eq!(component.navigation_keys, ["title"]);
3446        assert!(component.template_json.contains(r#""th":1"#));
3447    }
3448
3449    #[test]
3450    fn test_build_with_scripted_component_path_emits_client_artifact() {
3451        let app = create_app_dir(&[("index.html", "<ext-card></ext-card>")]);
3452        let ext_dir = TempDir::new().unwrap();
3453        fs::write(ext_dir.path().join("ext-card.html"), "<p>{{title}}</p>").unwrap();
3454        fs::write(ext_dir.path().join("ext-card.ts"), "export {};").unwrap();
3455
3456        let mut options = default_options(app.path());
3457        options.plugin = Some(Plugin::WebUI);
3458        options.components = vec![ext_dir.path().to_string_lossy().to_string()];
3459
3460        let result = build(options).unwrap();
3461        let template = &result.protocol.components["ext-card"].template_json;
3462        assert!(!template.is_empty(), "template should be emitted");
3463    }
3464
3465    #[test]
3466    fn test_build_rejects_projection_manifest_with_non_webui_plugin() {
3467        let app = create_app_dir(&[
3468            ("index.html", "<demo-card></demo-card>"),
3469            ("demo-card.html", "<p>{{name}}</p>"),
3470            ("demo-card.ts", "export {};"),
3471        ]);
3472        let manifest_json = projection::test_support::build_valid_manifest_json(
3473            app.path(),
3474            &[("demo-card.ts", "export {};")],
3475            &[("projection-bundle.js", "output")],
3476            &[(
3477                "demo-card",
3478                "demo-card.ts",
3479                &["projection-bundle.js"],
3480                &["name"],
3481                &["name"],
3482            )],
3483        );
3484        let manifest = projection::test_support::write_manifest(
3485            app.path(),
3486            "webui-projection.json",
3487            &manifest_json,
3488        );
3489        let mut options = default_options(app.path());
3490        // No plugin selected: default/FAST builds are incompatible with
3491        // projection manifests (`PROJ-B002`).
3492        options.projection_manifests = vec![manifest.into()];
3493        let err = build(options).expect_err("non-WebUI plugin + manifest should be rejected");
3494        assert!(
3495            err.chain_message().contains("PROJ-B002"),
3496            "msg: {}",
3497            err.chain_message()
3498        );
3499    }
3500
3501    #[test]
3502    fn test_build_rejects_missing_scripted_component_coverage() {
3503        let app = create_app_dir(&[
3504            ("index.html", "<demo-card></demo-card>"),
3505            ("demo-card.html", "<p>{{name}}</p>"),
3506            ("demo-card.ts", "export {};"),
3507        ]);
3508        // Manifest is valid but never mentions `demo-card`, the only
3509        // compiled scripted component in this build.
3510        let manifest_json =
3511            projection::test_support::build_valid_manifest_json(app.path(), &[], &[], &[]);
3512        let manifest = projection::test_support::write_manifest(
3513            app.path(),
3514            "webui-projection.json",
3515            &manifest_json,
3516        );
3517        let mut options = default_options(app.path());
3518        options.plugin = Some(Plugin::WebUI);
3519        options.projection_manifests = vec![manifest.into()];
3520        let err = build(options).expect_err("missing coverage should fail the build");
3521        let message = err.chain_message();
3522        assert!(message.contains("PROJ-B001"), "msg: {message}");
3523        assert!(message.contains("demo-card"), "msg: {message}");
3524    }
3525
3526    #[test]
3527    fn test_build_allows_unused_discovered_scripted_component_without_coverage() {
3528        let app = create_app_dir(&[
3529            ("index.html", "<demo-card></demo-card>"),
3530            ("demo-card.html", "<p>{{name}}</p>"),
3531            ("demo-card.ts", "export {};"),
3532            // `unused-card` is a scripted component discovered on disk but
3533            // never referenced from `index.html`, so it is not compiled
3534            // into the protocol and does not require manifest coverage.
3535            ("unused-card.html", "<p>{{title}}</p>"),
3536            ("unused-card.ts", "export {};"),
3537        ]);
3538        let manifest_json = projection::test_support::build_valid_manifest_json(
3539            app.path(),
3540            &[("demo-card.ts", "export {};")],
3541            &[("projection-bundle.js", "output")],
3542            &[(
3543                "demo-card",
3544                "demo-card.ts",
3545                &["projection-bundle.js"],
3546                &["name"],
3547                &["name"],
3548            )],
3549        );
3550        let manifest = projection::test_support::write_manifest(
3551            app.path(),
3552            "webui-projection.json",
3553            &manifest_json,
3554        );
3555        let mut options = default_options(app.path());
3556        options.plugin = Some(Plugin::WebUI);
3557        options.projection_manifests = vec![manifest.into()];
3558        let result = build(options).unwrap();
3559
3560        assert!(!result.protocol.fragments.contains_key("unused-card"));
3561        let component = result.protocol.components.get("demo-card").unwrap();
3562        assert_eq!(component.hydration_keys, vec!["name"]);
3563    }
3564
3565    #[test]
3566    fn test_build_allows_asset_only_scripted_component_without_projection_coverage() {
3567        let app = create_app_dir(&[
3568            ("index.html", "<app-shell></app-shell>"),
3569            ("app-shell.html", "<p>Entry</p>"),
3570            ("lazy-panel.html", "<p>{{title}}</p>"),
3571            ("lazy-panel.ts", "export {};"),
3572        ]);
3573        let manifest_json =
3574            projection::test_support::build_valid_manifest_json(app.path(), &[], &[], &[]);
3575        let manifest = projection::test_support::write_manifest(
3576            app.path(),
3577            "webui-projection.json",
3578            &manifest_json,
3579        );
3580        let mut options = default_options(app.path());
3581        options.plugin = Some(Plugin::WebUI);
3582        options.component_asset_roots = vec!["lazy-panel".to_string()];
3583        options.projection_manifests = vec![manifest.into()];
3584
3585        let result = build(options).unwrap();
3586
3587        assert_eq!(result.component_asset_files.len(), 2);
3588        assert!(!result.protocol.fragments.contains_key("lazy-panel"));
3589        assert!(!result.protocol.components.contains_key("lazy-panel"));
3590    }
3591
3592    #[test]
3593    fn test_build_merges_multiple_projection_manifest_fragments() {
3594        let app = create_app_dir(&[
3595            (
3596                "index.html",
3597                "<html><body><card-a></card-a><card-b></card-b></body></html>",
3598            ),
3599            ("card-a.html", "<p>{{alpha}}</p>"),
3600            ("card-a.ts", "export {};"),
3601            ("card-b.html", "<p>{{beta}}</p>"),
3602            ("card-b.ts", "export {};"),
3603        ]);
3604        let json_a = projection::test_support::build_valid_manifest_json(
3605            app.path(),
3606            &[("card-a.ts", "export {};")],
3607            &[("card-a.js", "output-a")],
3608            &[(
3609                "card-a",
3610                "card-a.ts",
3611                &["card-a.js"],
3612                &["alpha"],
3613                &["alpha"],
3614            )],
3615        );
3616        let manifest_a =
3617            projection::test_support::write_manifest(app.path(), "fragment-a.json", &json_a);
3618        let json_b = projection::test_support::build_valid_manifest_json(
3619            app.path(),
3620            &[("card-b.ts", "export {};")],
3621            &[("card-b.js", "output-b")],
3622            &[("card-b", "card-b.ts", &["card-b.js"], &["beta"], &["beta"])],
3623        );
3624        let manifest_b =
3625            projection::test_support::write_manifest(app.path(), "fragment-b.json", &json_b);
3626
3627        let mut options = default_options(app.path());
3628        options.plugin = Some(Plugin::WebUI);
3629        options.projection_manifests = vec![manifest_a.into(), manifest_b.into()];
3630        let result = build(options).unwrap();
3631
3632        assert_eq!(
3633            result.protocol.initial_state_strategy,
3634            webui_protocol::InitialStateStrategy::Components as i32
3635        );
3636        let card_a = result.protocol.components.get("card-a").unwrap();
3637        assert_eq!(card_a.hydration_keys, vec!["alpha"]);
3638        let card_b = result.protocol.components.get("card-b").unwrap();
3639        assert_eq!(card_b.hydration_keys, vec!["beta"]);
3640    }
3641
3642    #[test]
3643    fn test_build_hello_world_example() {
3644        let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
3645        let app_dir = manifest_dir.join("../../examples/app/hello-world/src");
3646
3647        let result = build(BuildOptions {
3648            app_dir,
3649            entry: "index.html".to_string(),
3650            ..BuildOptions::default()
3651        })
3652        .unwrap();
3653
3654        let index = &result.protocol.fragments["index.html"].fragments;
3655        assert!(index.iter().any(
3656            |f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(fl)) if fl.collection == "people")
3657        ));
3658        assert!(index
3659            .iter()
3660            .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
3661    }
3662
3663    // ── Security tests ───────────────────────────────────────────────
3664
3665    #[test]
3666    fn test_css_filename_sanitizes_path_separators() {
3667        // Even if a component tag somehow contains path separators,
3668        // the filename should be sanitized to prevent directory traversal.
3669        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
3670        let result = build(default_options(app.path())).unwrap();
3671
3672        // Verify all CSS filenames are plain file names (no path separators)
3673        for (filename, _) in &result.css_files {
3674            assert!(
3675                !filename.contains('/') && !filename.contains('\\'),
3676                "CSS filename contains path separator: {filename}"
3677            );
3678        }
3679    }
3680
3681    #[test]
3682    fn test_build_to_disk_css_stays_in_output_dir() {
3683        let app = create_app_dir(&[
3684            ("index.html", "<my-card>Hello</my-card>"),
3685            (
3686                "my-card.html",
3687                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
3688            ),
3689            ("my-card.css", ".card { color: red; }"),
3690        ]);
3691        let out = TempDir::new().unwrap();
3692
3693        build_to_disk(default_options(app.path()), out.path()).unwrap();
3694
3695        // Verify CSS file is written inside out_dir
3696        let css_path = out.path().join("my-card.css");
3697        assert!(css_path.exists());
3698        let canonical = css_path.canonicalize().unwrap();
3699        let out_canonical = out.path().canonicalize().unwrap();
3700        assert!(
3701            canonical.starts_with(&out_canonical),
3702            "CSS file escaped output directory"
3703        );
3704    }
3705
3706    // ── Performance / edge case tests ────────────────────────────────
3707
3708    #[test]
3709    fn test_build_empty_html() {
3710        let app = create_app_dir(&[("index.html", "")]);
3711        let result = build(default_options(app.path())).unwrap();
3712
3713        assert!(result.protocol.fragments.contains_key("index.html"));
3714        assert_eq!(result.stats.fragment_count, 0);
3715        assert!(result.stats.protocol_size_bytes > 0);
3716    }
3717
3718    #[test]
3719    fn test_build_large_fragment_count() {
3720        // Verify stats are accurate with many fragments
3721        let mut html = String::with_capacity(2000);
3722        for i in 0..50 {
3723            html.push_str(&format!("<p>Item {i}</p>\n"));
3724        }
3725        let app = create_app_dir(&[("index.html", &html)]);
3726        let result = build(default_options(app.path())).unwrap();
3727
3728        assert!(result.stats.fragment_count > 0);
3729        assert_eq!(result.stats.css_file_count, 0);
3730    }
3731
3732    #[test]
3733    fn test_build_to_disk_inline_mode_no_css_files() {
3734        let app = create_app_dir(&[
3735            ("index.html", "<my-card>Hello</my-card>"),
3736            (
3737                "my-card.html",
3738                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
3739            ),
3740            ("my-card.css", ".card { color: red; }"),
3741        ]);
3742        let out = TempDir::new().unwrap();
3743        let mut options = default_options(app.path());
3744        options.css = CssStrategy::Style;
3745
3746        let stats = build_to_disk(options, out.path()).unwrap();
3747
3748        assert!(out.path().join("protocol.bin").exists());
3749        assert!(!out.path().join("my-card.css").exists());
3750        assert_eq!(stats.css_file_count, 0);
3751    }
3752
3753    #[test]
3754    fn test_build_stats_duration_is_nonzero() {
3755        let app = create_app_dir(&[("index.html", "<h1>Hello {{name}}</h1>")]);
3756        let result = build(default_options(app.path())).unwrap();
3757
3758        assert!(!result.stats.duration.is_zero());
3759    }
3760
3761    #[test]
3762    fn test_build_multiple_components_css() {
3763        let app = create_app_dir(&[
3764            ("index.html", "<card-a>A</card-a><card-b>B</card-b>"),
3765            (
3766                "card-a.html",
3767                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
3768            ),
3769            ("card-a.css", ".a { color: red; }"),
3770            (
3771                "card-b.html",
3772                r#"<template shadowrootmode="open"><span><slot></slot></span></template>"#,
3773            ),
3774            ("card-b.css", ".b { color: blue; }"),
3775        ]);
3776        let result = build(default_options(app.path())).unwrap();
3777
3778        assert_eq!(result.css_files.len(), 2);
3779        assert_eq!(result.stats.css_file_count, 2);
3780        let filenames: Vec<&str> = result.css_files.iter().map(|(n, _)| n.as_str()).collect();
3781        assert!(filenames.contains(&"card-a.css"));
3782        assert!(filenames.contains(&"card-b.css"));
3783    }
3784
3785    #[test]
3786    fn test_build_unused_component_css_not_emitted() {
3787        // card-b is registered but not referenced in index.html
3788        let app = create_app_dir(&[
3789            ("index.html", "<card-a>A</card-a>"),
3790            (
3791                "card-a.html",
3792                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
3793            ),
3794            ("card-a.css", ".a { color: red; }"),
3795            (
3796                "card-b.html",
3797                r#"<template shadowrootmode="open"><span><slot></slot></span></template>"#,
3798            ),
3799            ("card-b.css", ".b { color: blue; }"),
3800        ]);
3801        let result = build(default_options(app.path())).unwrap();
3802
3803        assert_eq!(result.css_files.len(), 1);
3804        assert_eq!(result.css_files[0].0, "card-a.css");
3805    }
3806
3807    #[test]
3808    fn test_build_unused_component_render_policy_not_emitted() {
3809        let app = create_app_dir(&[
3810            ("index.html", "<card-a>A</card-a>"),
3811            (
3812                "card-a.html",
3813                r#"<template w-render="lazy" w-reserve-block-size="10px"><div>a</div></template>"#,
3814            ),
3815            (
3816                "card-b.html",
3817                r#"<template w-render="lazy" w-reserve-block-size="20px"><div>b</div></template>"#,
3818            ),
3819        ]);
3820        let result = build(default_options(app.path())).unwrap();
3821
3822        assert!(result.protocol.component_render_css.contains("card-a"));
3823        assert!(!result.protocol.component_render_css.contains("card-b"));
3824    }
3825
3826    #[test]
3827    fn test_build_persists_interaction_work_policy() {
3828        let app = create_app_dir(&[
3829            ("index.html", "<interaction-shell></interaction-shell>"),
3830            (
3831                "interaction-shell.html",
3832                r#"<template w-hydrate="interaction"><button>Open</button></template>"#,
3833            ),
3834            (
3835                "interaction-shell.ts",
3836                "customElements.define('interaction-shell', class extends HTMLElement {});",
3837            ),
3838        ]);
3839        for plugin in [Plugin::WebUI, Plugin::FastV3] {
3840            let is_webui = matches!(plugin, Plugin::WebUI);
3841            let mut options = default_options(app.path());
3842            options.plugin = Some(plugin);
3843            let result = build(options).expect("interaction policy build");
3844            let component = &result.protocol.components["interaction-shell"];
3845            assert_eq!(
3846                component.work_policy,
3847                webui_protocol::ComponentWorkPolicy::Interaction as i32
3848            );
3849            if is_webui {
3850                assert!(component.template_json.contains(r#""wp":3"#));
3851            }
3852        }
3853    }
3854
3855    #[test]
3856    fn test_build_persists_combined_render_interaction_policy() {
3857        let app = create_app_dir(&[
3858            ("index.html", "<interaction-panel></interaction-panel>"),
3859            (
3860                "interaction-panel.html",
3861                concat!(
3862                    r#"<template w-render="lazy" w-reserve-block-size="18rem" "#,
3863                    r#"w-hydrate="interaction"><button>Open</button></template>"#,
3864                ),
3865            ),
3866            (
3867                "interaction-panel.ts",
3868                "customElements.define('interaction-panel', class extends HTMLElement {});",
3869            ),
3870        ]);
3871        for plugin in [Plugin::WebUI, Plugin::FastV3] {
3872            let is_webui = matches!(plugin, Plugin::WebUI);
3873            let mut options = default_options(app.path());
3874            options.plugin = Some(plugin);
3875            let result = build(options).expect("combined interaction policy build");
3876            let component = &result.protocol.components["interaction-panel"];
3877            assert_eq!(
3878                component.work_policy,
3879                webui_protocol::ComponentWorkPolicy::LazyRenderInteraction as i32
3880            );
3881            assert!(result
3882                .protocol
3883                .component_render_css
3884                .contains("interaction-panel"));
3885            if is_webui {
3886                assert!(component.template_json.contains(r#""wp":4"#));
3887            }
3888        }
3889    }
3890
3891    #[test]
3892    fn test_inspect_roundtrip_preserves_content() {
3893        let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
3894        let result = build(default_options(app.path())).unwrap();
3895
3896        let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
3897        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
3898
3899        let fragments = &parsed["fragments"]["index.html"]["fragments"];
3900        assert!(fragments.is_array());
3901        assert!(!fragments.as_array().unwrap().is_empty());
3902    }
3903
3904    #[test]
3905    fn test_build_nonexistent_app_dir() {
3906        let options = BuildOptions {
3907            app_dir: PathBuf::from("/nonexistent/path/that/does/not/exist"),
3908            ..BuildOptions::default()
3909        };
3910        let result = build(options);
3911        assert!(result.is_err());
3912    }
3913
3914    #[test]
3915    fn test_build_allows_fallback_chain_when_all_theme_tokens_exist() {
3916        let app = create_app_dir(&[
3917            ("index.html", "<my-card></my-card>"),
3918            ("my-card.html", "<div>Card</div>"),
3919            (
3920                "my-card.css",
3921                ":host { color: var(--token-a, var(--token-b, var(--token-c))); }",
3922            ),
3923        ]);
3924        let mut options = default_options(app.path());
3925        options.theme = Some(TokenFile {
3926            themes: HashMap::from([(
3927                "light".to_string(),
3928                HashMap::from([
3929                    ("token-a".to_string(), "red".to_string()),
3930                    ("token-b".to_string(), "blue".to_string()),
3931                    ("token-c".to_string(), "green".to_string()),
3932                ]),
3933            )]),
3934        });
3935
3936        let result = build(options).expect("all fallback tokens are defined");
3937        assert!(result.protocol.tokens.contains(&"token-a".to_string()));
3938        assert!(result.protocol.tokens.contains(&"token-b".to_string()));
3939        assert!(result.protocol.tokens.contains(&"token-c".to_string()));
3940    }
3941
3942    #[test]
3943    fn test_build_allows_literal_fallback_token_absent_from_theme() {
3944        let app = create_app_dir(&[
3945            ("index.html", "<my-card></my-card>"),
3946            ("my-card.html", "<div>Card</div>"),
3947            ("my-card.css", ":host { color: var(--brand, #000); }"),
3948        ]);
3949        let mut options = default_options(app.path());
3950        options.theme = Some(TokenFile {
3951            themes: HashMap::from([("light".to_string(), HashMap::new())]),
3952        });
3953
3954        // The CSS literal fallback (`#000`) means `--brand` is not required from
3955        // the theme, so the build must succeed — but the token is still hoisted
3956        // so the runtime resolves it when a theme provides it.
3957        let result = build(options).expect("literal fallback should not fail the build");
3958        assert!(result.protocol.tokens.contains(&"brand".to_string()));
3959        // `--brand` is absent from every theme and only used with a literal
3960        // fallback, so it is surfaced as a non-fatal typo advisory.
3961        assert_eq!(result.warnings.len(), 1, "warnings: {:?}", result.warnings);
3962        assert!(result.warnings[0].body().contains("--brand"));
3963    }
3964
3965    #[test]
3966    fn test_build_ancestor_inline_style_definition_after_comment_satisfies_descendant() {
3967        // `--foo-bar` is defined in the entry's inline <style> after a signal
3968        // comment and consumed inside a component. Custom properties inherit
3969        // through Shadow DOM, so the ancestor definition must satisfy the
3970        // descendant and the token must not be required from the theme.
3971        let app = create_app_dir(&[
3972            (
3973                "index.html",
3974                "<html><head><style>:root {\n  /*{{{tokens.light}}}*/\n  --foo-bar: 100px;\n}</style></head><body><my-card></my-card></body></html>",
3975            ),
3976            ("my-card.html", "<div>Card</div>"),
3977            ("my-card.css", ":host { padding: var(--foo-bar); }"),
3978        ]);
3979        let mut options = default_options(app.path());
3980        options.theme = Some(TokenFile {
3981            themes: HashMap::from([("light".to_string(), HashMap::new())]),
3982        });
3983
3984        let result = build(options)
3985            .expect("ancestor inline-style definition (after a comment) should satisfy the child");
3986        assert!(
3987            !result.protocol.tokens.contains(&"foo-bar".to_string()),
3988            "ancestor-defined token should not be hoisted: {:?}",
3989            result.protocol.tokens
3990        );
3991    }
3992
3993    #[test]
3994    fn test_build_no_warning_when_literal_fallback_token_is_themed() {
3995        let app = create_app_dir(&[
3996            ("index.html", "<my-card></my-card>"),
3997            ("my-card.html", "<div>Card</div>"),
3998            ("my-card.css", ":host { color: var(--brand, #000); }"),
3999        ]);
4000        let mut options = default_options(app.path());
4001        options.theme = Some(TokenFile {
4002            themes: HashMap::from([(
4003                "light".to_string(),
4004                HashMap::from([("brand".to_string(), "#123456".to_string())]),
4005            )]),
4006        });
4007
4008        let result = build(options).expect("themed literal-fallback token builds");
4009        assert!(
4010            result.warnings.is_empty(),
4011            "a themed token must not warn: {:?}",
4012            result.warnings
4013        );
4014    }
4015
4016    #[test]
4017    fn test_build_without_theme_produces_no_warnings() {
4018        let app = create_app_dir(&[
4019            ("index.html", "<my-card></my-card>"),
4020            ("my-card.html", "<div>Card</div>"),
4021            ("my-card.css", ":host { color: var(--brand, #000); }"),
4022        ]);
4023        let result = build(default_options(app.path())).unwrap();
4024        assert!(result.warnings.is_empty());
4025    }
4026
4027    #[test]
4028    fn test_build_warns_for_multiple_outlets_in_route_component() {
4029        let app = create_app_dir(&[
4030            (
4031                "index.html",
4032                r#"<route path="/" component="app-shell"><route path="child" component="child-page" exact /></route>"#,
4033            ),
4034            (
4035                "app-shell.html",
4036                r#"<main><outlet /></main><aside><outlet /></aside>"#,
4037            ),
4038            ("child-page.html", "<p>Child</p>"),
4039        ]);
4040
4041        let result = build(default_options(app.path())).unwrap();
4042        assert_eq!(result.warnings.len(), 1, "warnings: {:?}", result.warnings);
4043        let warning = &result.warnings[0];
4044        assert_eq!(warning.severity(), Severity::Warning);
4045        assert_eq!(
4046            warning.error_code(),
4047            Some(webui_parser::codes::MULTIPLE_OUTLETS)
4048        );
4049        assert_eq!(warning.component_name(), Some("app-shell"));
4050        assert!(warning.help_text().is_some());
4051    }
4052
4053    #[test]
4054    fn test_build_warns_for_outlets_across_component_templates() {
4055        let app = create_app_dir(&[
4056            (
4057                "index.html",
4058                r#"<route path="/" component="app-shell"><route path="child" component="child-page" exact /></route>"#,
4059            ),
4060            (
4061                "app-shell.html",
4062                r#"<nested-layout></nested-layout><aside><outlet /></aside>"#,
4063            ),
4064            ("nested-layout.html", "<main><outlet /></main>"),
4065            ("child-page.html", "<p>Matched child</p>"),
4066        ]);
4067
4068        let result = build(default_options(app.path())).unwrap();
4069        let html = render_html(&result.protocol, "/child");
4070        assert_eq!(html.matches("<p>Matched child</p>").count(), 1, "{html}");
4071        assert!(html.contains("<aside></aside>"), "{html}");
4072        assert_eq!(result.warnings.len(), 1, "warnings: {:?}", result.warnings);
4073        assert_eq!(
4074            result.warnings[0].error_code(),
4075            Some(webui_parser::codes::MULTIPLE_OUTLETS)
4076        );
4077        assert_eq!(result.warnings[0].component_name(), Some("app-shell"));
4078    }
4079
4080    #[test]
4081    fn test_build_excludes_tokens_defined_by_ancestor_component_css() {
4082        let app = create_app_dir(&[
4083            ("index.html", "<component-a></component-a>"),
4084            ("component-a.html", "<component-b></component-b>"),
4085            ("component-a.css", ":host { --brand-color: red; }"),
4086            ("component-b.html", "<div>Child</div>"),
4087            ("component-b.css", ":host { color: var(--brand-color); }"),
4088        ]);
4089        let mut options = default_options(app.path());
4090        options.theme = Some(TokenFile {
4091            themes: HashMap::from([("light".to_string(), HashMap::new())]),
4092        });
4093
4094        let result =
4095            build(options).expect("ancestor component custom property should satisfy child");
4096        assert!(
4097            !result.protocol.tokens.contains(&"brand-color".to_string()),
4098            "ancestor-defined token should not be exposed: {:?}",
4099            result.protocol.tokens
4100        );
4101    }
4102
4103    #[test]
4104    fn test_build_scoped_parent_overrides_preserve_theme_defaults() {
4105        for selector in [
4106            ".green",
4107            ":host([split]) plain-control",
4108            ":host([active])",
4109            "@media (width > 10000px) { :host",
4110            "@supports (display: unknown) { :host",
4111            "@container (width > 10000px) { :host",
4112        ] {
4113            let mut css = format!("{selector} {{ --brand: var(--green); }}");
4114            if selector.starts_with('@') {
4115                css.push('}');
4116            }
4117            let app = create_app_dir(&[
4118                ("index.html", "<layout-container></layout-container>"),
4119                (
4120                    "layout-container.html",
4121                    "<plain-control></plain-control><div class=\"green\"><plain-control></plain-control></div>",
4122                ),
4123                ("layout-container.css", &css),
4124                ("plain-control.html", "<button>Primary</button>"),
4125                ("plain-control.css", "button { color: var(--brand); }"),
4126            ]);
4127            let mut options = default_options(app.path());
4128            options.theme = Some(TokenFile {
4129                themes: HashMap::from([(
4130                    "light".to_string(),
4131                    HashMap::from([
4132                        ("brand".to_string(), "black".to_string()),
4133                        ("green".to_string(), "green".to_string()),
4134                    ]),
4135                )]),
4136            });
4137            let result = build(options).expect("scoped overrides should build");
4138            assert_eq!(result.protocol.tokens, ["brand", "green"], "{selector}");
4139
4140            let mut options = default_options(app.path());
4141            options.theme = Some(TokenFile {
4142                themes: HashMap::from([(
4143                    "light".to_string(),
4144                    HashMap::from([("green".to_string(), "green".to_string())]),
4145                )]),
4146            });
4147            let error = build(options).expect_err("uncovered child still requires --brand");
4148            assert!(error.chain_message().contains("missing-theme-token"));
4149        }
4150    }
4151
4152    #[test]
4153    fn test_build_scoped_inline_overrides_preserve_theme_defaults() {
4154        let app = create_app_dir(&[
4155            (
4156                "index.html",
4157                "<style>.green { --brand: var(--green); }</style><plain-control></plain-control><div class=\"green\"><plain-control></plain-control></div>",
4158            ),
4159            ("plain-control.html", "<button>Primary</button>"),
4160            ("plain-control.css", "button { color: var(--brand); }"),
4161        ]);
4162        let result = build(default_options(app.path())).expect("build");
4163        assert_eq!(result.protocol.tokens, ["brand", "green"]);
4164    }
4165
4166    #[test]
4167    fn test_build_does_not_use_sibling_component_definitions_for_theme_validation() {
4168        let app = create_app_dir(&[
4169            (
4170                "index.html",
4171                "<component-a></component-a><component-b></component-b>",
4172            ),
4173            ("component-a.html", "<div>A</div>"),
4174            ("component-a.css", ":host { --brand-color: red; }"),
4175            ("component-b.html", "<div>B</div>"),
4176            ("component-b.css", ":host { color: var(--brand-color); }"),
4177        ]);
4178        let mut options = default_options(app.path());
4179        options.theme = Some(TokenFile {
4180            themes: HashMap::from([("light".to_string(), HashMap::new())]),
4181        });
4182
4183        let Err(err) = build(options) else {
4184            panic!("sibling custom property must not satisfy component-b");
4185        };
4186        let message = err.chain_message();
4187        assert!(message.contains("missing theme token"), "msg: {message}");
4188        assert!(message.contains("missing-theme-token"), "msg: {message}");
4189        assert!(message.contains("--brand-color"), "msg: {message}");
4190    }
4191
4192    #[test]
4193    fn test_build_rejects_missing_theme_token_after_local_definition_filter() {
4194        let app = create_app_dir(&[
4195            ("index.html", "<my-card></my-card>"),
4196            ("my-card.html", "<div>Card</div>"),
4197            (
4198                "my-card.css",
4199                ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c))); }",
4200            ),
4201        ]);
4202        let mut options = default_options(app.path());
4203        options.theme = Some(TokenFile {
4204            themes: HashMap::from([(
4205                "light".to_string(),
4206                HashMap::from([("token-b".to_string(), "green".to_string())]),
4207            )]),
4208        });
4209
4210        let Err(err) = build(options) else {
4211            panic!("missing --token-c in the theme must fail the build");
4212        };
4213        let message = err.chain_message();
4214        assert!(message.contains("missing-theme-token"), "msg: {message}");
4215        assert!(message.contains("--token-c"), "msg: {message}");
4216        // The error demands the missing `--token-c`, never the locally-defined
4217        // `--token-a` (which only appears in the source snippet as context).
4218        assert!(!message.contains("add --token-a"), "msg: {message}");
4219    }
4220
4221    mod package_discovery {
4222        use super::*;
4223
4224        type TestResult = Result<(), Box<dyn std::error::Error>>;
4225
4226        fn write(root: &Path, name: &str, content: &str) -> TestResult {
4227            let path = root.join(name);
4228            if let Some(parent) = path.parent() {
4229                fs::create_dir_all(parent)?;
4230            }
4231            fs::write(path, content)?;
4232            Ok(())
4233        }
4234
4235        fn package_fixture(package: &Path, catalog: Option<&str>) -> TestResult {
4236            let mut metadata = serde_json::json!({
4237                "name": "@fixture/catalog", "customElements": "custom-elements.json"
4238            });
4239            if let Some(catalog) = catalog {
4240                metadata["webui"] = serde_json::json!({"components": [catalog]});
4241            }
4242            write(package, "package.json", &metadata.to_string())?;
4243            let catalog = package.join(catalog.unwrap_or("components"));
4244            write(&catalog, "fixture-card.html", "<p>Package component</p>")?;
4245            write(&catalog, "fixture-card.css", "p { color: blue; }")?;
4246            write(
4247                &catalog,
4248                "fixture-card.v2.html",
4249                "<span>Dotted component</span>",
4250            )?;
4251            write(
4252                package,
4253                "custom-elements.json",
4254                r#"{"modules":[{"path":"dist/manifest-card.js","declarations":[{"name":"ManifestCard","tagName":"manifest-card"}]}]}"#,
4255            )?;
4256            write(package, "dist/manifest-card.js", "export {};")?;
4257            write(
4258                package,
4259                "dist/manifest-card.template-webui.html",
4260                r#"<f-template name="manifest-card"><template><p>Manifest component</p></template></f-template>"#,
4261            )?;
4262            for report in [
4263                "test_results/coverage/lcov-report/core/initial-state.ts.html",
4264                "test_results/coverage/lcov-report/initial-state.ts.html",
4265            ] {
4266                write(
4267                    package,
4268                    report,
4269                    "<!doctype html><html><body>Generated coverage report</body></html>",
4270                )?;
4271            }
4272            Ok(())
4273        }
4274
4275        fn assert_build(root: &Path, plugin: Option<Plugin>) -> TestResult {
4276            let manifest = matches!(plugin, Some(Plugin::FastV2 | Plugin::FastV3));
4277            let app = root.join("app/src");
4278            write(&app, "app-local.html", "<span>App component</span>")?;
4279            write(
4280                &app,
4281                "index.html",
4282                "<app-local></app-local><fixture-card></fixture-card><fixture-card.v2></fixture-card.v2><manifest-card></manifest-card>",
4283            )?;
4284            let options = BuildOptions {
4285                app_dir: app,
4286                components: vec!["@fixture/catalog".to_string()],
4287                plugin,
4288                css: CssStrategy::Style,
4289                ..BuildOptions::default()
4290            };
4291            for _ in 0..2 {
4292                let result = build(options.clone())?;
4293                assert_eq!(result.stats.component_count, if manifest { 4 } else { 3 });
4294                assert!(!result.protocol.fragments.contains_key("initial-state.ts"));
4295                let mut writer = StringWriter { buf: String::new() };
4296                WebUIHandler::new().render(
4297                    &Protocol::new(result.protocol),
4298                    &serde_json::json!({}),
4299                    &RenderOptions::new("index.html", "/"),
4300                    &mut writer,
4301                )?;
4302                for text in ["App component", "Package component", "Dotted component"] {
4303                    assert!(writer.buf.contains(text), "Missing rendered {text}");
4304                }
4305                assert!(writer.buf.contains("color: blue"));
4306                assert!(!writer.buf.contains("Generated coverage report"));
4307                assert_eq!(writer.buf.contains("Manifest component"), manifest);
4308            }
4309            Ok(())
4310        }
4311
4312        #[test]
4313        fn package_build_excludes_reports_for_default_and_explicit_catalogs() -> TestResult {
4314            for (plugin, catalog) in [
4315                (None, None),
4316                (Some(Plugin::WebUI), Some("./src/components")),
4317                (Some(Plugin::FastV2), None),
4318                (Some(Plugin::FastV3), Some("./src/components")),
4319            ] {
4320                let root = tempfile::tempdir()?;
4321                package_fixture(&root.path().join("node_modules/@fixture/catalog"), catalog)?;
4322                assert_build(root.path(), plugin)?;
4323            }
4324            Ok(())
4325        }
4326
4327        #[cfg(unix)]
4328        #[test]
4329        fn symlinked_workspace_package_build_excludes_generated_reports() -> TestResult {
4330            let root = tempfile::tempdir()?;
4331            let package = root.path().join("packages/catalog");
4332            package_fixture(&package, Some("./src/components"))?;
4333            fs::create_dir_all(root.path().join("node_modules/@fixture"))?;
4334            std::os::unix::fs::symlink(
4335                &package,
4336                root.path().join("node_modules/@fixture/catalog"),
4337            )?;
4338            assert_build(root.path(), Some(Plugin::FastV3))
4339        }
4340
4341        #[test]
4342        fn distinct_files_and_overlapping_roots_preserve_duplicate_errors() -> TestResult {
4343            for (roots, second_file) in [
4344                (["./a", "./b"], Some("b/fixture-card.html")),
4345                (["./a", "./a"], None),
4346                (["./", "./a"], None),
4347                (["./a", "./"], None),
4348            ] {
4349                let root = tempfile::tempdir()?;
4350                let package = root.path().join("node_modules/@fixture/catalog");
4351                write(
4352                    &package,
4353                    "package.json",
4354                    &serde_json::json!({"webui":{"components":roots}}).to_string(),
4355                )?;
4356                write(&package, "a/fixture-card.html", "<p>First</p>")?;
4357                if let Some(path) = second_file {
4358                    write(&package, path, "<p>Second</p>")?;
4359                }
4360                let app = root.path().join("app");
4361                write(&app, "index.html", "<fixture-card></fixture-card>")?;
4362                let error = build(BuildOptions {
4363                    app_dir: app,
4364                    components: vec!["@fixture/catalog".to_string()],
4365                    ..BuildOptions::default()
4366                })
4367                .expect_err("Duplicate component names must remain errors");
4368                let message = error.chain_message();
4369                assert!(message.contains("fixture-card"), "{message}");
4370                assert!(message.contains("already registered"), "{message}");
4371            }
4372            Ok(())
4373        }
4374    }
4375
4376    #[test]
4377    fn test_build_to_disk_returns_accurate_stats() {
4378        let app = create_app_dir(&[
4379            ("index.html", "<my-card>Hello</my-card><p>{{name}}</p>"),
4380            (
4381                "my-card.html",
4382                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
4383            ),
4384            ("my-card.css", ".card { color: red; }"),
4385        ]);
4386        let out = TempDir::new().unwrap();
4387
4388        let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
4389
4390        assert!(stats.fragment_count > 0);
4391        assert_eq!(stats.css_file_count, 1);
4392        assert!(stats.component_count > 0);
4393        assert!(stats.protocol_size_bytes > 0);
4394        assert!(!stats.duration.is_zero());
4395    }
4396}