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 named_for_builds_recursive_component_assets_and_styles() {
1611        for (css, expected_tokens) in [
1612            (":root { --accent: red; }", &[][..]),
1613            ("tree-view { --accent: red; }", &["accent"][..]),
1614        ] {
1615            let app = create_app_dir(&[
1616                ("index.html", "<tree-view></tree-view>"),
1617                (
1618                    "tree-view.html",
1619                    r#"<for id="tree-item" each="child in items"><span>{{child.name}}</span><token-leaf></token-leaf><for id="tree-item" each="child in child.children" /></for>"#,
1620                ),
1621                ("tree-view.css", css),
1622                ("token-leaf.html", "<b>leaf</b>"),
1623                ("token-leaf.css", "b { color: var(--accent); }"),
1624            ]);
1625            let result = build(BuildOptions {
1626                plugin: Some(Plugin::WebUI),
1627                css_bundle: true,
1628                component_asset_roots: vec!["tree-view".to_string()],
1629                ..light_options(app.path())
1630            })
1631            .unwrap();
1632            assert_eq!(result.protocol.tokens, expected_tokens, "{css}");
1633            assert!(!result.component_asset_files.is_empty());
1634            assert_eq!(
1635                result.protocol.style_closures["tree-view"].component_tags,
1636                ["tree-view", "token-leaf"]
1637            );
1638            let meta: serde_json::Value =
1639                serde_json::from_str(&result.protocol.components["tree-view"].template_json)
1640                    .unwrap();
1641            let blocks = meta["b"].as_array().unwrap();
1642            assert_eq!(
1643                blocks.len(),
1644                1,
1645                "recursive depth does not expand compiled metadata"
1646            );
1647            assert_eq!(meta["r"][0][2], blocks[0]["r"][0][2]);
1648            assert!(result.protocol_bytes.len() < 16_384);
1649        }
1650    }
1651
1652    #[test]
1653    fn test_build_with_component_css() {
1654        let app = create_app_dir(&[
1655            ("index.html", "<my-card>Hello</my-card>"),
1656            (
1657                "my-card.html",
1658                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
1659            ),
1660            ("my-card.css", ".card { color: red; }"),
1661            ("my-card.ts", "export {};"),
1662        ]);
1663        let result = build(default_options(app.path())).unwrap();
1664
1665        assert_eq!(result.css_files.len(), 1);
1666        assert_eq!(result.css_files[0].0, "my-card.css");
1667        assert!(result.css_files[0].1.contains("color: red"));
1668        assert_eq!(result.stats.css_file_count, 1);
1669    }
1670
1671    #[test]
1672    fn light_css_bytes_align_across_link_style_and_module() {
1673        let authored = " \n.ready{color:red}\n ";
1674        let expected = authored;
1675
1676        for strategy in [CssStrategy::Link, CssStrategy::Style, CssStrategy::Module] {
1677            let app = create_app_dir(&[
1678                ("index.html", "<my-card></my-card>"),
1679                ("my-card.html", "<p>content</p>"),
1680                ("my-card.css", authored),
1681            ]);
1682            let mut options = light_options(app.path());
1683            options.css = strategy;
1684            if strategy == CssStrategy::Link {
1685                options.css_file_name_template = "[name]-[hash].[ext]".to_string();
1686            }
1687            let result = build(options).unwrap();
1688
1689            let actual = match strategy {
1690                CssStrategy::Link => {
1691                    let expected_name = webui_parser::CssLinkOptions::try_new(
1692                        "[name]-[hash].[ext]".to_string(),
1693                        None,
1694                    )
1695                    .unwrap()
1696                    .resolve("my-card", expected)
1697                    .filename;
1698                    assert_eq!(result.css_files[0].0, expected_name);
1699                    result.css_files[0].1.clone()
1700                }
1701                CssStrategy::Module => result.protocol.components["my-card"].css.clone(),
1702                CssStrategy::Style => {
1703                    assert_eq!(result.protocol.components["my-card"].css, expected);
1704                    result.protocol.components["my-card"].css.clone()
1705                }
1706            };
1707            assert_eq!(actual, expected, "mismatch for {strategy:?}");
1708            if strategy != CssStrategy::Link {
1709                assert_eq!(
1710                    result.protocol.component_style_resource("my-card"),
1711                    Some(expected)
1712                );
1713            }
1714            assert_eq!(
1715                result
1716                    .protocol
1717                    .style_closure("index.html")
1718                    .expect("entry closure"),
1719                ["my-card"],
1720                "Light CSS must be installed by the Document closure"
1721            );
1722
1723            let protocol = Protocol::new(result.protocol.clone());
1724            let mut writer = StringWriter { buf: String::new() };
1725            WebUIHandler::new()
1726                .render(
1727                    &protocol,
1728                    &serde_json::json!({}),
1729                    &RenderOptions::new("index.html", "/"),
1730                    &mut writer,
1731                )
1732                .unwrap();
1733            match strategy {
1734                CssStrategy::Link => {
1735                    let href = &result.protocol.components["my-card"].css_href;
1736                    assert!(writer.buf.contains(&format!(
1737                        r#"<link rel="stylesheet" href="{href}" data-webui-resource="my-card" data-webui-strategy="link">"#
1738                    )));
1739                }
1740                CssStrategy::Style | CssStrategy::Module => {
1741                    let kind = if strategy == CssStrategy::Module {
1742                        "module"
1743                    } else {
1744                        "style"
1745                    };
1746                    assert!(writer.buf.contains(&format!(
1747                        r#"<style data-webui-resource="my-card" data-webui-strategy="{kind}">{expected}</style>"#
1748                    )));
1749                }
1750            }
1751        }
1752    }
1753
1754    #[test]
1755    fn build_precomputes_style_closures_with_authored_shadow_cut() {
1756        let app = create_app_dir(&[
1757            (
1758                "index.html",
1759                "<light-card></light-card><shadow-card><after-card></after-card></shadow-card>",
1760            ),
1761            ("light-card.html", "<nested-card></nested-card>"),
1762            ("light-card.css", ".light{}"),
1763            (
1764                "shadow-card.html",
1765                r#"<template shadowrootmode="open"><nested-card></nested-card></template>"#,
1766            ),
1767            ("shadow-card.css", ".shadow{}"),
1768            ("nested-card.html", "<span>nested</span>"),
1769            ("nested-card.css", ".nested{}"),
1770            ("after-card.html", "<span>after</span>"),
1771            ("after-card.css", ".after{}"),
1772        ]);
1773        let mut options = light_options(app.path());
1774        options.css = CssStrategy::Style;
1775        let result = build(options).unwrap();
1776
1777        assert_eq!(
1778            result
1779                .protocol
1780                .style_closure("index.html")
1781                .expect("entry closure"),
1782            ["light-card", "nested-card", "after-card"]
1783        );
1784        assert_eq!(
1785            result
1786                .protocol
1787                .style_closure("shadow-card")
1788                .expect("Shadow closure"),
1789            ["shadow-card", "nested-card"]
1790        );
1791    }
1792
1793    /// A fixture that exercises both bundling outcomes at once: two components
1794    /// used only by the entry merge, and one reached from a second CSS tree is
1795    /// split out so neither tree over-delivers.
1796    fn bundling_app() -> tempfile::TempDir {
1797        create_app_dir(&[
1798            (
1799                "index.html",
1800                "<a-card></a-card><b-card></b-card><shared-card></shared-card>\
1801                 <shadow-card></shadow-card>",
1802            ),
1803            ("a-card.html", "<span>a</span>"),
1804            ("a-card.css", ".a{color:red}"),
1805            ("b-card.html", "<span>b</span>"),
1806            ("b-card.css", ".b{color:green}"),
1807            ("shared-card.html", "<span>shared</span>"),
1808            ("shared-card.css", ".shared{color:blue}"),
1809            (
1810                "shadow-card.html",
1811                r#"<template shadowrootmode="open"><shared-card></shared-card></template>"#,
1812            ),
1813            ("shadow-card.css", ".shadow{color:black}"),
1814        ])
1815    }
1816
1817    /// Concatenate a closure's CSS the way the browser cascade sees it, once
1818    /// per component and once per chunk.
1819    fn closure_css(protocol: &WebUIProtocol, root: &str) -> (String, String) {
1820        let closure = protocol.style_closures.get(root).expect("closure");
1821        let unbundled = closure
1822            .component_tags
1823            .iter()
1824            .filter_map(|tag| protocol.components.get(tag))
1825            .map(|component| component.css.as_str())
1826            .collect::<Vec<_>>()
1827            .join("\n");
1828        let bundled = closure
1829            .style_chunks
1830            .iter()
1831            .filter_map(|index| protocol.style_chunk_resource(*index))
1832            .map(|(_, css)| css)
1833            .collect::<Vec<_>>()
1834            .join("\n");
1835        (unbundled, bundled)
1836    }
1837
1838    #[test]
1839    fn bundling_preserves_the_cascade_of_every_closure() {
1840        let app = bundling_app();
1841        let mut options = light_options(app.path());
1842        options.css = CssStrategy::Style;
1843        options.css_bundle = true;
1844        let result = build(options).unwrap();
1845
1846        let roots: Vec<String> = result.protocol.style_closures.keys().cloned().collect();
1847        assert!(roots.len() >= 2, "fixture must produce several CSS trees");
1848        let mut chunked = 0usize;
1849        for root in roots {
1850            let (unbundled, bundled) = closure_css(&result.protocol, &root);
1851            // A closure the runtime never installs as a unit keeps no chunks and
1852            // falls back to per-component delivery, which is the unbundled path.
1853            if bundled.is_empty() && !unbundled.is_empty() {
1854                continue;
1855            }
1856            chunked += 1;
1857            assert_eq!(
1858                bundled, unbundled,
1859                "chunked cascade diverged from the per-component cascade for `{root}`"
1860            );
1861        }
1862        assert!(chunked >= 2, "fixture must chunk several CSS trees");
1863    }
1864
1865    #[test]
1866    fn bundling_merges_entry_local_styles_and_splits_shared_ones() {
1867        let app = bundling_app();
1868        let mut options = light_options(app.path());
1869        options.css = CssStrategy::Style;
1870        options.css_bundle = true;
1871        let result = build(options).unwrap();
1872
1873        let members: Vec<&[String]> = result
1874            .protocol
1875            .style_chunks
1876            .iter()
1877            .map(|chunk| chunk.component_tags.as_slice())
1878            .collect();
1879        assert!(
1880            members.contains(&["a-card".to_string(), "b-card".to_string()].as_slice()),
1881            "entry-local components should merge: {members:?}"
1882        );
1883        assert!(
1884            members.contains(&["shared-card".to_string()].as_slice()),
1885            "a component reached from two CSS trees must stay alone: {members:?}"
1886        );
1887
1888        // The shared chunk is the same index in both trees, so it is fetched and
1889        // cached once.
1890        let entry = result.protocol.style_closure_chunks("index.html").unwrap();
1891        let shadow = result.protocol.style_closure_chunks("shadow-card").unwrap();
1892        let shared = entry
1893            .iter()
1894            .find(|index| shadow.contains(index))
1895            .expect("both trees should share a chunk");
1896        assert_eq!(
1897            result.protocol.style_chunk_members(*shared).unwrap(),
1898            ["shared-card"]
1899        );
1900    }
1901
1902    #[test]
1903    fn bundling_is_off_by_default_and_leaves_the_protocol_unchanged() {
1904        let app = bundling_app();
1905        let mut options = default_options(app.path());
1906        options.css = CssStrategy::Style;
1907        let result = build(options).unwrap();
1908
1909        assert!(result.protocol.style_chunks.is_empty());
1910        for closure in result.protocol.style_closures.values() {
1911            assert!(closure.style_chunks.is_empty());
1912        }
1913    }
1914
1915    #[test]
1916    fn bundling_link_emits_chunks_and_component_fallback_files() {
1917        let app = bundling_app();
1918        let mut options = light_options(app.path());
1919        options.css_bundle = true;
1920        let result = build(options).unwrap();
1921
1922        let names: Vec<&str> = result
1923            .css_files
1924            .iter()
1925            .map(|(name, _)| name.as_str())
1926            .collect();
1927        for (tag, component) in &result.protocol.components {
1928            if component.css_href.is_empty() {
1929                continue;
1930            }
1931            assert!(
1932                names.contains(&component.css_href.as_str()),
1933                "`{tag}` needs its independently loaded fallback file: {names:?}"
1934            );
1935        }
1936        for chunk in &result.protocol.style_chunks {
1937            assert!(!chunk.css_href.is_empty(), "Link chunks need an href");
1938            assert!(
1939                names.contains(&chunk.css_href.as_str()),
1940                "every rendered chunk needs an emitted file: {names:?}"
1941            );
1942            assert!(
1943                chunk.css.is_empty(),
1944                "Link chunks ship their CSS as a file, not inline"
1945            );
1946        }
1947    }
1948
1949    #[test]
1950    fn bundling_keeps_component_files_when_component_assets_need_them() {
1951        let app = create_app_dir(&[
1952            (
1953                "index.html",
1954                "<a-card></a-card><b-card></b-card><shared-card></shared-card>",
1955            ),
1956            ("a-card.html", "<span>a</span>"),
1957            ("a-card.css", ".a{color:red}"),
1958            ("b-card.html", "<span>b</span>"),
1959            ("b-card.css", ".b{color:green}"),
1960            ("shared-card.html", "<span>shared</span>"),
1961            ("shared-card.css", ".shared{color:blue}"),
1962            ("lazy-panel.html", "<a-card></a-card><p>lazy</p>"),
1963            ("lazy-panel.css", ".lazy{color:gray}"),
1964            ("lazy-panel.ts", "export {};"),
1965        ]);
1966        let mut options = light_options(app.path());
1967        options.plugin = Some(Plugin::WebUI);
1968        options.css_bundle = true;
1969        options.component_asset_roots = vec!["lazy-panel".to_string()];
1970        let result = build(options).unwrap();
1971
1972        let names: Vec<&str> = result
1973            .css_files
1974            .iter()
1975            .map(|(name, _)| name.as_str())
1976            .collect();
1977        assert!(
1978            names.iter().any(|name| name.starts_with("lazy-panel")),
1979            "static assets fetch a component's own stylesheet: {names:?}"
1980        );
1981        assert!(
1982            names.iter().any(|name| name.starts_with("_chunk-")),
1983            "bundled chunks are still emitted: {names:?}"
1984        );
1985        assert!(
1986            result.component_asset_files[0]
1987                .content
1988                .contains(r#""a-card":{"kind":"link","href":"a-card.css"}"#),
1989            "a deferred CSS tree needs the exact external component resource even when the entry Document uses a bundle: {}",
1990            result.component_asset_files[0].content
1991        );
1992    }
1993
1994    /// Render a built protocol and return the emitted HTML.
1995    fn render_html(protocol: &WebUIProtocol, route: &str) -> String {
1996        let protocol = Protocol::new(protocol.clone());
1997        let mut writer = StringWriter { buf: String::new() };
1998        WebUIHandler::new()
1999            .render(
2000                &protocol,
2001                &serde_json::json!({}),
2002                &RenderOptions::new("index.html", route),
2003                &mut writer,
2004            )
2005            .unwrap();
2006        writer.buf
2007    }
2008
2009    /// Every stylesheet URL the rendered page tells the browser to fetch.
2010    fn linked_stylesheets(html: &str) -> Vec<&str> {
2011        let mut hrefs = Vec::new();
2012        let mut rest = html;
2013        while let Some(start) = rest.find("href=\"") {
2014            rest = &rest[start + "href=\"".len()..];
2015            let Some(end) = rest.find('"') else { break };
2016            let href = &rest[..end];
2017            if href.ends_with(".css") {
2018                hrefs.push(href);
2019            }
2020            rest = &rest[end..];
2021        }
2022        hrefs
2023    }
2024
2025    /// The `data-webui-resource` values in emitted order.
2026    fn emitted_resources(html: &str) -> Vec<&str> {
2027        let mut resources = Vec::new();
2028        let mut rest = html;
2029        while let Some(start) = rest.find("data-webui-resource=\"") {
2030            rest = &rest[start + "data-webui-resource=\"".len()..];
2031            match rest.find('"') {
2032                Some(end) => {
2033                    resources.push(&rest[..end]);
2034                    rest = &rest[end..];
2035                }
2036                None => break,
2037            }
2038        }
2039        resources
2040    }
2041
2042    #[test]
2043    fn bundled_rendering_emits_one_resource_per_chunk() {
2044        for strategy in [CssStrategy::Link, CssStrategy::Style] {
2045            let app = bundling_app();
2046            let mut options = light_options(app.path());
2047            options.css = strategy;
2048            options.css_bundle = true;
2049            let result = build(options).unwrap();
2050
2051            let html = render_html(&result.protocol, "/");
2052            let resources = emitted_resources(&html);
2053            assert!(
2054                resources.contains(&"_chunk-a-card-2"),
2055                "{strategy:?} should deliver the merged chunk once: {resources:?}"
2056            );
2057            assert!(
2058                !resources.contains(&"a-card") && !resources.contains(&"b-card"),
2059                "{strategy:?} must not also deliver merged members: {resources:?}"
2060            );
2061        }
2062    }
2063
2064    #[test]
2065    fn bundling_never_merges_an_authored_shadow_stylesheet() {
2066        // `shadow-card` sits between two Light components that share its exact
2067        // consumer set, so ordering alone would let all three merge.
2068        let app = create_app_dir(&[
2069            (
2070                "index.html",
2071                "<a-card></a-card><shadow-card></shadow-card><b-card></b-card>",
2072            ),
2073            ("a-card.html", "<span>a</span>"),
2074            ("a-card.css", ".a{color:red}"),
2075            ("b-card.html", "<span>b</span>"),
2076            ("b-card.css", ".b{color:green}"),
2077            (
2078                "shadow-card.html",
2079                r#"<template shadowrootmode="open"><span>s</span></template>"#,
2080            ),
2081            ("shadow-card.css", ".shadow{color:black}"),
2082        ]);
2083        let mut options = light_options(app.path());
2084        options.css = CssStrategy::Style;
2085        options.css_bundle = true;
2086        let result = build(options).unwrap();
2087
2088        let chunk = result
2089            .protocol
2090            .style_chunks
2091            .iter()
2092            .find(|chunk| chunk.component_tags.iter().any(|tag| tag == "shadow-card"))
2093            .expect("shadow-card is styled, so it belongs to a chunk");
2094        // A single-member chunk is named after its member and carries its exact
2095        // bytes, which is what keeps the parse-time `css_href` and
2096        // `shadowrootadoptedstylesheets` references valid under bundling.
2097        assert_eq!(
2098            chunk.component_tags,
2099            vec!["shadow-card".to_string()],
2100            "an authored-Shadow stylesheet must stay in a chunk of its own"
2101        );
2102        assert_eq!(chunk.name, "shadow-card");
2103        assert_eq!(
2104            chunk.css, result.protocol.components["shadow-card"].css,
2105            "its chunk must carry the component's own bytes unchanged"
2106        );
2107    }
2108
2109    #[test]
2110    fn bundling_never_links_a_stylesheet_the_build_did_not_write() {
2111        // The rendered page is the contract: every stylesheet URL the browser is
2112        // told to fetch — document links and declarative Shadow roots alike —
2113        // must name a file the bundled build actually emitted.
2114        let app = bundling_app();
2115        let mut options = light_options(app.path());
2116        options.css = CssStrategy::Link;
2117        options.css_bundle = true;
2118        let result = build(options).unwrap();
2119
2120        let emitted: HashSet<&str> = result
2121            .css_files
2122            .iter()
2123            .map(|(name, _)| name.as_str())
2124            .collect();
2125        let html = render_html(&result.protocol, "/");
2126        let linked = linked_stylesheets(&html);
2127        assert!(
2128            !linked.is_empty(),
2129            "the page should link at least one stylesheet: {html}"
2130        );
2131        for href in &linked {
2132            let name = href.rsplit('/').next().unwrap_or(href);
2133            assert!(
2134                emitted.contains(name),
2135                "page links `{href}`, which the bundled build did not write: {emitted:?}"
2136            );
2137        }
2138    }
2139
2140    #[test]
2141    fn bundling_is_rejected_for_the_module_strategy() {
2142        let app = bundling_app();
2143        let mut options = light_options(app.path());
2144        options.css = CssStrategy::Module;
2145        options.css_bundle = true;
2146
2147        let Err(WebUIError::InvalidBuildOptions(message)) = build(options) else {
2148            panic!("Module builds resolve import-map specifiers per component");
2149        };
2150        assert!(
2151            message.contains("--css link"),
2152            "the error should name a strategy that works: {message}"
2153        );
2154    }
2155
2156    #[test]
2157    fn bundled_rendering_delivers_the_same_css_in_the_same_order() {
2158        // Style mode inlines the CSS, so the rendered document carries the whole
2159        // cascade and the two builds can be compared rule for rule.
2160        let extract = |html: &str| -> Vec<String> {
2161            let mut blocks = Vec::new();
2162            let mut rest = html;
2163            while let Some(start) = rest.find("data-webui-strategy=\"style\">") {
2164                rest = &rest[start + "data-webui-strategy=\"style\">".len()..];
2165                match rest.find("</style>") {
2166                    Some(end) => {
2167                        blocks.push(rest[..end].to_string());
2168                        rest = &rest[end..];
2169                    }
2170                    None => break,
2171                }
2172            }
2173            blocks
2174        };
2175
2176        let mut plan = Vec::new();
2177        for bundle in [false, true] {
2178            let app = bundling_app();
2179            let mut options = light_options(app.path());
2180            options.css = CssStrategy::Style;
2181            options.css_bundle = bundle;
2182            let result = build(options).unwrap();
2183            let html = render_html(&result.protocol, "/");
2184            plan.push(extract(&html).join("\n"));
2185        }
2186
2187        assert_eq!(
2188            plan[1], plan[0],
2189            "bundling must not change the delivered cascade"
2190        );
2191        assert!(!plan[0].is_empty(), "fixture must deliver CSS");
2192    }
2193
2194    #[test]
2195    fn bundled_rendering_never_repeats_a_chunk_across_css_trees() {
2196        let app = bundling_app();
2197        let mut options = light_options(app.path());
2198        options.css = CssStrategy::Style;
2199        options.css_bundle = true;
2200        let result = build(options).unwrap();
2201
2202        let html = render_html(&result.protocol, "/");
2203        let resources = emitted_resources(&html);
2204        // `shared-card` is reached by the document tree and by `shadow-card`'s
2205        // ShadowRoot. Each tree scopes styles separately, so it is delivered once
2206        // per tree and never twice within one.
2207        let mut counts: HashMap<&str, usize> = HashMap::new();
2208        for resource in &resources {
2209            *counts.entry(*resource).or_insert(0) += 1;
2210        }
2211        let shadow_start = html
2212            .find(r#"<template shadowrootmode="open""#)
2213            .expect("declarative Shadow root");
2214        let document_resources = emitted_resources(&html[..shadow_start]);
2215        let mut document_counts: HashMap<&str, usize> = HashMap::new();
2216        for resource in &document_resources {
2217            *document_counts.entry(*resource).or_insert(0) += 1;
2218        }
2219        for (resource, count) in &document_counts {
2220            assert_eq!(
2221                *count, 1,
2222                "`{resource}` was delivered {count} times in the document tree: {resources:?}"
2223            );
2224        }
2225    }
2226
2227    #[test]
2228    fn authored_shadow_css_bytes_are_not_lowered_or_trimmed() {
2229        let authored =
2230            " \n:host(.ready){color:red}:host-context(body){color:blue}::slotted(*){color:green}\n ";
2231
2232        for strategy in [CssStrategy::Link, CssStrategy::Style, CssStrategy::Module] {
2233            let app = create_app_dir(&[
2234                ("index.html", "<my-card></my-card>"),
2235                (
2236                    "my-card.html",
2237                    r#"<template shadowrootmode="open"><p>content</p></template>"#,
2238                ),
2239                ("my-card.css", authored),
2240            ]);
2241            let mut options = default_options(app.path());
2242            options.css = strategy;
2243            let result = build(options).unwrap();
2244
2245            match strategy {
2246                CssStrategy::Link => assert_eq!(result.css_files[0].1, authored),
2247                CssStrategy::Style | CssStrategy::Module => {
2248                    assert_eq!(result.protocol.components["my-card"].css, authored);
2249                    assert_eq!(
2250                        result.protocol.component_style_resource("my-card"),
2251                        Some(authored)
2252                    );
2253                }
2254            }
2255            assert_eq!(
2256                result
2257                    .protocol
2258                    .style_closure("my-card")
2259                    .expect("component closure"),
2260                ["my-card"],
2261                "the authored Shadow root owns its exact CSS resource"
2262            );
2263
2264            let protocol = Protocol::new(result.protocol.clone());
2265            let mut writer = StringWriter { buf: String::new() };
2266            WebUIHandler::new()
2267                .render(
2268                    &protocol,
2269                    &serde_json::json!({}),
2270                    &RenderOptions::new("index.html", "/"),
2271                    &mut writer,
2272                )
2273                .unwrap();
2274            match strategy {
2275                CssStrategy::Link => {
2276                    let href = &result.protocol.components["my-card"].css_href;
2277                    assert!(writer.buf.contains(&format!(
2278                        r#"<template shadowrootmode="open"><link rel="stylesheet" href="{href}" data-webui-resource="my-card" data-webui-strategy="link">"#
2279                    )));
2280                }
2281                CssStrategy::Style | CssStrategy::Module => {
2282                    let kind = if strategy == CssStrategy::Module {
2283                        "module"
2284                    } else {
2285                        "style"
2286                    };
2287                    assert!(writer.buf.contains(&format!(
2288                        r#"<style data-webui-resource="my-card" data-webui-strategy="{kind}">{authored}</style>"#
2289                    )));
2290                    let template_start = writer
2291                        .buf
2292                        .find(r#"<template shadowrootmode="open""#)
2293                        .expect("declarative Shadow root");
2294                    let style_start = writer
2295                        .buf
2296                        .find(&format!(
2297                            r#"<style data-webui-resource="my-card" data-webui-strategy="{kind}">"#
2298                        ))
2299                        .expect("tree-local style");
2300                    let content_start = writer.buf.find("<p>content</p>").expect("component body");
2301                    assert!(template_start < style_start && style_start < content_start);
2302                }
2303            }
2304        }
2305    }
2306
2307    #[test]
2308    fn test_build_strips_non_legal_css_comments_from_output_file() {
2309        let app = create_app_dir(&[
2310            ("index.html", "<my-card>Hello</my-card>"),
2311            (
2312                "my-card.html",
2313                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2314            ),
2315            (
2316                "my-card.css",
2317                "/* remove var(--ignored) */ .card { color: var(--textColor); }",
2318            ),
2319        ]);
2320        let result = build(default_options(app.path())).unwrap();
2321
2322        assert_eq!(result.css_files[0].1, " .card { color: var(--textColor); }");
2323        assert_eq!(result.protocol.tokens, vec!["textColor"]);
2324    }
2325
2326    #[test]
2327    fn test_build_preserves_legal_css_comments_by_default() {
2328        let app = create_app_dir(&[
2329            ("index.html", "<my-card>Hello</my-card>"),
2330            (
2331                "my-card.html",
2332                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2333            ),
2334            (
2335                "my-card.css",
2336                "/*! @license MIT */ .card { color: red; } /* remove */",
2337            ),
2338        ]);
2339        let result = build(default_options(app.path())).unwrap();
2340
2341        assert_eq!(
2342            result.css_files[0].1,
2343            "/*! @license MIT */ .card { color: red; } "
2344        );
2345    }
2346
2347    #[test]
2348    fn test_build_legal_comments_none_strips_legal_css_comments() {
2349        let app = create_app_dir(&[
2350            ("index.html", "<my-card>Hello</my-card>"),
2351            (
2352                "my-card.html",
2353                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2354            ),
2355            ("my-card.css", "/*! @license MIT */ .card { color: red; }"),
2356        ]);
2357        let mut options = default_options(app.path());
2358        options.legal_comments = LegalComments::None;
2359        let result = build(options).unwrap();
2360
2361        assert_eq!(result.css_files[0].1, " .card { color: red; }");
2362    }
2363
2364    #[test]
2365    fn test_build_with_css_hashed_filename_template() {
2366        let app = create_app_dir(&[
2367            ("index.html", "<my-card>Hello</my-card>"),
2368            (
2369                "my-card.html",
2370                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2371            ),
2372            ("my-card.css", ".card { color: red; }"),
2373        ]);
2374        let mut options = default_options(app.path());
2375        options.css_file_name_template = "[name]-[hash].[ext]".to_string();
2376        let result = build(options).unwrap();
2377
2378        assert_eq!(result.css_files.len(), 1);
2379        let generated = &result.css_files[0].0;
2380        assert!(generated.starts_with("my-card-"));
2381        assert!(generated.ends_with(".css"));
2382        assert_eq!(generated.len(), "my-card-".len() + 8 + ".css".len());
2383        assert_eq!(
2384            result
2385                .protocol
2386                .components
2387                .get("my-card")
2388                .map(|c| c.css_href.as_str()),
2389            Some(generated.as_str())
2390        );
2391    }
2392
2393    #[test]
2394    fn test_css_public_base_prefixes_css_href_only() {
2395        let app = create_app_dir(&[
2396            ("index.html", "<my-card>Hello</my-card>"),
2397            (
2398                "my-card.html",
2399                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2400            ),
2401            ("my-card.css", ".card { color: red; }"),
2402        ]);
2403        let mut options = default_options(app.path());
2404        options.css_file_name_template = "[name]-[hash].[ext]".to_string();
2405        options.css_public_base = Some("https://cdn.example.com/assets".to_string());
2406        let result = build(options).unwrap();
2407
2408        let filename = &result.css_files[0].0;
2409        assert!(filename.starts_with("my-card-"));
2410        let expected_href = format!("https://cdn.example.com/assets/{filename}");
2411        assert_eq!(
2412            result
2413                .protocol
2414                .components
2415                .get("my-card")
2416                .map(|c| c.css_href.as_str()),
2417            Some(expected_href.as_str())
2418        );
2419    }
2420
2421    #[test]
2422    fn test_css_public_base_emits_protocol_handler_and_payload_resources() {
2423        let app = create_app_dir(&[
2424            (
2425                "index.html",
2426                "<html><head></head><body><my-card>Hello</my-card></body></html>",
2427            ),
2428            (
2429                "my-card.html",
2430                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2431            ),
2432            ("my-card.css", ".card { color: red; }"),
2433        ]);
2434        let mut options = default_options(app.path());
2435        options.css_file_name_template = "[name]-[hash].[ext]".to_string();
2436        options.css_public_base = Some("https://cdn.example.com/assets".to_string());
2437        let result = build(options).unwrap();
2438
2439        let filename = &result.css_files[0].0;
2440        let expected_href = format!("https://cdn.example.com/assets/{filename}");
2441        assert_eq!(
2442            result.protocol.component_style_resource("my-card"),
2443            Some(expected_href.as_str())
2444        );
2445        assert_eq!(
2446            result
2447                .protocol
2448                .style_closure("my-card")
2449                .expect("component closure"),
2450            ["my-card"]
2451        );
2452
2453        let handler = WebUIHandler::new();
2454        let protocol = Protocol::new(result.protocol.clone());
2455        let mut writer = StringWriter { buf: String::new() };
2456        handler
2457            .render(
2458                &protocol,
2459                &serde_json::json!({}),
2460                &RenderOptions::new("index.html", "/"),
2461                &mut writer,
2462            )
2463            .unwrap();
2464        assert!(
2465            writer.buf.contains(&format!(
2466                r#"<link rel="stylesheet" href="{}" data-webui-resource="my-card" data-webui-strategy="link">"#,
2467                expected_href.replace('/', "&#x2F;")
2468            )),
2469            "handler Shadow-root resource should use CDN href: {}",
2470            writer.buf
2471        );
2472        let payload = protocol
2473            .render_component_templates(&["my-card"], "")
2474            .expect("component resource payload");
2475        assert_eq!(
2476            payload["componentStyles"]["resources"]["my-card"]["href"],
2477            expected_href
2478        );
2479    }
2480
2481    #[test]
2482    fn test_css_public_base_keeps_webui_template_style_free() {
2483        let app = create_app_dir(&[
2484            ("index.html", "<my-card>Hello</my-card>"),
2485            (
2486                "my-card.html",
2487                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2488            ),
2489            ("my-card.css", ".card { color: red; }"),
2490            ("my-card.ts", "export {};"),
2491        ]);
2492        let mut options = default_options(app.path());
2493        options.plugin = Some(Plugin::WebUI);
2494        options.css_file_name_template = "[name]-[hash].[ext]".to_string();
2495        options.css_public_base = Some("https://cdn.example.com/assets".to_string());
2496        let result = build(options).unwrap();
2497
2498        let filename = &result.css_files[0].0;
2499        let expected_href = format!("https://cdn.example.com/assets/{filename}");
2500        let template = &result.protocol.components["my-card"].template_json;
2501        assert_eq!(
2502            result.protocol.component_style_resource("my-card"),
2503            Some(expected_href.as_str())
2504        );
2505        assert!(
2506            !template.contains(&expected_href),
2507            "WebUI templates must not duplicate handler-owned CSS links: {template}"
2508        );
2509    }
2510
2511    #[test]
2512    fn test_invalid_css_template_is_rejected() {
2513        let app = create_app_dir(&[
2514            ("index.html", "<my-card>Hello</my-card>"),
2515            (
2516                "my-card.html",
2517                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2518            ),
2519            ("my-card.css", ".card { color: red; }"),
2520        ]);
2521        let mut options = default_options(app.path());
2522        options.css_file_name_template = "[name]-[bogus].[ext]".to_string();
2523        let result = build(options);
2524
2525        assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
2526    }
2527
2528    #[test]
2529    fn test_css_filename_collision_is_rejected() {
2530        let app = create_app_dir(&[
2531            ("index.html", "<card-a>A</card-a><card-b>B</card-b>"),
2532            (
2533                "card-a.html",
2534                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2535            ),
2536            ("card-a.css", ".x { color: red; }"),
2537            (
2538                "card-b.html",
2539                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2540            ),
2541            ("card-b.css", ".x { color: red; }"),
2542        ]);
2543        let mut options = default_options(app.path());
2544        options.css_file_name_template = "[hash].[ext]".to_string();
2545        let result = build(options);
2546
2547        assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
2548    }
2549
2550    #[test]
2551    fn test_build_returns_static_component_asset_files() {
2552        let app = create_app_dir(&[
2553            ("index.html", "<app-shell></app-shell>"),
2554            ("app-shell.html", "<div></div>"),
2555            (
2556                "lazy-panel.html",
2557                r#"<if condition="ready"><p>{{title}}</p></if>"#,
2558            ),
2559            ("lazy-panel.ts", "export {};"),
2560        ]);
2561        let mut options = default_options(app.path());
2562        options.plugin = Some(Plugin::WebUI);
2563        options.component_asset_roots = vec!["lazy-panel".to_string()];
2564
2565        let result = build(options).unwrap();
2566
2567        assert_eq!(result.component_asset_files.len(), 1);
2568        assert_eq!(result.component_asset_files[0].name, "lazy-panel.webui.js");
2569        assert!(result.component_asset_files[0]
2570            .content
2571            .contains(r#""type":"webui-component-asset""#));
2572        assert!(result.component_asset_files[0]
2573            .content
2574            .contains(r#""version":3"#));
2575        assert!(result.component_asset_files[0]
2576            .content
2577            .contains(r#""kind":"root""#));
2578        assert!(result.component_asset_files[0]
2579            .content
2580            .contains(r#""templateFunctions":{"lazy-panel":"#));
2581        assert!(!result.protocol.fragments.contains_key("lazy-panel"));
2582        assert!(
2583            !result
2584                .protocol
2585                .fragments
2586                .keys()
2587                .any(|key| key.starts_with("__webui_asset_root_")),
2588            "synthetic asset root fragments must not be serialized"
2589        );
2590    }
2591
2592    #[test]
2593    fn component_assets_emit_enhanced_style_catalog_for_all_strategies() {
2594        for (strategy, kind) in [
2595            (CssStrategy::Link, "link"),
2596            (CssStrategy::Style, "style"),
2597            (CssStrategy::Module, "module"),
2598        ] {
2599            let app = create_app_dir(&[
2600                ("index.html", "<app-shell></app-shell>"),
2601                ("app-shell.html", "<p>Entry</p>"),
2602                ("lazy-panel.html", "<p>Lazy</p>"),
2603                ("lazy-panel.css", ".lazy{color:red}"),
2604            ]);
2605            let mut options = default_options(app.path());
2606            options.plugin = Some(Plugin::WebUI);
2607            options.css = strategy;
2608            options.component_asset_roots = vec!["lazy-panel".to_string()];
2609
2610            let result = build(options).unwrap();
2611            let asset = &result.component_asset_files[0].content;
2612            assert!(asset.contains(r#""version":3"#));
2613            assert!(asset.contains(&format!(
2614                r#""componentStyles":{{"version":1,"strategy":"{kind}""#
2615            )));
2616            assert!(asset.contains(&format!(r#""lazy-panel":{{"kind":"{kind}""#)));
2617            assert!(asset.contains(r#""closures":{"lazy-panel":["lazy-panel"]}"#));
2618            if strategy == CssStrategy::Module {
2619                assert!(asset
2620                    .contains(r#""lazy-panel":{"kind":"module","specifier":"lazy-panel","css":"#,));
2621            }
2622            if strategy == CssStrategy::Link {
2623                assert!(asset.contains(r#""href":new URL("lazy-panel.css",import.meta.url).href"#,));
2624            }
2625        }
2626    }
2627
2628    #[test]
2629    fn test_build_splits_shared_assets_and_keeps_them_out_of_protocol() {
2630        let app = create_app_dir(&[
2631            ("index.html", "<app-shell></app-shell>"),
2632            ("app-shell.html", "<entry-badge></entry-badge>"),
2633            ("entry-badge.html", "<span>Entry</span>"),
2634            ("entry-badge.css", ":host { display: inline-block; }"),
2635            (
2636                "lazy-panel.html",
2637                "<entry-badge></entry-badge><shared-detail></shared-detail><panel-only></panel-only>",
2638            ),
2639            (
2640                "secondary-panel.html",
2641                "<entry-badge></entry-badge><shared-detail></shared-detail><secondary-only></secondary-only>",
2642            ),
2643            ("shared-detail.html", "<p>Shared</p>"),
2644            ("shared-detail.css", ":host { display: block; }"),
2645            ("lazy-panel.css", ":host { color: green; }"),
2646            ("secondary-panel.css", ":host { color: blue; }"),
2647            ("panel-only.html", "<p>Panel</p>"),
2648            ("secondary-only.html", "<p>Secondary</p>"),
2649        ]);
2650        let mut options = default_options(app.path());
2651        options.plugin = Some(Plugin::WebUI);
2652        options.component_asset_roots =
2653            vec!["lazy-panel".to_string(), "secondary-panel".to_string()];
2654
2655        let result = build(options).unwrap();
2656
2657        for tag in [
2658            "lazy-panel",
2659            "secondary-panel",
2660            "shared-detail",
2661            "panel-only",
2662            "secondary-only",
2663        ] {
2664            assert!(
2665                !result.protocol.fragments.contains_key(tag),
2666                "asset-only fragment <{tag}> must not be serialized"
2667            );
2668            assert!(
2669                !result.protocol.components.contains_key(tag),
2670                "asset-only component <{tag}> must not be serialized"
2671            );
2672        }
2673        assert!(result.protocol.fragments.contains_key("app-shell"));
2674        assert!(result.protocol.components.contains_key("entry-badge"));
2675        assert_eq!(
2676            result.protocol.component_asset_style_preloads,
2677            vec![
2678                webui_protocol::ComponentAssetStylePreload {
2679                    root: "lazy-panel".to_string(),
2680                    style_hrefs: vec![
2681                        "lazy-panel.css".to_string(),
2682                        "shared-detail.css".to_string(),
2683                    ],
2684                },
2685                webui_protocol::ComponentAssetStylePreload {
2686                    root: "secondary-panel".to_string(),
2687                    style_hrefs: vec![
2688                        "secondary-panel.css".to_string(),
2689                        "shared-detail.css".to_string(),
2690                    ],
2691                },
2692            ]
2693        );
2694
2695        let names: Vec<&str> = result
2696            .component_asset_files
2697            .iter()
2698            .map(|file| file.name.as_str())
2699            .collect();
2700        assert_eq!(
2701            names,
2702            vec![
2703                "lazy-panel.webui.js",
2704                "secondary-panel.webui.js",
2705                "chunk-shared-detail.webui.js",
2706            ]
2707        );
2708        let lazy = &result.component_asset_files[0].content;
2709        assert!(lazy.contains(r#""version":3"#));
2710        assert!(lazy.contains(r#""kind":"root""#));
2711        assert!(lazy.contains(r#""externalComponents":["entry-badge"]"#));
2712        assert!(lazy.contains(r#""entry-badge":{"kind":"link","href":"entry-badge.css"}"#));
2713        assert!(!lazy.contains(
2714            r#""entry-badge":{"kind":"link","href":new URL("entry-badge.css",import.meta.url).href}"#
2715        ));
2716        assert!(lazy
2717            .contains(r#""href":new URL("./chunk-shared-detail.webui.js",import.meta.url).href"#));
2718        assert!(lazy.contains(r#"import("./chunk-shared-detail.webui.js")"#));
2719        assert!(!lazy.contains(r#""templates":{"entry-badge":"#));
2720        assert!(!lazy.contains(r#""templates":{"shared-detail":"#));
2721
2722        let chunk = &result.component_asset_files[2].content;
2723        assert!(chunk.contains(r#""kind":"chunk""#));
2724        assert!(chunk.contains(r#""components":["shared-detail"]"#));
2725        assert!(chunk.contains(r#""templates":{"shared-detail":"#));
2726    }
2727
2728    #[test]
2729    fn test_component_asset_graph_groups_each_consumer_set() {
2730        let app = create_app_dir(&[
2731            ("index.html", "<app-shell></app-shell>"),
2732            ("app-shell.html", "<p>Entry</p>"),
2733            (
2734                "root-a.html",
2735                "<shared-ab></shared-ab><shared-all></shared-all><only-a></only-a>",
2736            ),
2737            (
2738                "root-b.html",
2739                "<shared-ab></shared-ab><shared-all></shared-all><only-b></only-b>",
2740            ),
2741            ("root-c.html", "<shared-all></shared-all><only-c></only-c>"),
2742            ("shared-ab.html", "<p>AB</p>"),
2743            ("shared-all.html", "<p>All</p>"),
2744            ("only-a.html", "<p>A</p>"),
2745            ("only-b.html", "<p>B</p>"),
2746            ("only-c.html", "<p>C</p>"),
2747        ]);
2748        let mut options = default_options(app.path());
2749        options.plugin = Some(Plugin::WebUI);
2750        options.component_asset_roots = vec![
2751            "root-a".to_string(),
2752            "root-b".to_string(),
2753            "root-c".to_string(),
2754        ];
2755
2756        let result = build(options).unwrap();
2757        let names: Vec<&str> = result
2758            .component_asset_files
2759            .iter()
2760            .map(|file| file.name.as_str())
2761            .collect();
2762        assert_eq!(
2763            names,
2764            [
2765                "root-a.webui.js",
2766                "root-b.webui.js",
2767                "root-c.webui.js",
2768                "chunk-shared-ab.webui.js",
2769                "chunk-shared-all.webui.js",
2770            ]
2771        );
2772        assert!(result.component_asset_files[0]
2773            .content
2774            .contains(r#""components":["only-a","root-a"]"#));
2775        assert!(result.component_asset_files[3]
2776            .content
2777            .contains(r#""components":["shared-ab"]"#));
2778        assert!(result.component_asset_files[4]
2779            .content
2780            .contains(r#""components":["shared-all"]"#));
2781    }
2782
2783    #[test]
2784    fn test_component_asset_graph_is_root_order_independent_with_hashed_imports() {
2785        let app = create_app_dir(&[
2786            ("index.html", "<app-shell></app-shell>"),
2787            ("app-shell.html", "<p>Entry</p>"),
2788            ("root-a.html", "<shared-detail></shared-detail>"),
2789            ("root-b.html", "<shared-detail></shared-detail>"),
2790            ("shared-detail.html", "<p>Shared</p>"),
2791        ]);
2792        let build_files = |roots: Vec<String>| {
2793            let mut options = default_options(app.path());
2794            options.plugin = Some(Plugin::WebUI);
2795            options.component_asset_roots = roots;
2796            options.css_file_name_template = "[name]-[hash].[ext]".to_string();
2797            build(options).unwrap().component_asset_files
2798        };
2799
2800        let forward = build_files(vec!["root-a".to_string(), "root-b".to_string()]);
2801        let reverse = build_files(vec!["root-b".to_string(), "root-a".to_string()]);
2802
2803        assert_eq!(forward, reverse);
2804        let chunk_name = &forward[2].name;
2805        assert!(chunk_name.starts_with("chunk-shared-detail-"));
2806        assert!(forward[0].content.contains(chunk_name));
2807        assert!(forward[1].content.contains(chunk_name));
2808    }
2809
2810    #[test]
2811    fn test_component_asset_style_preloads_use_final_css_hrefs() {
2812        let app = create_app_dir(&[
2813            ("index.html", "<app-shell></app-shell>"),
2814            ("app-shell.html", "<p>Entry</p>"),
2815            ("lazy-panel.html", "<p>Lazy</p>"),
2816            ("lazy-panel.css", ":host { display: block; }"),
2817        ]);
2818        let mut options = default_options(app.path());
2819        options.plugin = Some(Plugin::WebUI);
2820        options.component_asset_roots = vec!["lazy-panel".to_string()];
2821        options.css_file_name_template = "[name]-[hash].[ext]".to_string();
2822        options.css_public_base = Some("https://cdn.example.com/webui".to_string());
2823
2824        let result = build(options).unwrap();
2825        let preload = &result.protocol.component_asset_style_preloads[0];
2826
2827        assert_eq!(preload.root, "lazy-panel");
2828        assert_eq!(preload.style_hrefs.len(), 1);
2829        assert!(preload.style_hrefs[0].starts_with("https://cdn.example.com/webui/lazy-panel-"));
2830        assert!(preload.style_hrefs[0].ends_with(".css"));
2831    }
2832
2833    #[test]
2834    fn test_light_component_asset_styles_are_retained_for_document_links() {
2835        let app = create_app_dir(&[
2836            ("index.html", "<app-shell></app-shell>"),
2837            ("app-shell.html", "<p>Entry</p>"),
2838            ("z-lazy-panel.html", "<a-lazy-child></a-lazy-child>"),
2839            ("z-lazy-panel.css", ".z-lazy-panel { display: block; }"),
2840            ("a-lazy-child.html", "<p>Child</p>"),
2841            ("a-lazy-child.css", ".a-lazy-child { color: green; }"),
2842        ]);
2843        let mut options = default_options(app.path());
2844        options.plugin = Some(Plugin::WebUI);
2845        options.dom = DomStrategy::Light;
2846        options.component_asset_roots = vec!["z-lazy-panel".to_string()];
2847
2848        let result = build(options).unwrap();
2849
2850        assert_eq!(
2851            result.protocol.component_asset_style_preloads,
2852            vec![webui_protocol::ComponentAssetStylePreload {
2853                root: "z-lazy-panel".to_string(),
2854                style_hrefs: vec![
2855                    "z-lazy-panel.css".to_string(),
2856                    "a-lazy-child.css".to_string(),
2857                ],
2858            }]
2859        );
2860    }
2861
2862    #[test]
2863    fn test_metafile_describes_dynamic_import_graph() {
2864        let app = create_app_dir(&[
2865            ("index.html", "<app-shell></app-shell>"),
2866            ("app-shell.html", "<p>Entry</p>"),
2867            ("root-a.html", "<shared-detail></shared-detail>"),
2868            ("root-b.html", "<shared-detail></shared-detail>"),
2869            ("shared-detail.html", "<p>Shared</p>"),
2870        ]);
2871        let mut options = default_options(app.path());
2872        options.plugin = Some(Plugin::WebUI);
2873        options.component_asset_roots = vec!["root-a".to_string(), "root-b".to_string()];
2874        options.metafile = true;
2875
2876        let result = build(options).unwrap();
2877        let metafile = result.metafile.as_deref().unwrap();
2878        let value: serde_json::Value = serde_json::from_str(metafile).unwrap();
2879        assert!(value["inputs"]
2880            .get("webui:component/shared-detail")
2881            .is_some());
2882        assert_eq!(
2883            value["outputs"]["root-a.webui.js"]["entryPoint"],
2884            "webui:component/root-a"
2885        );
2886        assert_eq!(
2887            value["outputs"]["root-a.webui.js"]["imports"][0]["path"],
2888            "chunk-shared-detail.webui.js"
2889        );
2890        assert_eq!(
2891            value["outputs"]["root-a.webui.js"]["imports"][0]["kind"],
2892            "dynamic-import"
2893        );
2894        assert_eq!(
2895            value["inputs"]["webui:component/root-a"]["imports"][0]["kind"],
2896            "dynamic-import"
2897        );
2898        assert_eq!(
2899            value["outputs"]["chunk-shared-detail.webui.js"]["bytes"],
2900            result.component_asset_files[2].content.len()
2901        );
2902    }
2903
2904    #[test]
2905    fn test_component_assets_reject_routes_with_stable_diagnostic() {
2906        let app = create_app_dir(&[
2907            (
2908                "index.html",
2909                r#"<route path="/" component="app-shell" exact />"#,
2910            ),
2911            ("app-shell.html", "<p>Entry</p>"),
2912            ("lazy-panel.html", "<p>Lazy</p>"),
2913        ]);
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
2918        let error = build(options).unwrap_err();
2919        match error {
2920            WebUIError::ComponentAssets(diagnostic) => {
2921                assert_eq!(
2922                    diagnostic.error_code(),
2923                    Some(webui_parser::codes::COMPONENT_ASSETS_WITH_ROUTES)
2924                );
2925                assert!(diagnostic.help_text().is_some());
2926            }
2927            other => panic!("expected route diagnostic, received {other}"),
2928        }
2929    }
2930
2931    #[test]
2932    fn test_component_asset_filename_collision_with_css_is_rejected() {
2933        let app = create_app_dir(&[
2934            ("index.html", "<app-shell></app-shell>"),
2935            ("app-shell.html", "<div></div>"),
2936            ("lazy-panel.html", "<p>Lazy</p>"),
2937            ("lazy-panel.css", ".panel { color: red; }"),
2938        ]);
2939        let mut options = default_options(app.path());
2940        options.plugin = Some(Plugin::WebUI);
2941        options.component_asset_roots = vec!["lazy-panel".to_string()];
2942        options.css_file_name_template = "[name]".to_string();
2943
2944        let result = build(options);
2945
2946        assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
2947    }
2948
2949    #[test]
2950    fn test_build_to_disk_rejects_protocol_name_collision_before_writing() {
2951        let app = create_app_dir(&[
2952            ("index.html", "<app-shell></app-shell>"),
2953            ("app-shell.html", "<div></div>"),
2954            ("lazy-panel.html", "<p>Lazy</p>"),
2955        ]);
2956        let out = TempDir::new().unwrap();
2957        let mut options = default_options(app.path());
2958        options.plugin = Some(Plugin::WebUI);
2959        options.component_asset_roots = vec!["lazy-panel".to_string()];
2960        options.css_file_name_template = "protocol.bin".to_string();
2961
2962        let result = build_to_disk(options, out.path());
2963
2964        assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
2965        assert!(!out.path().join("protocol.bin").exists());
2966    }
2967
2968    #[test]
2969    fn test_css_href_set_for_light_dom_link_strategy() {
2970        let app = create_app_dir(&[
2971            ("index.html", "<has-css>A</has-css><no-css>B</no-css>"),
2972            ("has-css.html", "<p>styled</p>"),
2973            ("has-css.css", ".yes { color: green; }"),
2974            ("no-css.html", "<p>plain</p>"),
2975        ]);
2976        let options = default_options(app.path());
2977        let result = build(options).unwrap();
2978
2979        let href = result
2980            .protocol
2981            .components
2982            .get("has-css")
2983            .map(|c| c.css_href.as_str())
2984            .unwrap_or("");
2985        assert_eq!(
2986            href, "has-css.css",
2987            "Light×Link component with CSS should have css_href"
2988        );
2989
2990        let no_href = result
2991            .protocol
2992            .components
2993            .get("no-css")
2994            .map(|c| c.css_href.as_str())
2995            .unwrap_or("");
2996        assert!(
2997            no_href.is_empty(),
2998            "Component without CSS should have empty css_href"
2999        );
3000    }
3001
3002    #[test]
3003    fn test_plugin_free_build_persists_component_shadow_usage() {
3004        let app = create_app_dir(&[
3005            (
3006                "index.html",
3007                "<light-card></light-card><shadow-card></shadow-card>",
3008            ),
3009            ("light-card.html", "<p>light</p>"),
3010            (
3011                "shadow-card.html",
3012                "<template shadowrootmode=\"open\"><slot></slot></template>",
3013            ),
3014        ]);
3015        let mut options = default_options(app.path());
3016        options.dom = DomStrategy::Light;
3017        options.plugin = None;
3018        let result = build(options).unwrap();
3019
3020        assert!(!result.protocol.component_uses_shadow_dom("light-card"));
3021        assert!(result.protocol.component_uses_shadow_dom("shadow-card"));
3022        assert!(!result.protocol.components["light-card"].uses_shadow_dom);
3023        assert!(result.protocol.components["shadow-card"].uses_shadow_dom);
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("<light-card>"));
3034        assert!(entry_html.contains("<shadow-card>"));
3035        assert!(!entry_html.contains("<shadow-card data-wl"));
3036    }
3037
3038    #[test]
3039    fn test_authored_shadow_under_light_matches_ssr_protocol_and_webui_metadata() {
3040        let app = create_app_dir(&[
3041            ("index.html", "<shadow-card>projected</shadow-card>"),
3042            (
3043                "shadow-card.html",
3044                "<!-- lead --><template shadowrootmode=\"open\"><slot></slot></template><!-- tail -->",
3045            ),
3046        ]);
3047        let mut options = default_options(app.path());
3048        options.plugin = Some(Plugin::WebUI);
3049        let result = build(options).unwrap();
3050
3051        assert!(result.protocol.component_uses_shadow_dom("shadow-card"));
3052        assert!(result.protocol.components["shadow-card"]
3053            .template_json
3054            .contains("\"sd\":1"));
3055
3056        let component_html: String = result.protocol.fragments["shadow-card"]
3057            .fragments
3058            .iter()
3059            .filter_map(|fragment| match fragment.fragment.as_ref() {
3060                Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
3061                _ => None,
3062            })
3063            .collect();
3064        assert_eq!(
3065            component_html,
3066            "<template shadowrootmode=\"open\"><slot></slot></template>"
3067        );
3068        let entry_html: String = result.protocol.fragments["index.html"]
3069            .fragments
3070            .iter()
3071            .filter_map(|fragment| match fragment.fragment.as_ref() {
3072                Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
3073                _ => None,
3074            })
3075            .collect();
3076        assert!(!entry_html.contains("data-wl"));
3077    }
3078
3079    #[test]
3080    fn test_shadow_dom_link_strategy_sets_css_href() {
3081        // Shadow×Link: css_href is always set for Link-strategy components.
3082        // The handler uses protocol.css_strategy + component Shadow metadata
3083        // to decide preload (Shadow) vs stylesheet (Light) in <head>.
3084        let app = create_app_dir(&[
3085            ("index.html", "<my-card>A</my-card>"),
3086            (
3087                "my-card.html",
3088                r#"<template shadowrootmode="open"><p><slot></slot></p></template>"#,
3089            ),
3090            ("my-card.css", ".card { color: red; }"),
3091        ]);
3092        let result = build(default_options(app.path())).unwrap();
3093
3094        let comp = result.protocol.components.get("my-card").unwrap();
3095        assert_eq!(
3096            comp.css_href, "my-card.css",
3097            "Shadow×Link should set css_href"
3098        );
3099
3100        // Strategy fields on protocol
3101        assert_eq!(
3102            result.protocol.css_strategy(),
3103            webui_protocol::CssStrategy::Link,
3104        );
3105
3106        // CSS file should still be emitted for the server to serve
3107        assert_eq!(
3108            result.css_files.len(),
3109            1,
3110            "CSS file should still be produced"
3111        );
3112    }
3113
3114    #[test]
3115    fn test_css_href_empty_for_style_strategy() {
3116        let app = create_app_dir(&[
3117            ("index.html", "<my-card>A</my-card>"),
3118            (
3119                "my-card.html",
3120                r#"<template shadowrootmode="open"><p><slot></slot></p></template>"#,
3121            ),
3122            ("my-card.css", ".card { color: red; }"),
3123        ]);
3124        let mut options = default_options(app.path());
3125        options.css = CssStrategy::Style;
3126        let result = build(options).unwrap();
3127
3128        let href = result
3129            .protocol
3130            .components
3131            .get("my-card")
3132            .map(|c| c.css_href.as_str())
3133            .unwrap_or("");
3134        assert!(href.is_empty(), "Style-strategy should not set css_href");
3135    }
3136
3137    #[test]
3138    fn test_css_href_empty_for_module_strategy() {
3139        let app = create_app_dir(&[
3140            ("index.html", "<my-card>A</my-card>"),
3141            (
3142                "my-card.html",
3143                r#"<template shadowrootmode="open"><p><slot></slot></p></template>"#,
3144            ),
3145            ("my-card.css", ".card { color: red; }"),
3146        ]);
3147        let mut options = default_options(app.path());
3148        options.css = CssStrategy::Module;
3149        let result = build(options).unwrap();
3150
3151        let href = result
3152            .protocol
3153            .components
3154            .get("my-card")
3155            .map(|c| c.css_href.as_str())
3156            .unwrap_or("");
3157        assert!(href.is_empty(), "Module-strategy should not set css_href");
3158        assert!(
3159            !result.protocol.components["my-card"].css.is_empty(),
3160            "Module-strategy should still populate css content"
3161        );
3162    }
3163
3164    #[test]
3165    fn test_build_to_disk_writes_files() {
3166        let app = create_app_dir(&[
3167            ("index.html", "<my-card>Hello</my-card>"),
3168            (
3169                "my-card.html",
3170                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
3171            ),
3172            ("my-card.css", ".card { color: red; }"),
3173            ("my-card.ts", "export {};"),
3174        ]);
3175        let out = TempDir::new().unwrap();
3176
3177        let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
3178
3179        assert!(out.path().join("protocol.bin").exists());
3180        assert!(out.path().join("my-card.css").exists());
3181        assert_eq!(stats.css_file_count, 1);
3182        assert!(stats.fragment_count > 0);
3183    }
3184
3185    #[test]
3186    fn test_build_to_disk_creates_nested_output_dir() {
3187        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
3188        let out = TempDir::new().unwrap();
3189        let nested = out.path().join("nested").join("output");
3190
3191        let stats = build_to_disk(default_options(app.path()), &nested).unwrap();
3192        assert!(nested.join("protocol.bin").exists());
3193        assert!(stats.fragment_count > 0);
3194    }
3195
3196    #[test]
3197    fn test_build_missing_entry() {
3198        let app = create_app_dir(&[("other.html", "<h1>Not index</h1>")]);
3199        let result = build(default_options(app.path()));
3200
3201        assert!(result.is_err());
3202        let err = result.unwrap_err();
3203        assert!(matches!(err, WebUIError::Io { .. }));
3204    }
3205
3206    #[test]
3207    fn test_build_stats_populated() {
3208        let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
3209        let result = build(default_options(app.path())).unwrap();
3210
3211        assert!(result.stats.fragment_count > 0);
3212        assert!(result.stats.protocol_size_bytes > 0);
3213        assert_eq!(result.stats.css_file_count, 0);
3214    }
3215
3216    #[test]
3217    fn test_build_inline_css() {
3218        let app = create_app_dir(&[
3219            ("index.html", "<my-card>Hello</my-card>"),
3220            (
3221                "my-card.html",
3222                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
3223            ),
3224            ("my-card.css", ".card { color: red; }"),
3225        ]);
3226        let mut options = default_options(app.path());
3227        options.css = CssStrategy::Style;
3228
3229        let result = build(options).unwrap();
3230        // Inline mode embeds CSS in <style> tags — no external CSS files
3231        assert!(result.css_files.is_empty());
3232        assert_eq!(result.stats.css_file_count, 0);
3233        assert!(result.stats.fragment_count > 0);
3234    }
3235
3236    #[test]
3237    fn test_build_module_css() {
3238        let app = create_app_dir(&[
3239            ("index.html", "<my-card>Hello</my-card>"),
3240            (
3241                "my-card.html",
3242                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
3243            ),
3244            ("my-card.css", ".card { color: red; }"),
3245        ]);
3246        let mut options = default_options(app.path());
3247        options.css = CssStrategy::Module;
3248
3249        let result = build(options).unwrap();
3250        // Module mode emits <script type="importmap"> data URIs — no external CSS files
3251        assert!(result.css_files.is_empty());
3252        assert_eq!(result.stats.css_file_count, 0);
3253        assert!(result.stats.fragment_count > 0);
3254    }
3255
3256    #[test]
3257    fn test_build_custom_entry() {
3258        let app = create_app_dir(&[("page.html", "<h1>Custom</h1>")]);
3259        let mut options = default_options(app.path());
3260        options.entry = "page.html".to_string();
3261
3262        let result = build(options).unwrap();
3263        assert!(result.protocol.fragments.contains_key("page.html"));
3264        assert!(!result.protocol.fragments.contains_key("index.html"));
3265    }
3266
3267    #[test]
3268    fn test_build_protocol_roundtrip() {
3269        let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
3270        let result = build(default_options(app.path())).unwrap();
3271
3272        let restored = WebUIProtocol::from_protobuf(&result.protocol_bytes).unwrap();
3273        assert!(restored.fragments.contains_key("index.html"));
3274    }
3275
3276    #[test]
3277    fn protocol_is_available_from_facade() {
3278        let protocol = Protocol::new(WebUIProtocol::new(HashMap::new()));
3279        assert!(protocol.tokens().is_empty());
3280    }
3281
3282    #[test]
3283    fn test_inspect_bytes_valid() {
3284        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
3285        let result = build(default_options(app.path())).unwrap();
3286
3287        let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
3288        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
3289        assert!(parsed.get("fragments").is_some());
3290    }
3291
3292    #[test]
3293    fn test_inspect_bytes_invalid() {
3294        let result = inspect_bytes(b"not a protobuf");
3295        assert!(result.is_err());
3296        assert!(matches!(result.unwrap_err(), WebUIError::Protocol(_)));
3297    }
3298
3299    #[test]
3300    fn test_inspect_file() {
3301        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
3302        let out = TempDir::new().unwrap();
3303        build_to_disk(default_options(app.path()), out.path()).unwrap();
3304
3305        let json_str = inspect(&out.path().join("protocol.bin")).unwrap();
3306        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
3307        assert!(parsed["fragments"]["index.html"]["fragments"].is_array());
3308    }
3309
3310    #[test]
3311    fn test_inspect_missing_file() {
3312        let result = inspect(Path::new("/nonexistent/protocol.bin"));
3313        assert!(result.is_err());
3314        assert!(matches!(result.unwrap_err(), WebUIError::Io { .. }));
3315    }
3316
3317    #[test]
3318    fn test_build_with_components_local_path() {
3319        let app = create_app_dir(&[("index.html", "<ext-card>Hello</ext-card>")]);
3320        let ext_dir = TempDir::new().unwrap();
3321        fs::write(
3322            ext_dir.path().join("ext-card.html"),
3323            r#"<template shadowrootmode="open"><div class="card"><slot></slot></div></template>"#,
3324        )
3325        .unwrap();
3326        fs::write(
3327            ext_dir.path().join("ext-card.css"),
3328            ".card { border: 1px solid #ccc; }",
3329        )
3330        .unwrap();
3331
3332        let mut options = default_options(app.path());
3333        options.components = vec![ext_dir.path().to_string_lossy().to_string()];
3334
3335        let result = build(options).unwrap();
3336        assert!(result.protocol.fragments.contains_key("index.html"));
3337        assert_eq!(result.css_files.len(), 1);
3338        assert!(result.css_files[0].1.contains("border"));
3339    }
3340
3341    #[test]
3342    fn test_build_with_scriptless_component_path_emits_dormant_template() {
3343        let app = create_app_dir(&[("index.html", "<ext-card></ext-card>")]);
3344        let ext_dir = TempDir::new().unwrap();
3345        fs::write(ext_dir.path().join("ext-card.html"), "<p>{{title}}</p>").unwrap();
3346
3347        let mut options = default_options(app.path());
3348        options.plugin = Some(Plugin::WebUI);
3349        options.components = vec![ext_dir.path().to_string_lossy().to_string()];
3350
3351        let result = build(options).unwrap();
3352        assert!(result
3353            .component_templates
3354            .iter()
3355            .any(|template| template.tag_name == "ext-card"));
3356        let component = result
3357            .protocol
3358            .components
3359            .get("ext-card")
3360            .expect("external scriptless component metadata should be retained");
3361        assert!(component.hydration_keys.is_empty());
3362        assert_eq!(component.navigation_keys, ["title"]);
3363        assert!(component.template_json.contains(r#""th":1"#));
3364    }
3365
3366    #[test]
3367    fn test_build_with_scripted_component_path_emits_client_artifact() {
3368        let app = create_app_dir(&[("index.html", "<ext-card></ext-card>")]);
3369        let ext_dir = TempDir::new().unwrap();
3370        fs::write(ext_dir.path().join("ext-card.html"), "<p>{{title}}</p>").unwrap();
3371        fs::write(ext_dir.path().join("ext-card.ts"), "export {};").unwrap();
3372
3373        let mut options = default_options(app.path());
3374        options.plugin = Some(Plugin::WebUI);
3375        options.components = vec![ext_dir.path().to_string_lossy().to_string()];
3376
3377        let result = build(options).unwrap();
3378        let template = &result.protocol.components["ext-card"].template_json;
3379        assert!(!template.is_empty(), "template should be emitted");
3380    }
3381
3382    #[test]
3383    fn test_build_rejects_projection_manifest_with_non_webui_plugin() {
3384        let app = create_app_dir(&[
3385            ("index.html", "<demo-card></demo-card>"),
3386            ("demo-card.html", "<p>{{name}}</p>"),
3387            ("demo-card.ts", "export {};"),
3388        ]);
3389        let manifest_json = projection::test_support::build_valid_manifest_json(
3390            app.path(),
3391            &[("demo-card.ts", "export {};")],
3392            &[("projection-bundle.js", "output")],
3393            &[(
3394                "demo-card",
3395                "demo-card.ts",
3396                &["projection-bundle.js"],
3397                &["name"],
3398                &["name"],
3399            )],
3400        );
3401        let manifest = projection::test_support::write_manifest(
3402            app.path(),
3403            "webui-projection.json",
3404            &manifest_json,
3405        );
3406        let mut options = default_options(app.path());
3407        // No plugin selected: default/FAST builds are incompatible with
3408        // projection manifests (`PROJ-B002`).
3409        options.projection_manifests = vec![manifest.into()];
3410        let err = build(options).expect_err("non-WebUI plugin + manifest should be rejected");
3411        assert!(
3412            err.chain_message().contains("PROJ-B002"),
3413            "msg: {}",
3414            err.chain_message()
3415        );
3416    }
3417
3418    #[test]
3419    fn test_build_rejects_missing_scripted_component_coverage() {
3420        let app = create_app_dir(&[
3421            ("index.html", "<demo-card></demo-card>"),
3422            ("demo-card.html", "<p>{{name}}</p>"),
3423            ("demo-card.ts", "export {};"),
3424        ]);
3425        // Manifest is valid but never mentions `demo-card`, the only
3426        // compiled scripted component in this build.
3427        let manifest_json =
3428            projection::test_support::build_valid_manifest_json(app.path(), &[], &[], &[]);
3429        let manifest = projection::test_support::write_manifest(
3430            app.path(),
3431            "webui-projection.json",
3432            &manifest_json,
3433        );
3434        let mut options = default_options(app.path());
3435        options.plugin = Some(Plugin::WebUI);
3436        options.projection_manifests = vec![manifest.into()];
3437        let err = build(options).expect_err("missing coverage should fail the build");
3438        let message = err.chain_message();
3439        assert!(message.contains("PROJ-B001"), "msg: {message}");
3440        assert!(message.contains("demo-card"), "msg: {message}");
3441    }
3442
3443    #[test]
3444    fn test_build_allows_unused_discovered_scripted_component_without_coverage() {
3445        let app = create_app_dir(&[
3446            ("index.html", "<demo-card></demo-card>"),
3447            ("demo-card.html", "<p>{{name}}</p>"),
3448            ("demo-card.ts", "export {};"),
3449            // `unused-card` is a scripted component discovered on disk but
3450            // never referenced from `index.html`, so it is not compiled
3451            // into the protocol and does not require manifest coverage.
3452            ("unused-card.html", "<p>{{title}}</p>"),
3453            ("unused-card.ts", "export {};"),
3454        ]);
3455        let manifest_json = projection::test_support::build_valid_manifest_json(
3456            app.path(),
3457            &[("demo-card.ts", "export {};")],
3458            &[("projection-bundle.js", "output")],
3459            &[(
3460                "demo-card",
3461                "demo-card.ts",
3462                &["projection-bundle.js"],
3463                &["name"],
3464                &["name"],
3465            )],
3466        );
3467        let manifest = projection::test_support::write_manifest(
3468            app.path(),
3469            "webui-projection.json",
3470            &manifest_json,
3471        );
3472        let mut options = default_options(app.path());
3473        options.plugin = Some(Plugin::WebUI);
3474        options.projection_manifests = vec![manifest.into()];
3475        let result = build(options).unwrap();
3476
3477        assert!(!result.protocol.fragments.contains_key("unused-card"));
3478        let component = result.protocol.components.get("demo-card").unwrap();
3479        assert_eq!(component.hydration_keys, vec!["name"]);
3480    }
3481
3482    #[test]
3483    fn test_build_allows_asset_only_scripted_component_without_projection_coverage() {
3484        let app = create_app_dir(&[
3485            ("index.html", "<app-shell></app-shell>"),
3486            ("app-shell.html", "<p>Entry</p>"),
3487            ("lazy-panel.html", "<p>{{title}}</p>"),
3488            ("lazy-panel.ts", "export {};"),
3489        ]);
3490        let manifest_json =
3491            projection::test_support::build_valid_manifest_json(app.path(), &[], &[], &[]);
3492        let manifest = projection::test_support::write_manifest(
3493            app.path(),
3494            "webui-projection.json",
3495            &manifest_json,
3496        );
3497        let mut options = default_options(app.path());
3498        options.plugin = Some(Plugin::WebUI);
3499        options.component_asset_roots = vec!["lazy-panel".to_string()];
3500        options.projection_manifests = vec![manifest.into()];
3501
3502        let result = build(options).unwrap();
3503
3504        assert_eq!(result.component_asset_files.len(), 1);
3505        assert!(!result.protocol.fragments.contains_key("lazy-panel"));
3506        assert!(!result.protocol.components.contains_key("lazy-panel"));
3507    }
3508
3509    #[test]
3510    fn test_build_merges_multiple_projection_manifest_fragments() {
3511        let app = create_app_dir(&[
3512            (
3513                "index.html",
3514                "<html><body><card-a></card-a><card-b></card-b></body></html>",
3515            ),
3516            ("card-a.html", "<p>{{alpha}}</p>"),
3517            ("card-a.ts", "export {};"),
3518            ("card-b.html", "<p>{{beta}}</p>"),
3519            ("card-b.ts", "export {};"),
3520        ]);
3521        let json_a = projection::test_support::build_valid_manifest_json(
3522            app.path(),
3523            &[("card-a.ts", "export {};")],
3524            &[("card-a.js", "output-a")],
3525            &[(
3526                "card-a",
3527                "card-a.ts",
3528                &["card-a.js"],
3529                &["alpha"],
3530                &["alpha"],
3531            )],
3532        );
3533        let manifest_a =
3534            projection::test_support::write_manifest(app.path(), "fragment-a.json", &json_a);
3535        let json_b = projection::test_support::build_valid_manifest_json(
3536            app.path(),
3537            &[("card-b.ts", "export {};")],
3538            &[("card-b.js", "output-b")],
3539            &[("card-b", "card-b.ts", &["card-b.js"], &["beta"], &["beta"])],
3540        );
3541        let manifest_b =
3542            projection::test_support::write_manifest(app.path(), "fragment-b.json", &json_b);
3543
3544        let mut options = default_options(app.path());
3545        options.plugin = Some(Plugin::WebUI);
3546        options.projection_manifests = vec![manifest_a.into(), manifest_b.into()];
3547        let result = build(options).unwrap();
3548
3549        assert_eq!(
3550            result.protocol.initial_state_strategy,
3551            webui_protocol::InitialStateStrategy::Components as i32
3552        );
3553        let card_a = result.protocol.components.get("card-a").unwrap();
3554        assert_eq!(card_a.hydration_keys, vec!["alpha"]);
3555        let card_b = result.protocol.components.get("card-b").unwrap();
3556        assert_eq!(card_b.hydration_keys, vec!["beta"]);
3557    }
3558
3559    #[test]
3560    fn test_build_hello_world_example() {
3561        let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
3562        let app_dir = manifest_dir.join("../../examples/app/hello-world/src");
3563
3564        let result = build(BuildOptions {
3565            app_dir,
3566            entry: "index.html".to_string(),
3567            ..BuildOptions::default()
3568        })
3569        .unwrap();
3570
3571        let index = &result.protocol.fragments["index.html"].fragments;
3572        assert!(index.iter().any(
3573            |f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(fl)) if fl.collection == "people")
3574        ));
3575        assert!(index
3576            .iter()
3577            .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
3578    }
3579
3580    // ── Security tests ───────────────────────────────────────────────
3581
3582    #[test]
3583    fn test_css_filename_sanitizes_path_separators() {
3584        // Even if a component tag somehow contains path separators,
3585        // the filename should be sanitized to prevent directory traversal.
3586        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
3587        let result = build(default_options(app.path())).unwrap();
3588
3589        // Verify all CSS filenames are plain file names (no path separators)
3590        for (filename, _) in &result.css_files {
3591            assert!(
3592                !filename.contains('/') && !filename.contains('\\'),
3593                "CSS filename contains path separator: {filename}"
3594            );
3595        }
3596    }
3597
3598    #[test]
3599    fn test_build_to_disk_css_stays_in_output_dir() {
3600        let app = create_app_dir(&[
3601            ("index.html", "<my-card>Hello</my-card>"),
3602            (
3603                "my-card.html",
3604                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
3605            ),
3606            ("my-card.css", ".card { color: red; }"),
3607        ]);
3608        let out = TempDir::new().unwrap();
3609
3610        build_to_disk(default_options(app.path()), out.path()).unwrap();
3611
3612        // Verify CSS file is written inside out_dir
3613        let css_path = out.path().join("my-card.css");
3614        assert!(css_path.exists());
3615        let canonical = css_path.canonicalize().unwrap();
3616        let out_canonical = out.path().canonicalize().unwrap();
3617        assert!(
3618            canonical.starts_with(&out_canonical),
3619            "CSS file escaped output directory"
3620        );
3621    }
3622
3623    // ── Performance / edge case tests ────────────────────────────────
3624
3625    #[test]
3626    fn test_build_empty_html() {
3627        let app = create_app_dir(&[("index.html", "")]);
3628        let result = build(default_options(app.path())).unwrap();
3629
3630        assert!(result.protocol.fragments.contains_key("index.html"));
3631        assert_eq!(result.stats.fragment_count, 0);
3632        assert!(result.stats.protocol_size_bytes > 0);
3633    }
3634
3635    #[test]
3636    fn test_build_large_fragment_count() {
3637        // Verify stats are accurate with many fragments
3638        let mut html = String::with_capacity(2000);
3639        for i in 0..50 {
3640            html.push_str(&format!("<p>Item {i}</p>\n"));
3641        }
3642        let app = create_app_dir(&[("index.html", &html)]);
3643        let result = build(default_options(app.path())).unwrap();
3644
3645        assert!(result.stats.fragment_count > 0);
3646        assert_eq!(result.stats.css_file_count, 0);
3647    }
3648
3649    #[test]
3650    fn test_build_to_disk_inline_mode_no_css_files() {
3651        let app = create_app_dir(&[
3652            ("index.html", "<my-card>Hello</my-card>"),
3653            (
3654                "my-card.html",
3655                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
3656            ),
3657            ("my-card.css", ".card { color: red; }"),
3658        ]);
3659        let out = TempDir::new().unwrap();
3660        let mut options = default_options(app.path());
3661        options.css = CssStrategy::Style;
3662
3663        let stats = build_to_disk(options, out.path()).unwrap();
3664
3665        assert!(out.path().join("protocol.bin").exists());
3666        assert!(!out.path().join("my-card.css").exists());
3667        assert_eq!(stats.css_file_count, 0);
3668    }
3669
3670    #[test]
3671    fn test_build_stats_duration_is_nonzero() {
3672        let app = create_app_dir(&[("index.html", "<h1>Hello {{name}}</h1>")]);
3673        let result = build(default_options(app.path())).unwrap();
3674
3675        assert!(!result.stats.duration.is_zero());
3676    }
3677
3678    #[test]
3679    fn test_build_multiple_components_css() {
3680        let app = create_app_dir(&[
3681            ("index.html", "<card-a>A</card-a><card-b>B</card-b>"),
3682            (
3683                "card-a.html",
3684                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
3685            ),
3686            ("card-a.css", ".a { color: red; }"),
3687            (
3688                "card-b.html",
3689                r#"<template shadowrootmode="open"><span><slot></slot></span></template>"#,
3690            ),
3691            ("card-b.css", ".b { color: blue; }"),
3692        ]);
3693        let result = build(default_options(app.path())).unwrap();
3694
3695        assert_eq!(result.css_files.len(), 2);
3696        assert_eq!(result.stats.css_file_count, 2);
3697        let filenames: Vec<&str> = result.css_files.iter().map(|(n, _)| n.as_str()).collect();
3698        assert!(filenames.contains(&"card-a.css"));
3699        assert!(filenames.contains(&"card-b.css"));
3700    }
3701
3702    #[test]
3703    fn test_build_unused_component_css_not_emitted() {
3704        // card-b is registered but not referenced in index.html
3705        let app = create_app_dir(&[
3706            ("index.html", "<card-a>A</card-a>"),
3707            (
3708                "card-a.html",
3709                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
3710            ),
3711            ("card-a.css", ".a { color: red; }"),
3712            (
3713                "card-b.html",
3714                r#"<template shadowrootmode="open"><span><slot></slot></span></template>"#,
3715            ),
3716            ("card-b.css", ".b { color: blue; }"),
3717        ]);
3718        let result = build(default_options(app.path())).unwrap();
3719
3720        assert_eq!(result.css_files.len(), 1);
3721        assert_eq!(result.css_files[0].0, "card-a.css");
3722    }
3723
3724    #[test]
3725    fn test_build_unused_component_render_policy_not_emitted() {
3726        let app = create_app_dir(&[
3727            ("index.html", "<card-a>A</card-a>"),
3728            (
3729                "card-a.html",
3730                r#"<template w-render="lazy" w-reserve-block-size="10px"><div>a</div></template>"#,
3731            ),
3732            (
3733                "card-b.html",
3734                r#"<template w-render="lazy" w-reserve-block-size="20px"><div>b</div></template>"#,
3735            ),
3736        ]);
3737        let result = build(default_options(app.path())).unwrap();
3738
3739        assert!(result.protocol.component_render_css.contains("card-a"));
3740        assert!(!result.protocol.component_render_css.contains("card-b"));
3741    }
3742
3743    #[test]
3744    fn test_build_persists_interaction_work_policy() {
3745        let app = create_app_dir(&[
3746            ("index.html", "<interaction-shell></interaction-shell>"),
3747            (
3748                "interaction-shell.html",
3749                r#"<template w-hydrate="interaction"><button>Open</button></template>"#,
3750            ),
3751            (
3752                "interaction-shell.ts",
3753                "customElements.define('interaction-shell', class extends HTMLElement {});",
3754            ),
3755        ]);
3756        for plugin in [Plugin::WebUI, Plugin::FastV3] {
3757            let is_webui = matches!(plugin, Plugin::WebUI);
3758            let mut options = default_options(app.path());
3759            options.plugin = Some(plugin);
3760            let result = build(options).expect("interaction policy build");
3761            let component = &result.protocol.components["interaction-shell"];
3762            assert_eq!(
3763                component.work_policy,
3764                webui_protocol::ComponentWorkPolicy::Interaction as i32
3765            );
3766            if is_webui {
3767                assert!(component.template_json.contains(r#""wp":3"#));
3768            }
3769        }
3770    }
3771
3772    #[test]
3773    fn test_build_persists_combined_render_interaction_policy() {
3774        let app = create_app_dir(&[
3775            ("index.html", "<interaction-panel></interaction-panel>"),
3776            (
3777                "interaction-panel.html",
3778                concat!(
3779                    r#"<template w-render="lazy" w-reserve-block-size="18rem" "#,
3780                    r#"w-hydrate="interaction"><button>Open</button></template>"#,
3781                ),
3782            ),
3783            (
3784                "interaction-panel.ts",
3785                "customElements.define('interaction-panel', class extends HTMLElement {});",
3786            ),
3787        ]);
3788        for plugin in [Plugin::WebUI, Plugin::FastV3] {
3789            let is_webui = matches!(plugin, Plugin::WebUI);
3790            let mut options = default_options(app.path());
3791            options.plugin = Some(plugin);
3792            let result = build(options).expect("combined interaction policy build");
3793            let component = &result.protocol.components["interaction-panel"];
3794            assert_eq!(
3795                component.work_policy,
3796                webui_protocol::ComponentWorkPolicy::LazyRenderInteraction as i32
3797            );
3798            assert!(result
3799                .protocol
3800                .component_render_css
3801                .contains("interaction-panel"));
3802            if is_webui {
3803                assert!(component.template_json.contains(r#""wp":4"#));
3804            }
3805        }
3806    }
3807
3808    #[test]
3809    fn test_inspect_roundtrip_preserves_content() {
3810        let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
3811        let result = build(default_options(app.path())).unwrap();
3812
3813        let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
3814        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
3815
3816        let fragments = &parsed["fragments"]["index.html"]["fragments"];
3817        assert!(fragments.is_array());
3818        assert!(!fragments.as_array().unwrap().is_empty());
3819    }
3820
3821    #[test]
3822    fn test_build_nonexistent_app_dir() {
3823        let options = BuildOptions {
3824            app_dir: PathBuf::from("/nonexistent/path/that/does/not/exist"),
3825            ..BuildOptions::default()
3826        };
3827        let result = build(options);
3828        assert!(result.is_err());
3829    }
3830
3831    #[test]
3832    fn test_build_allows_fallback_chain_when_all_theme_tokens_exist() {
3833        let app = create_app_dir(&[
3834            ("index.html", "<my-card></my-card>"),
3835            ("my-card.html", "<div>Card</div>"),
3836            (
3837                "my-card.css",
3838                ":host { color: var(--token-a, var(--token-b, var(--token-c))); }",
3839            ),
3840        ]);
3841        let mut options = default_options(app.path());
3842        options.theme = Some(TokenFile {
3843            themes: HashMap::from([(
3844                "light".to_string(),
3845                HashMap::from([
3846                    ("token-a".to_string(), "red".to_string()),
3847                    ("token-b".to_string(), "blue".to_string()),
3848                    ("token-c".to_string(), "green".to_string()),
3849                ]),
3850            )]),
3851        });
3852
3853        let result = build(options).expect("all fallback tokens are defined");
3854        assert!(result.protocol.tokens.contains(&"token-a".to_string()));
3855        assert!(result.protocol.tokens.contains(&"token-b".to_string()));
3856        assert!(result.protocol.tokens.contains(&"token-c".to_string()));
3857    }
3858
3859    #[test]
3860    fn test_build_allows_literal_fallback_token_absent_from_theme() {
3861        let app = create_app_dir(&[
3862            ("index.html", "<my-card></my-card>"),
3863            ("my-card.html", "<div>Card</div>"),
3864            ("my-card.css", ":host { color: var(--brand, #000); }"),
3865        ]);
3866        let mut options = default_options(app.path());
3867        options.theme = Some(TokenFile {
3868            themes: HashMap::from([("light".to_string(), HashMap::new())]),
3869        });
3870
3871        // The CSS literal fallback (`#000`) means `--brand` is not required from
3872        // the theme, so the build must succeed — but the token is still hoisted
3873        // so the runtime resolves it when a theme provides it.
3874        let result = build(options).expect("literal fallback should not fail the build");
3875        assert!(result.protocol.tokens.contains(&"brand".to_string()));
3876        // `--brand` is absent from every theme and only used with a literal
3877        // fallback, so it is surfaced as a non-fatal typo advisory.
3878        assert_eq!(result.warnings.len(), 1, "warnings: {:?}", result.warnings);
3879        assert!(result.warnings[0].body().contains("--brand"));
3880    }
3881
3882    #[test]
3883    fn test_build_ancestor_inline_style_definition_after_comment_satisfies_descendant() {
3884        // `--foo-bar` is defined in the entry's inline <style> after a signal
3885        // comment and consumed inside a component. Custom properties inherit
3886        // through Shadow DOM, so the ancestor definition must satisfy the
3887        // descendant and the token must not be required from the theme.
3888        let app = create_app_dir(&[
3889            (
3890                "index.html",
3891                "<html><head><style>:root {\n  /*{{{tokens.light}}}*/\n  --foo-bar: 100px;\n}</style></head><body><my-card></my-card></body></html>",
3892            ),
3893            ("my-card.html", "<div>Card</div>"),
3894            ("my-card.css", ":host { padding: var(--foo-bar); }"),
3895        ]);
3896        let mut options = default_options(app.path());
3897        options.theme = Some(TokenFile {
3898            themes: HashMap::from([("light".to_string(), HashMap::new())]),
3899        });
3900
3901        let result = build(options)
3902            .expect("ancestor inline-style definition (after a comment) should satisfy the child");
3903        assert!(
3904            !result.protocol.tokens.contains(&"foo-bar".to_string()),
3905            "ancestor-defined token should not be hoisted: {:?}",
3906            result.protocol.tokens
3907        );
3908    }
3909
3910    #[test]
3911    fn test_build_no_warning_when_literal_fallback_token_is_themed() {
3912        let app = create_app_dir(&[
3913            ("index.html", "<my-card></my-card>"),
3914            ("my-card.html", "<div>Card</div>"),
3915            ("my-card.css", ":host { color: var(--brand, #000); }"),
3916        ]);
3917        let mut options = default_options(app.path());
3918        options.theme = Some(TokenFile {
3919            themes: HashMap::from([(
3920                "light".to_string(),
3921                HashMap::from([("brand".to_string(), "#123456".to_string())]),
3922            )]),
3923        });
3924
3925        let result = build(options).expect("themed literal-fallback token builds");
3926        assert!(
3927            result.warnings.is_empty(),
3928            "a themed token must not warn: {:?}",
3929            result.warnings
3930        );
3931    }
3932
3933    #[test]
3934    fn test_build_without_theme_produces_no_warnings() {
3935        let app = create_app_dir(&[
3936            ("index.html", "<my-card></my-card>"),
3937            ("my-card.html", "<div>Card</div>"),
3938            ("my-card.css", ":host { color: var(--brand, #000); }"),
3939        ]);
3940        let result = build(default_options(app.path())).unwrap();
3941        assert!(result.warnings.is_empty());
3942    }
3943
3944    #[test]
3945    fn test_build_excludes_tokens_defined_by_ancestor_component_css() {
3946        let app = create_app_dir(&[
3947            ("index.html", "<component-a></component-a>"),
3948            ("component-a.html", "<component-b></component-b>"),
3949            ("component-a.css", ":host { --brand-color: red; }"),
3950            ("component-b.html", "<div>Child</div>"),
3951            ("component-b.css", ":host { color: var(--brand-color); }"),
3952        ]);
3953        let mut options = default_options(app.path());
3954        options.theme = Some(TokenFile {
3955            themes: HashMap::from([("light".to_string(), HashMap::new())]),
3956        });
3957
3958        let result =
3959            build(options).expect("ancestor component custom property should satisfy child");
3960        assert!(
3961            !result.protocol.tokens.contains(&"brand-color".to_string()),
3962            "ancestor-defined token should not be exposed: {:?}",
3963            result.protocol.tokens
3964        );
3965    }
3966
3967    #[test]
3968    fn test_build_scoped_parent_overrides_preserve_theme_defaults() {
3969        for selector in [
3970            ".green",
3971            ":host([split]) plain-control",
3972            ":host([active])",
3973            "@media (width > 10000px) { :host",
3974            "@supports (display: unknown) { :host",
3975            "@container (width > 10000px) { :host",
3976        ] {
3977            let mut css = format!("{selector} {{ --brand: var(--green); }}");
3978            if selector.starts_with('@') {
3979                css.push('}');
3980            }
3981            let app = create_app_dir(&[
3982                ("index.html", "<layout-container></layout-container>"),
3983                (
3984                    "layout-container.html",
3985                    "<plain-control></plain-control><div class=\"green\"><plain-control></plain-control></div>",
3986                ),
3987                ("layout-container.css", &css),
3988                ("plain-control.html", "<button>Primary</button>"),
3989                ("plain-control.css", "button { color: var(--brand); }"),
3990            ]);
3991            let mut options = default_options(app.path());
3992            options.theme = Some(TokenFile {
3993                themes: HashMap::from([(
3994                    "light".to_string(),
3995                    HashMap::from([
3996                        ("brand".to_string(), "black".to_string()),
3997                        ("green".to_string(), "green".to_string()),
3998                    ]),
3999                )]),
4000            });
4001            let result = build(options).expect("scoped overrides should build");
4002            assert_eq!(result.protocol.tokens, ["brand", "green"], "{selector}");
4003
4004            let mut options = default_options(app.path());
4005            options.theme = Some(TokenFile {
4006                themes: HashMap::from([(
4007                    "light".to_string(),
4008                    HashMap::from([("green".to_string(), "green".to_string())]),
4009                )]),
4010            });
4011            let error = build(options).expect_err("uncovered child still requires --brand");
4012            assert!(error.chain_message().contains("missing-theme-token"));
4013        }
4014    }
4015
4016    #[test]
4017    fn test_build_scoped_inline_overrides_preserve_theme_defaults() {
4018        let app = create_app_dir(&[
4019            (
4020                "index.html",
4021                "<style>.green { --brand: var(--green); }</style><plain-control></plain-control><div class=\"green\"><plain-control></plain-control></div>",
4022            ),
4023            ("plain-control.html", "<button>Primary</button>"),
4024            ("plain-control.css", "button { color: var(--brand); }"),
4025        ]);
4026        let result = build(default_options(app.path())).expect("build");
4027        assert_eq!(result.protocol.tokens, ["brand", "green"]);
4028    }
4029
4030    #[test]
4031    fn test_build_does_not_use_sibling_component_definitions_for_theme_validation() {
4032        let app = create_app_dir(&[
4033            (
4034                "index.html",
4035                "<component-a></component-a><component-b></component-b>",
4036            ),
4037            ("component-a.html", "<div>A</div>"),
4038            ("component-a.css", ":host { --brand-color: red; }"),
4039            ("component-b.html", "<div>B</div>"),
4040            ("component-b.css", ":host { color: var(--brand-color); }"),
4041        ]);
4042        let mut options = default_options(app.path());
4043        options.theme = Some(TokenFile {
4044            themes: HashMap::from([("light".to_string(), HashMap::new())]),
4045        });
4046
4047        let Err(err) = build(options) else {
4048            panic!("sibling custom property must not satisfy component-b");
4049        };
4050        let message = err.chain_message();
4051        assert!(message.contains("missing theme token"), "msg: {message}");
4052        assert!(message.contains("missing-theme-token"), "msg: {message}");
4053        assert!(message.contains("--brand-color"), "msg: {message}");
4054    }
4055
4056    #[test]
4057    fn test_build_rejects_missing_theme_token_after_local_definition_filter() {
4058        let app = create_app_dir(&[
4059            ("index.html", "<my-card></my-card>"),
4060            ("my-card.html", "<div>Card</div>"),
4061            (
4062                "my-card.css",
4063                ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c))); }",
4064            ),
4065        ]);
4066        let mut options = default_options(app.path());
4067        options.theme = Some(TokenFile {
4068            themes: HashMap::from([(
4069                "light".to_string(),
4070                HashMap::from([("token-b".to_string(), "green".to_string())]),
4071            )]),
4072        });
4073
4074        let Err(err) = build(options) else {
4075            panic!("missing --token-c in the theme must fail the build");
4076        };
4077        let message = err.chain_message();
4078        assert!(message.contains("missing-theme-token"), "msg: {message}");
4079        assert!(message.contains("--token-c"), "msg: {message}");
4080        // The error demands the missing `--token-c`, never the locally-defined
4081        // `--token-a` (which only appears in the source snippet as context).
4082        assert!(!message.contains("add --token-a"), "msg: {message}");
4083    }
4084
4085    #[test]
4086    fn test_build_to_disk_returns_accurate_stats() {
4087        let app = create_app_dir(&[
4088            ("index.html", "<my-card>Hello</my-card><p>{{name}}</p>"),
4089            (
4090                "my-card.html",
4091                r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
4092            ),
4093            ("my-card.css", ".card { color: red; }"),
4094        ]);
4095        let out = TempDir::new().unwrap();
4096
4097        let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
4098
4099        assert!(stats.fragment_count > 0);
4100        assert_eq!(stats.css_file_count, 1);
4101        assert!(stats.component_count > 0);
4102        assert!(stats.protocol_size_bytes > 0);
4103        assert!(!stats.duration.is_zero());
4104    }
4105}