Skip to main content

webui/
lib.rs

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