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