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