mod chunking;
pub mod component_asset_output;
mod component_assets;
mod error;
mod module_preload;
mod projection;
pub mod server;
pub mod streaming;
mod style_bundle;
pub use component_assets::{render_component_assets, ComponentAssetFile, ComponentAssetGraph};
pub use error::WebUIError;
pub use webui_handler::route_handler::{encode_inventory, get_needed_components, parse_inventory};
pub use webui_handler::Result as HandlerResult;
pub use webui_handler::{
plugin::HandlerPlugin, BoundaryDescriptor, BoundaryInstanceId, BoundaryKey, BoundaryMode,
FlushWriter, HandlerError, Protocol, RenderOptions, ResponseWriter, SessionOptions,
SpanInstanceId, StreamStatus, StreamStep, StreamingResponse, StreamingSession, StreamingState,
WebUIHandler,
};
pub use webui_parser::plugin::{ComponentTemplateArtifact, StateSurface};
pub use webui_parser::CssStrategy;
pub use webui_parser::DomStrategy;
pub use webui_parser::LegalComments;
pub use webui_parser::ParserError;
pub use webui_parser::ParserOptions;
pub use webui_parser::Plugin;
pub use webui_parser::DEFAULT_CSS_FILE_NAME_TEMPLATE;
pub use webui_parser::{
AssetFileNameTemplate, AssetFileNameTemplateError, DEFAULT_ASSET_FILE_NAME_TEMPLATE,
};
pub use webui_parser::{CssFallbackChain, CssTokenAnalysis};
pub use webui_parser::{Diagnostic, Severity};
pub use webui_protocol::WebUIProtocol;
pub use webui_tokens::{load_token_file, resolve_theme_path, TokenFile};
use std::collections::HashSet;
use std::ffi::{OsStr, OsString};
use std::fs;
use std::path::Path;
use std::time::Instant;
use webui_discovery::{DiscoveryPlugin, FastDiscoveryPlugin, WebUIDiscoveryPlugin};
use webui_parser::plugin::fast_v2::FastV2ParserPlugin;
use webui_parser::plugin::fast_v3::FastV3ParserPlugin;
use webui_parser::plugin::webui::WebUIParserPlugin;
use webui_parser::plugin::ParserPluginArtifacts;
use webui_parser::HtmlParser;
#[derive(Debug, Clone)]
pub struct PreparedProjectionManifests {
snapshot: std::sync::Arc<projection::ProjectionSnapshot>,
}
#[derive(Debug)]
enum ProjectionBarrierState {
Waiting,
Ready(PreparedProjectionManifests),
Failed(String),
}
#[doc(hidden)]
#[derive(Debug, Clone)]
pub struct PendingProjectionManifests {
state: std::sync::Arc<(std::sync::Mutex<ProjectionBarrierState>, std::sync::Condvar)>,
}
#[doc(hidden)]
pub struct ProjectionManifestCompleter {
state: std::sync::Arc<(std::sync::Mutex<ProjectionBarrierState>, std::sync::Condvar)>,
completed: bool,
}
#[derive(Debug, Clone)]
pub enum ProjectionManifestSource {
Path(std::path::PathBuf),
Inline {
manifest_path: std::path::PathBuf,
json: String,
},
Prepared(PreparedProjectionManifests),
#[doc(hidden)]
Pending(PendingProjectionManifests),
}
impl From<std::path::PathBuf> for ProjectionManifestSource {
fn from(path: std::path::PathBuf) -> Self {
Self::Path(path)
}
}
pub fn prepare_projection_manifests(
sources: &[ProjectionManifestSource],
) -> Result<PreparedProjectionManifests, WebUIError> {
let snapshot = projection::load_and_merge(sources)?
.unwrap_or_else(|| std::sync::Arc::new(projection::ProjectionSnapshot::default()));
Ok(PreparedProjectionManifests { snapshot })
}
#[doc(hidden)]
#[must_use]
pub fn resolve_generated_module_preloads(
projection: &PreparedProjectionManifests,
existing_hrefs: &[String],
entry_outputs: &[&Path],
output_urls: &std::collections::BTreeMap<std::path::PathBuf, String>,
) -> (Vec<String>, Vec<Diagnostic>) {
let mut resolved = module_preload::resolve_exact(
&projection.snapshot.entry_closures,
existing_hrefs,
entry_outputs,
output_urls,
);
(
std::mem::take(&mut resolved.hrefs),
std::mem::take(&mut resolved.warnings),
)
}
#[doc(hidden)]
#[must_use]
pub fn generated_module_preload_outputs(
projection: &PreparedProjectionManifests,
entry_outputs: &[&Path],
) -> Vec<std::path::PathBuf> {
module_preload::exact_output_identities(&projection.snapshot.entry_closures, entry_outputs)
}
#[doc(hidden)]
#[must_use]
pub fn projection_manifest_barrier() -> (ProjectionManifestSource, ProjectionManifestCompleter) {
let state = std::sync::Arc::new((
std::sync::Mutex::new(ProjectionBarrierState::Waiting),
std::sync::Condvar::new(),
));
(
ProjectionManifestSource::Pending(PendingProjectionManifests {
state: std::sync::Arc::clone(&state),
}),
ProjectionManifestCompleter {
state,
completed: false,
},
)
}
impl PendingProjectionManifests {
fn wait(&self) -> Result<PreparedProjectionManifests, WebUIError> {
let (lock, ready) = self.state.as_ref();
let mut state = lock.lock().map_err(|_| {
WebUIError::InvalidBuildOptions("projection synchronization lock poisoned".to_string())
})?;
loop {
match &*state {
ProjectionBarrierState::Waiting => {
state = ready.wait(state).map_err(|_| {
WebUIError::InvalidBuildOptions(
"projection synchronization lock poisoned".to_string(),
)
})?;
}
ProjectionBarrierState::Ready(projection) => {
return Ok(projection.clone());
}
ProjectionBarrierState::Failed(message) => {
return Err(WebUIError::InvalidBuildOptions(message.clone()));
}
}
}
}
}
impl ProjectionManifestCompleter {
pub fn complete(mut self, result: std::result::Result<PreparedProjectionManifests, String>) {
let (lock, ready) = self.state.as_ref();
if let Ok(mut state) = lock.lock() {
*state = match result {
Ok(projection) => ProjectionBarrierState::Ready(projection),
Err(message) => ProjectionBarrierState::Failed(message),
};
self.completed = true;
ready.notify_all();
}
}
}
impl Drop for ProjectionManifestCompleter {
fn drop(&mut self) {
if self.completed {
return;
}
let (lock, ready) = self.state.as_ref();
if let Ok(mut state) = lock.lock() {
*state = ProjectionBarrierState::Failed(
"projection producer terminated before completing metadata".to_string(),
);
ready.notify_all();
}
}
}
#[derive(Debug, Clone)]
pub struct BuildOptions {
pub app_dir: std::path::PathBuf,
pub entry: String,
pub css: CssStrategy,
pub dom: DomStrategy,
pub css_bundle: bool,
pub plugin: Option<Plugin>,
pub components: Vec<String>,
pub component_asset_roots: Vec<String>,
pub metafile: bool,
pub css_file_name_template: String,
pub css_public_base: Option<String>,
pub legal_comments: LegalComments,
pub theme: Option<TokenFile>,
pub projection_manifests: Vec<ProjectionManifestSource>,
}
impl Default for BuildOptions {
fn default() -> Self {
Self {
app_dir: std::path::PathBuf::from("."),
entry: "index.html".to_string(),
css: CssStrategy::Link,
dom: DomStrategy::Shadow,
css_bundle: false,
plugin: None,
components: Vec::new(),
component_asset_roots: Vec::new(),
metafile: false,
css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
css_public_base: None,
legal_comments: LegalComments::default(),
theme: None,
projection_manifests: Vec::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct BuildStats {
pub duration: std::time::Duration,
pub fragment_count: usize,
pub component_count: usize,
pub css_file_count: usize,
pub protocol_size_bytes: usize,
pub token_count: usize,
}
#[derive(Debug)]
pub struct BuildResult {
pub protocol: WebUIProtocol,
pub protocol_bytes: Vec<u8>,
pub css_files: Vec<(String, String)>,
pub component_asset_files: Vec<ComponentAssetFile>,
pub metafile: Option<String>,
pub component_templates: Vec<ComponentTemplateArtifact>,
pub warnings: Vec<Diagnostic>,
pub stats: BuildStats,
}
#[cold]
#[inline(never)]
fn streaming_without_projection_warning(entry: &str, boundary_count: usize) -> Diagnostic {
let plural = if boundary_count == 1 { "" } else { "s" };
Diagnostic::warning(format!(
"{entry} declares {boundary_count} streaming boundary checkpoint{plural} \
but this build has no state-projection manifest"
))
.code(webui_parser::codes::STREAMING_WITHOUT_PROJECTION)
.help(
"every checkpoint will serialize the whole state object instead of its own \
components' keys; pass the client build's `webui-projection.json` through \
`BuildOptions::projection_manifests` to send boundary-local state only",
)
}
#[must_use = "BuildResult contains the compiled protocol and statistics"]
pub fn build(options: BuildOptions) -> Result<BuildResult, WebUIError> {
let started = Instant::now();
let raw = build_protocol_inner(&options)?;
let protocol_bytes = raw.protocol.to_protobuf()?;
let stats = BuildStats {
duration: started.elapsed(),
fragment_count: raw.fragment_count,
component_count: raw.component_count,
css_file_count: raw.css_files.len(),
protocol_size_bytes: protocol_bytes.len(),
token_count: raw.token_count,
};
Ok(BuildResult {
protocol: raw.protocol,
protocol_bytes,
css_files: raw.css_files,
component_asset_files: raw.component_asset_files,
metafile: raw.metafile,
component_templates: raw.component_templates,
warnings: raw.warnings,
stats,
})
}
pub fn build_to_disk(options: BuildOptions, out_dir: &Path) -> Result<BuildStats, WebUIError> {
let result = build(options)?;
validate_output_file_names(OsStr::new("protocol.bin"), &result)?;
fs::create_dir_all(out_dir).map_err(|source| WebUIError::Io {
context: format!("Failed to create {}", out_dir.display()),
source,
})?;
fs::write(out_dir.join("protocol.bin"), &result.protocol_bytes).map_err(|source| {
WebUIError::Io {
context: format!("Failed to write protocol.bin to {}", out_dir.display()),
source,
}
})?;
for (name, content) in &result.css_files {
fs::write(out_dir.join(name), content).map_err(|source| WebUIError::Io {
context: format!("Failed to write {name} to {}", out_dir.display()),
source,
})?;
}
if !result.component_asset_files.is_empty() {
component_asset_output::publish(out_dir, &result.component_asset_files)?;
}
Ok(result.stats)
}
pub fn inspect(protocol_path: &Path) -> Result<String, WebUIError> {
let bytes = fs::read(protocol_path).map_err(|source| WebUIError::Io {
context: format!("Failed to read {}", protocol_path.display()),
source,
})?;
inspect_bytes(&bytes)
}
pub fn inspect_bytes(protocol_bytes: &[u8]) -> Result<String, WebUIError> {
let protocol = WebUIProtocol::from_protobuf(protocol_bytes)?;
protocol
.to_json_pretty()
.map_err(|e| WebUIError::Serialization(e.to_string()))
}
struct RawBuildOutput {
protocol: WebUIProtocol,
css_files: Vec<(String, String)>,
component_asset_files: Vec<ComponentAssetFile>,
metafile: Option<String>,
component_templates: Vec<ComponentTemplateArtifact>,
warnings: Vec<Diagnostic>,
fragment_count: usize,
component_count: usize,
token_count: usize,
}
fn build_protocol_inner(options: &BuildOptions) -> Result<RawBuildOutput, WebUIError> {
let projection_enabled = !options.projection_manifests.is_empty();
if projection_enabled && options.plugin != Some(Plugin::WebUI) {
return Err(projection::incompatible_plugin_error());
}
if !options.component_asset_roots.is_empty() && options.plugin != Some(Plugin::WebUI) {
return Err(WebUIError::InvalidBuildOptions(
"component assets require --plugin webui because generated entries use the WebUI Framework registration runtime"
.to_string(),
));
}
if options.css_bundle && options.css == CssStrategy::Module {
return Err(css_bundle_module_error());
}
let parser_options = ParserOptions::try_new(
options.css,
options.dom,
&options.css_file_name_template,
options.css_public_base.as_deref(),
options.legal_comments,
)
.map_err(|e| WebUIError::InvalidBuildOptions(e.to_string()))?;
let css_link_options = parser_options.css_link_options.clone();
let mut parser = match options.plugin {
Some(Plugin::Fast | Plugin::FastV2) => {
HtmlParser::with_plugin_options(Box::new(FastV2ParserPlugin::new()), parser_options)
}
Some(Plugin::FastV3) => {
HtmlParser::with_plugin_options(Box::new(FastV3ParserPlugin::new()), parser_options)
}
Some(Plugin::WebUI) => {
HtmlParser::with_plugin_options(Box::new(WebUIParserPlugin::new()), parser_options)
}
None => HtmlParser::with_options(parser_options),
};
let discovery_plugin: Box<dyn DiscoveryPlugin> = match options.plugin {
Some(Plugin::Fast | Plugin::FastV2 | Plugin::FastV3) => {
Box::new(FastDiscoveryPlugin::new())
}
Some(Plugin::WebUI) | None => Box::new(WebUIDiscoveryPlugin::new()),
};
let app_components = discovery_plugin
.discover_local(&options.app_dir)
.map_err(|error| {
WebUIError::ComponentDiscovery(format!(
"Failed to discover components from {}: {error}",
options.app_dir.display()
))
})?;
for component in &app_components {
parser
.component_registry_mut()
.register_component(webui_parser::ComponentRegistration {
tag_name: &component.tag_name,
html_content: &component.html_content,
css_content: component.css_content.as_deref(),
is_client_owned: component.is_client_owned,
})
.map_err(|source| WebUIError::ComponentRegistration {
context: format!(
"Failed to register component '{}' from {}",
component.tag_name, component.source
),
source,
})?;
}
for source in &options.components {
let result = webui_discovery::discover_source_with_plugin(
source,
&options.app_dir,
discovery_plugin.as_ref(),
)
.map_err(|e| {
WebUIError::ComponentDiscovery(format!(
"Failed to discover components from {source}: {e}"
))
})?;
for comp in &result.components {
parser
.component_registry_mut()
.register_component(webui_parser::ComponentRegistration {
tag_name: &comp.tag_name,
html_content: &comp.html_content,
css_content: comp.css_content.as_deref(),
is_client_owned: comp.is_client_owned,
})
.map_err(|source| WebUIError::ComponentRegistration {
context: format!(
"Failed to register component '{}' from {}",
comp.tag_name, comp.source
),
source,
})?;
}
}
let component_count = parser.component_registry().len();
let entry_path = options.app_dir.join(&options.entry);
let html_content = fs::read_to_string(&entry_path).map_err(|source| WebUIError::Io {
context: format!("Failed to read {}", entry_path.display()),
source,
})?;
parser
.parse(&options.entry, &html_content)
.map_err(|source| WebUIError::Parse {
context: format!("Failed to parse {}", options.entry),
source,
})?;
let module_entry_srcs = parser.module_entry_srcs().to_vec();
let synthetic_asset_fragments =
parse_component_asset_roots(&mut parser, &options.component_asset_roots)?;
let component_render_css = parser.component_registry().render_policy_css(
parser
.component_registry()
.get_all()
.filter(|component| parser.has_fragment(&component.tag_name))
.map(|component| component.tag_name.as_str()),
);
let css_snapshot: Vec<(String, String)> = parser
.component_registry()
.get_all()
.filter(|component| parser.has_fragment(&component.tag_name))
.filter_map(|component| {
component
.css_content
.as_ref()
.map(|css| (component.tag_name.clone(), css.clone()))
})
.collect();
let component_shadow_dom_usage: Vec<(String, bool)> = parser
.component_shadow_dom_usage()
.map(|(tag_name, uses_shadow_dom)| (tag_name.to_string(), uses_shadow_dom))
.collect();
let component_work_policies: Vec<(String, u8)> = parser
.component_work_policies()
.map(|(tag_name, policy)| (tag_name.to_string(), policy))
.collect();
let token_analysis = parser.token_analysis();
let mut warnings: Vec<Diagnostic> = parser.take_warnings();
if let Some(theme) = options.theme.as_ref() {
token_analysis
.validate_theme_tokens(theme)
.map_err(|source| WebUIError::Parse {
context: "Failed to validate theme tokens".to_string(),
source,
})?;
warnings.extend(token_analysis.theme_token_warnings(theme));
}
let token_count = token_analysis.protocol_tokens.len();
let component_templates =
match parser
.take_plugin_artifacts()
.map_err(|source| WebUIError::Parse {
context: format!(
"Failed to compile component templates for {}",
options.entry
),
source,
})? {
ParserPluginArtifacts::None => Vec::new(),
ParserPluginArtifacts::ComponentTemplates(templates) => templates,
};
let mut fragment_records = parser.into_fragment_records();
for fragment_id in synthetic_asset_fragments {
fragment_records.remove(&fragment_id);
}
let boundary_count = fragment_records
.values()
.flat_map(|list| list.fragments.iter())
.filter(|fragment| {
matches!(
fragment.fragment.as_ref(),
Some(webui_protocol::web_ui_fragment::Fragment::Boundary(boundary))
if boundary.phase() == webui_protocol::BoundaryPhase::Start
)
})
.count();
let merged_manifest = projection::load_and_merge(&options.projection_manifests)?;
let mut protocol = WebUIProtocol::with_tokens(fragment_records, token_analysis.protocol_tokens);
protocol.initial_state_strategy = if merged_manifest.is_some() {
webui_protocol::InitialStateStrategy::Components as i32
} else {
webui_protocol::InitialStateStrategy::Full as i32
};
if boundary_count > 0 && merged_manifest.is_none() {
warnings.push(streaming_without_projection_warning(
&options.entry,
boundary_count,
));
}
if let Some(merged) = &merged_manifest {
let mut preloads = module_preload::resolve(&merged.entry_closures, &module_entry_srcs);
protocol.module_preloads = std::mem::take(&mut preloads.hrefs);
warnings.append(&mut preloads.warnings);
}
protocol.set_css_strategy(match options.css {
CssStrategy::Link => webui_protocol::CssStrategy::Link,
CssStrategy::Style => webui_protocol::CssStrategy::Style,
CssStrategy::Module => webui_protocol::CssStrategy::Module,
});
for (tag_name, uses_shadow_dom) in component_shadow_dom_usage {
if !protocol.fragments.contains_key(&tag_name) {
continue;
}
protocol
.components
.entry(tag_name)
.or_default()
.uses_shadow_dom = uses_shadow_dom;
}
for (tag_name, work_policy) in component_work_policies {
if protocol.fragments.contains_key(&tag_name) {
protocol.components.entry(tag_name).or_default().work_policy = i32::from(work_policy);
}
}
protocol.component_render_css = component_render_css;
let stores_css = options.css != CssStrategy::Link;
let mut css_files: Vec<(String, String)> = Vec::new();
let mut emitted_names: HashSet<String> = HashSet::new();
let mut bundle_inputs: Vec<(String, String)> = Vec::new();
for (tag, css) in css_snapshot {
if !protocol.fragments.contains_key(&tag) {
continue;
}
if options.css_bundle {
bundle_inputs.push((tag.clone(), css.clone()));
}
if stores_css {
protocol.components.entry(tag).or_default().css = css;
} else {
let resolved = css_link_options.resolve(&tag, &css);
if !emitted_names.insert(resolved.filename.clone()) {
return Err(WebUIError::InvalidBuildOptions(format!(
"CSS filename collision for Link strategy: '{}'. Adjust the asset filename template to include [name] or another unique component-specific segment.",
resolved.filename
)));
}
protocol.components.entry(tag).or_default().css_href = resolved.href;
css_files.push((resolved.filename, css));
}
}
protocol.populate_style_closures(&[options.entry.as_str()]);
if options.css_bundle {
let by_tag = bundle_inputs
.iter()
.map(|(tag, css)| (tag.as_str(), css.as_str()))
.collect();
let chunk_files = style_bundle::bundle_style_chunks(
&mut protocol,
options.entry.as_str(),
&by_tag,
&css_link_options,
)?;
if !stores_css {
for (filename, css) in chunk_files {
if emitted_names.insert(filename.clone()) {
css_files.push((filename, css));
continue;
}
let identical = css_files
.iter()
.any(|(name, existing)| *name == filename && *existing == css);
if !identical {
return Err(WebUIError::InvalidBuildOptions(format!(
"CSS filename collision between bundled chunk '{filename}' and a component stylesheet of the same name. Adjust the asset filename template to include [hash] or another unique segment."
)));
}
}
}
}
for artifact in &component_templates {
let component = protocol
.components
.entry(artifact.tag_name.clone())
.or_default();
component.template = artifact.template.clone();
component.template_json = artifact.template_json.clone();
component.template_functions = artifact.template_functions.clone();
let manifest_entry = if artifact.is_scripted {
merged_manifest
.as_ref()
.and_then(|merged| merged.components.get(&artifact.tag_name))
} else {
None
};
let (hydration_mode, hydration_keys) = match manifest_entry {
Some(entry) => encode_state_surface(&StateSurface::Keys(entry.hydration_keys.clone())),
None => encode_state_surface(&artifact.hydration),
};
component.hydration_mode = hydration_mode;
component.hydration_keys = hydration_keys;
let (navigation_mode, navigation_keys) = match manifest_entry {
Some(entry) => {
let union =
projection::union_keys(&entry.navigation_keys, &artifact.template_roots);
encode_state_surface(&StateSurface::Keys(union))
}
None => encode_state_surface(&artifact.navigation),
};
component.navigation_mode = Some(navigation_mode);
component.navigation_keys = navigation_keys;
}
let mut component_asset_graph = component_assets::render_component_assets(
&protocol,
&options.entry,
&options.component_asset_roots,
options.metafile,
)?;
component_asset_graph.retain_entry_protocol(&mut protocol)?;
if let Some(merged) = &merged_manifest {
let entry_scripted_tags: Vec<&str> = component_templates
.iter()
.filter(|artifact| {
artifact.is_scripted && protocol.fragments.contains_key(&artifact.tag_name)
})
.map(|artifact| artifact.tag_name.as_str())
.collect();
projection::validate_coverage(&merged.components, &entry_scripted_tags)?;
}
let fragment_count = protocol
.fragments
.values()
.map(|fragments| fragments.fragments.len())
.sum();
let component_asset_files = component_asset_graph.files;
validate_generated_file_names(&css_files, &component_asset_files)?;
Ok(RawBuildOutput {
protocol,
css_files,
component_asset_files,
metafile: component_asset_graph.metafile,
component_templates,
warnings,
fragment_count,
component_count,
token_count,
})
}
fn encode_state_surface(surface: &StateSurface) -> (i32, Vec<String>) {
match surface {
StateSurface::None => (webui_protocol::StateProjectionMode::None as i32, Vec::new()),
StateSurface::Keys(keys) => (
webui_protocol::StateProjectionMode::Keys as i32,
keys.clone(),
),
StateSurface::All => (webui_protocol::StateProjectionMode::All as i32, Vec::new()),
}
}
fn parse_component_asset_roots(
parser: &mut HtmlParser,
roots: &[String],
) -> Result<Vec<String>, WebUIError> {
let mut synthetic_fragments = Vec::with_capacity(roots.len());
for (index, root) in roots.iter().enumerate() {
let root = root.trim();
if parser.has_fragment(root) {
continue;
}
let mut synthetic = String::with_capacity(root.len() * 2 + 5);
synthetic.push('<');
synthetic.push_str(root);
synthetic.push_str("></");
synthetic.push_str(root);
synthetic.push('>');
let fragment_id = format!("__webui_asset_root_{index}");
parser
.parse(&fragment_id, &synthetic)
.map_err(|source| WebUIError::Parse {
context: format!("Failed to parse component asset root <{root}>"),
source,
})?;
synthetic_fragments.push(fragment_id);
}
Ok(synthetic_fragments)
}
fn validate_generated_file_names(
css_files: &[(String, String)],
component_asset_files: &[ComponentAssetFile],
) -> Result<(), WebUIError> {
let mut names = HashSet::with_capacity(css_files.len() + component_asset_files.len());
for (name, _) in css_files {
names.insert(name.as_str());
}
for file in component_asset_files {
if !names.insert(file.name.as_str()) {
return Err(WebUIError::InvalidBuildOptions(format!(
"output filename collision for '{}'. Adjust the asset filename template to include [ext] or another unique asset-type segment.",
file.name
)));
}
}
Ok(())
}
fn validate_output_file_names(
protocol_name: &OsStr,
result: &BuildResult,
) -> Result<(), WebUIError> {
let mut names =
HashSet::with_capacity(1 + result.css_files.len() + result.component_asset_files.len());
names.insert(protocol_name.to_os_string());
for (name, _) in &result.css_files {
let file_name = OsString::from(name);
if !names.insert(file_name.clone()) {
return Err(output_file_collision_error(&file_name));
}
}
for file in &result.component_asset_files {
let file_name = OsString::from(&file.name);
if !names.insert(file_name.clone()) {
return Err(output_file_collision_error(&file_name));
}
}
Ok(())
}
fn output_file_collision_error(name: &OsStr) -> WebUIError {
WebUIError::InvalidBuildOptions(format!(
"output filename collision for '{}'. Adjust the asset filename template to include [ext] or another unique asset-type segment.",
name.to_string_lossy()
))
}
#[cold]
#[inline(never)]
fn css_bundle_module_error() -> WebUIError {
WebUIError::InvalidBuildOptions(
"CSS bundling is not supported with the Module strategy. Module builds already inline every stylesheet as a data URI, so there are no requests to merge, and their import-map specifiers are resolved per component before chunks exist. Use --css link (or --css style) with --css-bundle, or drop --css-bundle.".to_string(),
)
}
#[cfg(test)]
#[allow(clippy::disallowed_methods)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::path::PathBuf;
use tempfile::TempDir;
use webui_handler::plugin::webui::WebUIHydrationPlugin;
use webui_protocol::web_ui_fragment::Fragment;
struct StringWriter {
buf: String,
}
impl ResponseWriter for StringWriter {
fn write(&mut self, content: &str) -> webui_handler::Result<()> {
self.buf.push_str(content);
Ok(())
}
fn end(&mut self) -> webui_handler::Result<()> {
Ok(())
}
}
fn create_app_dir(files: &[(&str, &str)]) -> TempDir {
let dir = TempDir::new().unwrap();
for (name, content) in files {
let path = dir.path().join(name);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, content).unwrap();
}
dir
}
fn default_options(app_dir: &Path) -> BuildOptions {
BuildOptions {
app_dir: app_dir.to_path_buf(),
..BuildOptions::default()
}
}
fn component_payload<'a>(files: &'a [ComponentAssetFile], tag: &str) -> &'a ComponentAssetFile {
let mut prefix = String::with_capacity(tag.len() + 12);
prefix.push_str("components/");
prefix.push_str(tag);
prefix.push('.');
files
.iter()
.find(|file| file.name.starts_with(&prefix))
.unwrap()
}
fn light_options(app_dir: &Path) -> BuildOptions {
BuildOptions {
dom: DomStrategy::Light,
..default_options(app_dir)
}
}
mod fast;
#[test]
fn test_build_simple_html() {
let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
let result = build(default_options(app.path())).unwrap();
assert!(result.protocol.fragments.contains_key("index.html"));
assert!(result.stats.fragment_count > 0);
assert!(result.stats.protocol_size_bytes > 0);
assert!(!result.stats.duration.is_zero());
assert_eq!(
result.protocol.initial_state_strategy,
webui_protocol::InitialStateStrategy::Full as i32
);
}
#[test]
fn light_build_uses_light_unless_component_authors_shadow() {
let app = create_app_dir(&[
(
"index.html",
"<my-card></my-card><shadow-card></shadow-card>",
),
("my-card.html", "<p>content</p>"),
(
"shadow-card.html",
r#"<template shadowrootmode="open"><p>shadow</p></template>"#,
),
]);
let result = build(BuildOptions {
app_dir: app.path().to_path_buf(),
dom: DomStrategy::Light,
..BuildOptions::default()
})
.unwrap();
assert!(!result.protocol.component_uses_shadow_dom("my-card"));
assert!(result.protocol.component_uses_shadow_dom("shadow-card"));
let light_html: String = result.protocol.fragments["my-card"]
.fragments
.iter()
.filter_map(|fragment| match fragment.fragment.as_ref() {
Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
_ => None,
})
.collect();
assert!(!light_html.contains("shadowrootmode"));
let shadow_html: String = result.protocol.fragments["shadow-card"]
.fragments
.iter()
.filter_map(|fragment| match fragment.fragment.as_ref() {
Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
_ => None,
})
.collect();
assert!(shadow_html.contains(r#"<template shadowrootmode="open">"#));
}
#[test]
fn build_defaults_unwrapped_components_to_shadow() {
let app = create_app_dir(&[
("index.html", "<my-card></my-card>"),
("my-card.html", "<p>content</p>"),
]);
let result = build(default_options(app.path())).unwrap();
assert!(result.protocol.component_uses_shadow_dom("my-card"));
let html: String = result.protocol.fragments["my-card"]
.fragments
.iter()
.filter_map(|fragment| match fragment.fragment.as_ref() {
Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
_ => None,
})
.collect();
assert!(html.contains(r#"<template shadowrootmode="open">"#));
}
#[test]
fn build_rejects_slot_in_unwrapped_light_component() {
let app = create_app_dir(&[
("index.html", "<my-card></my-card>"),
("my-card.html", "<div><slot></slot></div>"),
]);
let error = build(BuildOptions {
app_dir: app.path().to_path_buf(),
dom: DomStrategy::Light,
..BuildOptions::default()
})
.expect_err("unwrapped Light DOM slot must fail the build");
assert!(
error.chain_message().contains("[light-dom-slot]"),
"unexpected build error: {}",
error.chain_message()
);
}
#[test]
fn pending_projection_unblocks_after_template_compilation() {
let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
let (source, completer) = projection_manifest_barrier();
let options = BuildOptions {
app_dir: app.path().to_path_buf(),
plugin: Some(Plugin::WebUI),
projection_manifests: vec![source],
..BuildOptions::default()
};
let handle = std::thread::spawn(move || build(options));
let prepared = prepare_projection_manifests(&[]).unwrap();
completer.complete(Ok(prepared));
let result = handle.join().unwrap().unwrap();
assert_eq!(
result.protocol.initial_state_strategy,
webui_protocol::InitialStateStrategy::Components as i32
);
}
#[test]
fn streaming_entry_without_projection_manifest_warns() {
let app = create_app_dir(&[(
"index.html",
concat!(
"<html><body>",
r#"<boundary name="a"><p>{{one}}</p></boundary>"#,
r#"<boundary name="b"><p>{{two}}</p></boundary>"#,
"</body></html>",
),
)]);
let mut options = default_options(app.path());
options.plugin = Some(Plugin::WebUI);
let result = build(options).unwrap();
assert_eq!(
result.protocol.initial_state_strategy,
webui_protocol::InitialStateStrategy::Full as i32
);
let boundary_names = |protocol: &WebUIProtocol| {
protocol.fragments["index.html"]
.fragments
.iter()
.filter_map(|fragment| match fragment.fragment.as_ref() {
Some(webui_protocol::web_ui_fragment::Fragment::Boundary(boundary))
if boundary.phase() == webui_protocol::BoundaryPhase::Start =>
{
Some(boundary.name.clone())
}
_ => None,
})
.collect::<Vec<_>>()
};
assert_eq!(boundary_names(&result.protocol), ["a", "b"]);
let decoded = WebUIProtocol::from_protobuf(&result.protocol_bytes).unwrap();
assert_eq!(boundary_names(&decoded), ["a", "b"]);
let warning = result
.warnings
.iter()
.find(|diag| {
diag.error_code() == Some(webui_parser::codes::STREAMING_WITHOUT_PROJECTION)
})
.expect("a streaming build without projection must warn");
assert_eq!(warning.severity(), webui_parser::Severity::Warning);
assert!(
warning.to_string().contains("2 streaming boundary"),
"warning should report the boundary count: {warning}"
);
assert!(
warning
.help_text()
.is_some_and(|help| help.contains("projection_manifests")),
"warning should point at the fix: {warning}"
);
}
#[test]
fn streaming_entry_with_projection_manifest_does_not_warn() {
let app = create_app_dir(&[(
"index.html",
concat!(
"<html><body>",
r#"<boundary name="a"><p>{{one}}</p></boundary>"#,
"</body></html>",
),
)]);
let manifest_json =
projection::test_support::build_valid_manifest_json(app.path(), &[], &[], &[]);
let manifest = projection::test_support::write_manifest(
app.path(),
"webui-projection.json",
&manifest_json,
);
let mut options = default_options(app.path());
options.plugin = Some(Plugin::WebUI);
options.projection_manifests = vec![manifest.into()];
let result = build(options).unwrap();
assert_eq!(
result.protocol.initial_state_strategy,
webui_protocol::InitialStateStrategy::Components as i32
);
assert!(
!result
.warnings
.iter()
.any(|diag| diag.error_code()
== Some(webui_parser::codes::STREAMING_WITHOUT_PROJECTION)),
"a projected streaming build must not warn"
);
}
#[test]
fn non_streaming_entry_without_projection_manifest_does_not_warn() {
let app = create_app_dir(&[("index.html", "<html><body><p>{{one}}</p></body></html>")]);
let mut options = default_options(app.path());
options.plugin = Some(Plugin::WebUI);
let result = build(options).unwrap();
assert!(
!result
.warnings
.iter()
.any(|diag| diag.error_code()
== Some(webui_parser::codes::STREAMING_WITHOUT_PROJECTION)),
"a non-streaming build must not warn about projection"
);
}
#[test]
fn test_build_keeps_scriptless_component_dormant_until_client_use() {
let app = create_app_dir(&[
(
"index.html",
"<html><body><demo-card></demo-card></body></html>",
),
("demo-card.html", "<p>{{name}} {{serverTitle}}</p>"),
]);
let manifest_json =
projection::test_support::build_valid_manifest_json(app.path(), &[], &[], &[]);
let manifest = projection::test_support::write_manifest(
app.path(),
"webui-projection.json",
&manifest_json,
);
let mut options = default_options(app.path());
options.plugin = Some(Plugin::WebUI);
options.projection_manifests = vec![manifest.into()];
let result = build(options).unwrap();
assert_eq!(
result.protocol.initial_state_strategy,
webui_protocol::InitialStateStrategy::Components as i32
);
assert!(result
.component_templates
.iter()
.any(|template| template.tag_name == "demo-card"));
let component = result
.protocol
.components
.get("demo-card")
.expect("scriptless component metadata should be retained");
assert!(component.hydration_keys.is_empty());
assert_eq!(component.navigation_keys, ["name", "serverTitle"]);
assert!(component.template_json.contains(r#""th":1"#));
let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
let state = serde_json::json!({ "name": "Server rendered" });
let protocol = Protocol::new(result.protocol.clone());
let mut writer = StringWriter { buf: String::new() };
handler
.render(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert!(writer.buf.contains("Server rendered"));
assert!(writer.buf.contains(r#""state":{}"#));
assert!(writer.buf.contains(r#""templates":{"demo-card":"#));
}
#[test]
fn test_build_treats_sibling_component_script_as_scripted() {
let app = create_app_dir(&[
("index.html", "<demo-card></demo-card>"),
(
"demo-card.html",
r#"<button @click="{onClick()}">{{name}}</button>"#,
),
(
"demo-card.ts",
"import { WebUIElement } from '@microsoft/webui-framework';\n\
class DemoCard extends WebUIElement { onClick() {} }\n\
DemoCard.define('demo-card');",
),
]);
let mut options = default_options(app.path());
options.plugin = Some(Plugin::WebUI);
let result = build(options).unwrap();
let template = result
.component_templates
.iter()
.find(|template| template.tag_name == "demo-card")
.unwrap();
assert!(template.template_json.contains(r#""eg":[["click""#));
}
#[test]
fn test_build_populates_component_hydration_keys_from_script() {
let app = create_app_dir(&[
("index.html", "<demo-card></demo-card>"),
("demo-card.html", "<p>{{name}}</p>"),
(
"demo-card.ts",
"import { WebUIElement, attr, observable } from '@microsoft/webui-framework';\n\
class DemoCard extends WebUIElement {\n\
@observable name = '';\n\
@attr({ attribute: 'cta-href' }) ctaHref = '/x';\n\
@observable count = 0;\n\
}\n\
DemoCard.define('demo-card');",
),
]);
let manifest_json = projection::test_support::build_valid_manifest_json(
app.path(),
&[("demo-card.ts", "source")],
&[("demo-card.js", "output")],
&[(
"demo-card",
"demo-card.ts",
&["demo-card.js"],
&["count", "ctaHref", "name"],
&["count", "ctaHref", "name"],
)],
);
let manifest = projection::test_support::write_manifest(
app.path(),
"webui-projection.json",
&manifest_json,
);
let mut options = default_options(app.path());
options.plugin = Some(Plugin::WebUI);
options.projection_manifests = vec![manifest.into()];
let result = build(options).unwrap();
assert_eq!(
result.protocol.initial_state_strategy,
webui_protocol::InitialStateStrategy::Components as i32
);
let component = result.protocol.components.get("demo-card").unwrap();
assert_eq!(
component.hydration_mode,
webui_protocol::StateProjectionMode::Keys as i32
);
assert_eq!(component.hydration_keys, vec!["count", "ctaHref", "name"]);
assert_eq!(
component.navigation_mode,
Some(webui_protocol::StateProjectionMode::Keys as i32)
);
assert_eq!(component.navigation_keys, vec!["count", "ctaHref", "name"]);
}
#[test]
fn test_build_scripted_component_without_manifest_falls_back_to_full_state() {
let app = create_app_dir(&[
(
"index.html",
"<html><body><demo-card></demo-card></body></html>",
),
("demo-card.html", "<p>{{name}}</p>"),
(
"demo-card.ts",
"import { WebUIElement, observable } from '@microsoft/webui-framework';\n\
class DemoCard extends WebUIElement {\n\
@observable name = '';\n\
}\n\
DemoCard.define('demo-card');",
),
]);
let mut options = default_options(app.path());
options.plugin = Some(Plugin::WebUI);
let result = build(options).unwrap();
assert_eq!(
result.protocol.initial_state_strategy,
webui_protocol::InitialStateStrategy::Full as i32
);
let component = result.protocol.components.get("demo-card").unwrap();
assert_eq!(
component.hydration_mode,
webui_protocol::StateProjectionMode::All as i32
);
assert!(component.hydration_keys.is_empty());
assert_eq!(
component.navigation_mode,
Some(webui_protocol::StateProjectionMode::All as i32)
);
let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
let state = serde_json::json!({ "name": "Visible" });
let protocol = Protocol::new(result.protocol.clone());
let mut writer = StringWriter { buf: String::new() };
handler
.render(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert!(writer.buf.contains("Visible"));
assert!(writer.buf.contains(r#""state":{"name":"Visible"}"#));
}
#[test]
fn test_build_rejects_scriptless_event_component() {
let app = create_app_dir(&[
("index.html", "<demo-card></demo-card>"),
(
"demo-card.html",
r#"<button @click="{onClick()}">Click</button>"#,
),
]);
let mut options = default_options(app.path());
options.plugin = Some(Plugin::WebUI);
let err = build(options).expect_err("scriptless events should fail");
let message = err.chain_message();
assert!(
message.contains("scriptless-event-handler"),
"msg: {message}"
);
}
#[test]
fn test_build_returns_actionable_error_for_invalid_w_ref() {
let app = create_app_dir(&[
("index.html", "<my-card>Hello</my-card>"),
("my-card.html", r#"<div><input w-ref="myInput" /></div>"#),
]);
let mut options = default_options(app.path());
options.plugin = Some(Plugin::WebUI);
let Err(err) = build(options) else {
panic!("non-braced w-ref must fail the build");
};
let msg = err.chain_message();
assert!(msg.contains("invalid w-ref binding"), "msg: {msg}");
assert!(
msg.contains("component <my-card> · element <input>"),
"msg: {msg}"
);
assert!(msg.contains("help:"), "msg: {msg}");
}
#[test]
fn test_build_returns_actionable_error_for_invalid_event_handler() {
let app = create_app_dir(&[
("index.html", "<my-btn>x</my-btn>"),
(
"my-btn.html",
r#"<div><button @click="e.preventDefault()">x</button></div>"#,
),
]);
let mut options = default_options(app.path());
options.plugin = Some(Plugin::WebUI);
let Err(err) = build(options) else {
panic!("invalid @event handler must fail the build");
};
let msg = err.chain_message();
assert!(msg.contains("invalid @click handler"), "msg: {msg}");
assert!(
msg.contains("component <my-btn> · element <button>"),
"msg: {msg}"
);
}
#[test]
fn test_error_chain_message_has_no_duplicate_source() {
let err = WebUIError::Parse {
context: "Failed to parse index.html".to_string(),
source: webui_parser::ParserError::Directive("Invalid for each: x".to_string()),
};
assert_eq!(err.to_string(), "Failed to parse index.html");
assert_eq!(
err.chain_message(),
"Failed to parse index.html: Directive error: Invalid for each: x"
);
}
#[test]
fn test_build_with_directives() {
let html = r#"<h1>Hello</h1>
<for each="item in items">
<p>{{item.name}}</p>
</for>
<if condition="show">
<p>Visible</p>
</if>"#;
let app = create_app_dir(&[("index.html", html)]);
let result = build(default_options(app.path())).unwrap();
let index = &result.protocol.fragments["index.html"].fragments;
assert!(index
.iter()
.any(|f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(_)))));
assert!(index
.iter()
.any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
}
#[test]
fn named_for_builds_recursive_component_assets_and_styles() {
for (css, expected_tokens) in [
(":root { --accent: red; }", &[][..]),
("tree-view { --accent: red; }", &["accent"][..]),
] {
let app = create_app_dir(&[
("index.html", "<tree-view></tree-view>"),
(
"tree-view.html",
r#"<for id="tree-item" each="child in items"><span>{{child.name}}</span><token-leaf></token-leaf><for id="tree-item" each="child in child.children" /></for>"#,
),
("tree-view.css", css),
("token-leaf.html", "<b>leaf</b>"),
("token-leaf.css", "b { color: var(--accent); }"),
]);
let result = build(BuildOptions {
plugin: Some(Plugin::WebUI),
css_bundle: true,
component_asset_roots: vec!["tree-view".to_string()],
..light_options(app.path())
})
.unwrap();
assert_eq!(result.protocol.tokens, expected_tokens, "{css}");
assert!(!result.component_asset_files.is_empty());
assert_eq!(
result.protocol.style_closures["tree-view"].component_tags,
["tree-view", "token-leaf"]
);
let meta: serde_json::Value =
serde_json::from_str(&result.protocol.components["tree-view"].template_json)
.unwrap();
let blocks = meta["b"].as_array().unwrap();
assert_eq!(
blocks.len(),
1,
"recursive depth does not expand compiled metadata"
);
assert_eq!(meta["r"][0][2], blocks[0]["r"][0][2]);
assert!(result.protocol_bytes.len() < 16_384);
}
}
#[test]
fn test_build_with_component_css() {
let app = create_app_dir(&[
("index.html", "<my-card>Hello</my-card>"),
(
"my-card.html",
r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
),
("my-card.css", ".card { color: red; }"),
("my-card.ts", "export {};"),
]);
let result = build(default_options(app.path())).unwrap();
assert_eq!(result.css_files.len(), 1);
assert_eq!(result.css_files[0].0, "my-card.css");
assert!(result.css_files[0].1.contains("color: red"));
assert_eq!(result.stats.css_file_count, 1);
}
#[test]
fn light_css_bytes_align_across_link_style_and_module() {
let authored = " \n.ready{color:red}\n ";
let expected = authored;
for strategy in [CssStrategy::Link, CssStrategy::Style, CssStrategy::Module] {
let app = create_app_dir(&[
("index.html", "<my-card></my-card>"),
("my-card.html", "<p>content</p>"),
("my-card.css", authored),
]);
let mut options = light_options(app.path());
options.css = strategy;
if strategy == CssStrategy::Link {
options.css_file_name_template = "[name]-[hash].[ext]".to_string();
}
let result = build(options).unwrap();
let actual = match strategy {
CssStrategy::Link => {
let expected_name = webui_parser::CssLinkOptions::try_new(
"[name]-[hash].[ext]".to_string(),
None,
)
.unwrap()
.resolve("my-card", expected)
.filename;
assert_eq!(result.css_files[0].0, expected_name);
result.css_files[0].1.clone()
}
CssStrategy::Module => result.protocol.components["my-card"].css.clone(),
CssStrategy::Style => {
assert_eq!(result.protocol.components["my-card"].css, expected);
result.protocol.components["my-card"].css.clone()
}
};
assert_eq!(actual, expected, "mismatch for {strategy:?}");
if strategy != CssStrategy::Link {
assert_eq!(
result.protocol.component_style_resource("my-card"),
Some(expected)
);
}
assert_eq!(
result
.protocol
.style_closure("index.html")
.expect("entry closure"),
["my-card"],
"Light CSS must be installed by the Document closure"
);
let protocol = Protocol::new(result.protocol.clone());
let mut writer = StringWriter { buf: String::new() };
WebUIHandler::new()
.render(
&protocol,
&serde_json::json!({}),
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
match strategy {
CssStrategy::Link => {
let href = &result.protocol.components["my-card"].css_href;
assert!(writer.buf.contains(&format!(
r#"<link rel="stylesheet" href="{href}" data-webui-resource="my-card" data-webui-strategy="link">"#
)));
}
CssStrategy::Style | CssStrategy::Module => {
let kind = if strategy == CssStrategy::Module {
"module"
} else {
"style"
};
assert!(writer.buf.contains(&format!(
r#"<style data-webui-resource="my-card" data-webui-strategy="{kind}">{expected}</style>"#
)));
}
}
}
}
#[test]
fn build_precomputes_style_closures_with_authored_shadow_cut() {
let app = create_app_dir(&[
(
"index.html",
"<light-card></light-card><shadow-card><after-card></after-card></shadow-card>",
),
("light-card.html", "<nested-card></nested-card>"),
("light-card.css", ".light{}"),
(
"shadow-card.html",
r#"<template shadowrootmode="open"><nested-card></nested-card></template>"#,
),
("shadow-card.css", ".shadow{}"),
("nested-card.html", "<span>nested</span>"),
("nested-card.css", ".nested{}"),
("after-card.html", "<span>after</span>"),
("after-card.css", ".after{}"),
]);
let mut options = light_options(app.path());
options.css = CssStrategy::Style;
let result = build(options).unwrap();
assert_eq!(
result
.protocol
.style_closure("index.html")
.expect("entry closure"),
["light-card", "nested-card", "after-card"]
);
assert_eq!(
result
.protocol
.style_closure("shadow-card")
.expect("Shadow closure"),
["shadow-card", "nested-card"]
);
}
fn bundling_app() -> tempfile::TempDir {
create_app_dir(&[
(
"index.html",
"<a-card></a-card><b-card></b-card><shared-card></shared-card>\
<shadow-card></shadow-card>",
),
("a-card.html", "<span>a</span>"),
("a-card.css", ".a{color:red}"),
("b-card.html", "<span>b</span>"),
("b-card.css", ".b{color:green}"),
("shared-card.html", "<span>shared</span>"),
("shared-card.css", ".shared{color:blue}"),
(
"shadow-card.html",
r#"<template shadowrootmode="open"><shared-card></shared-card></template>"#,
),
("shadow-card.css", ".shadow{color:black}"),
])
}
fn closure_css(protocol: &WebUIProtocol, root: &str) -> (String, String) {
let closure = protocol.style_closures.get(root).expect("closure");
let unbundled = closure
.component_tags
.iter()
.filter_map(|tag| protocol.components.get(tag))
.map(|component| component.css.as_str())
.collect::<Vec<_>>()
.join("\n");
let bundled = closure
.style_chunks
.iter()
.filter_map(|index| protocol.style_chunk_resource(*index))
.map(|(_, css)| css)
.collect::<Vec<_>>()
.join("\n");
(unbundled, bundled)
}
#[test]
fn bundling_preserves_the_cascade_of_every_closure() {
let app = bundling_app();
let mut options = light_options(app.path());
options.css = CssStrategy::Style;
options.css_bundle = true;
let result = build(options).unwrap();
let roots: Vec<String> = result.protocol.style_closures.keys().cloned().collect();
assert!(roots.len() >= 2, "fixture must produce several CSS trees");
let mut chunked = 0usize;
for root in roots {
let (unbundled, bundled) = closure_css(&result.protocol, &root);
if bundled.is_empty() && !unbundled.is_empty() {
continue;
}
chunked += 1;
assert_eq!(
bundled, unbundled,
"chunked cascade diverged from the per-component cascade for `{root}`"
);
}
assert!(chunked >= 2, "fixture must chunk several CSS trees");
}
#[test]
fn bundling_merges_entry_local_styles_and_splits_shared_ones() {
let app = bundling_app();
let mut options = light_options(app.path());
options.css = CssStrategy::Style;
options.css_bundle = true;
let result = build(options).unwrap();
let members: Vec<&[String]> = result
.protocol
.style_chunks
.iter()
.map(|chunk| chunk.component_tags.as_slice())
.collect();
assert!(
members.contains(&["a-card".to_string(), "b-card".to_string()].as_slice()),
"entry-local components should merge: {members:?}"
);
assert!(
members.contains(&["shared-card".to_string()].as_slice()),
"a component reached from two CSS trees must stay alone: {members:?}"
);
let entry = result.protocol.style_closure_chunks("index.html").unwrap();
let shadow = result.protocol.style_closure_chunks("shadow-card").unwrap();
let shared = entry
.iter()
.find(|index| shadow.contains(index))
.expect("both trees should share a chunk");
assert_eq!(
result.protocol.style_chunk_members(*shared).unwrap(),
["shared-card"]
);
}
#[test]
fn bundling_is_off_by_default_and_leaves_the_protocol_unchanged() {
let app = bundling_app();
let mut options = default_options(app.path());
options.css = CssStrategy::Style;
let result = build(options).unwrap();
assert!(result.protocol.style_chunks.is_empty());
for closure in result.protocol.style_closures.values() {
assert!(closure.style_chunks.is_empty());
}
}
#[test]
fn bundling_link_emits_chunks_and_component_fallback_files() {
let app = bundling_app();
let mut options = light_options(app.path());
options.css_bundle = true;
let result = build(options).unwrap();
let names: Vec<&str> = result
.css_files
.iter()
.map(|(name, _)| name.as_str())
.collect();
for (tag, component) in &result.protocol.components {
if component.css_href.is_empty() {
continue;
}
assert!(
names.contains(&component.css_href.as_str()),
"`{tag}` needs its independently loaded fallback file: {names:?}"
);
}
for chunk in &result.protocol.style_chunks {
assert!(!chunk.css_href.is_empty(), "Link chunks need an href");
assert!(
names.contains(&chunk.css_href.as_str()),
"every rendered chunk needs an emitted file: {names:?}"
);
assert!(
chunk.css.is_empty(),
"Link chunks ship their CSS as a file, not inline"
);
}
}
#[test]
fn bundling_keeps_component_files_when_component_assets_need_them() {
let app = create_app_dir(&[
(
"index.html",
"<a-card></a-card><b-card></b-card><shared-card></shared-card>",
),
("a-card.html", "<span>a</span>"),
("a-card.css", ".a{color:red}"),
("b-card.html", "<span>b</span>"),
("b-card.css", ".b{color:green}"),
("shared-card.html", "<span>shared</span>"),
("shared-card.css", ".shared{color:blue}"),
("lazy-panel.html", "<a-card></a-card><p>lazy</p>"),
("lazy-panel.css", ".lazy{color:gray}"),
("lazy-panel.ts", "export {};"),
]);
let mut options = light_options(app.path());
options.plugin = Some(Plugin::WebUI);
options.css_bundle = true;
options.component_asset_roots = vec!["lazy-panel".to_string()];
let result = build(options).unwrap();
let names: Vec<&str> = result
.css_files
.iter()
.map(|(name, _)| name.as_str())
.collect();
assert!(
names.iter().any(|name| name.starts_with("lazy-panel")),
"static assets fetch a component's own stylesheet: {names:?}"
);
assert!(
names.iter().any(|name| name.starts_with("_chunk-")),
"bundled chunks are still emitted: {names:?}"
);
assert!(
result.component_asset_files[0]
.content
.contains(r#""a-card":{"kind":"link","href":"a-card.css"}"#),
"a deferred CSS tree needs the exact external component resource even when the entry Document uses a bundle: {}",
result.component_asset_files[0].content
);
}
fn render_html(protocol: &WebUIProtocol, route: &str) -> String {
let protocol = Protocol::new(protocol.clone());
let mut writer = StringWriter { buf: String::new() };
WebUIHandler::new()
.render(
&protocol,
&serde_json::json!({}),
&RenderOptions::new("index.html", route),
&mut writer,
)
.unwrap();
writer.buf
}
fn linked_stylesheets(html: &str) -> Vec<&str> {
let mut hrefs = Vec::new();
let mut rest = html;
while let Some(start) = rest.find("href=\"") {
rest = &rest[start + "href=\"".len()..];
let Some(end) = rest.find('"') else { break };
let href = &rest[..end];
if href.ends_with(".css") {
hrefs.push(href);
}
rest = &rest[end..];
}
hrefs
}
fn emitted_resources(html: &str) -> Vec<&str> {
let mut resources = Vec::new();
let mut rest = html;
while let Some(start) = rest.find("data-webui-resource=\"") {
rest = &rest[start + "data-webui-resource=\"".len()..];
match rest.find('"') {
Some(end) => {
resources.push(&rest[..end]);
rest = &rest[end..];
}
None => break,
}
}
resources
}
#[test]
fn bundled_rendering_emits_one_resource_per_chunk() {
for strategy in [CssStrategy::Link, CssStrategy::Style] {
let app = bundling_app();
let mut options = light_options(app.path());
options.css = strategy;
options.css_bundle = true;
let result = build(options).unwrap();
let html = render_html(&result.protocol, "/");
let resources = emitted_resources(&html);
assert!(
resources.contains(&"_chunk-a-card-2"),
"{strategy:?} should deliver the merged chunk once: {resources:?}"
);
assert!(
!resources.contains(&"a-card") && !resources.contains(&"b-card"),
"{strategy:?} must not also deliver merged members: {resources:?}"
);
}
}
#[test]
fn bundling_never_merges_an_authored_shadow_stylesheet() {
let app = create_app_dir(&[
(
"index.html",
"<a-card></a-card><shadow-card></shadow-card><b-card></b-card>",
),
("a-card.html", "<span>a</span>"),
("a-card.css", ".a{color:red}"),
("b-card.html", "<span>b</span>"),
("b-card.css", ".b{color:green}"),
(
"shadow-card.html",
r#"<template shadowrootmode="open"><span>s</span></template>"#,
),
("shadow-card.css", ".shadow{color:black}"),
]);
let mut options = light_options(app.path());
options.css = CssStrategy::Style;
options.css_bundle = true;
let result = build(options).unwrap();
let chunk = result
.protocol
.style_chunks
.iter()
.find(|chunk| chunk.component_tags.iter().any(|tag| tag == "shadow-card"))
.expect("shadow-card is styled, so it belongs to a chunk");
assert_eq!(
chunk.component_tags,
vec!["shadow-card".to_string()],
"an authored-Shadow stylesheet must stay in a chunk of its own"
);
assert_eq!(chunk.name, "shadow-card");
assert_eq!(
chunk.css, result.protocol.components["shadow-card"].css,
"its chunk must carry the component's own bytes unchanged"
);
}
#[test]
fn bundling_never_links_a_stylesheet_the_build_did_not_write() {
let app = bundling_app();
let mut options = light_options(app.path());
options.css = CssStrategy::Link;
options.css_bundle = true;
let result = build(options).unwrap();
let emitted: HashSet<&str> = result
.css_files
.iter()
.map(|(name, _)| name.as_str())
.collect();
let html = render_html(&result.protocol, "/");
let linked = linked_stylesheets(&html);
assert!(
!linked.is_empty(),
"the page should link at least one stylesheet: {html}"
);
for href in &linked {
let name = href.rsplit('/').next().unwrap_or(href);
assert!(
emitted.contains(name),
"page links `{href}`, which the bundled build did not write: {emitted:?}"
);
}
}
#[test]
fn bundling_is_rejected_for_the_module_strategy() {
let app = bundling_app();
let mut options = light_options(app.path());
options.css = CssStrategy::Module;
options.css_bundle = true;
let Err(WebUIError::InvalidBuildOptions(message)) = build(options) else {
panic!("Module builds resolve import-map specifiers per component");
};
assert!(
message.contains("--css link"),
"the error should name a strategy that works: {message}"
);
}
#[test]
fn bundled_rendering_delivers_the_same_css_in_the_same_order() {
let extract = |html: &str| -> Vec<String> {
let mut blocks = Vec::new();
let mut rest = html;
while let Some(start) = rest.find("data-webui-strategy=\"style\">") {
rest = &rest[start + "data-webui-strategy=\"style\">".len()..];
match rest.find("</style>") {
Some(end) => {
blocks.push(rest[..end].to_string());
rest = &rest[end..];
}
None => break,
}
}
blocks
};
let mut plan = Vec::new();
for bundle in [false, true] {
let app = bundling_app();
let mut options = light_options(app.path());
options.css = CssStrategy::Style;
options.css_bundle = bundle;
let result = build(options).unwrap();
let html = render_html(&result.protocol, "/");
plan.push(extract(&html).join("\n"));
}
assert_eq!(
plan[1], plan[0],
"bundling must not change the delivered cascade"
);
assert!(!plan[0].is_empty(), "fixture must deliver CSS");
}
#[test]
fn bundled_rendering_never_repeats_a_chunk_across_css_trees() {
let app = bundling_app();
let mut options = light_options(app.path());
options.css = CssStrategy::Style;
options.css_bundle = true;
let result = build(options).unwrap();
let html = render_html(&result.protocol, "/");
let resources = emitted_resources(&html);
let mut counts: HashMap<&str, usize> = HashMap::new();
for resource in &resources {
*counts.entry(*resource).or_insert(0) += 1;
}
let shadow_start = html
.find(r#"<template shadowrootmode="open""#)
.expect("declarative Shadow root");
let document_resources = emitted_resources(&html[..shadow_start]);
let mut document_counts: HashMap<&str, usize> = HashMap::new();
for resource in &document_resources {
*document_counts.entry(*resource).or_insert(0) += 1;
}
for (resource, count) in &document_counts {
assert_eq!(
*count, 1,
"`{resource}` was delivered {count} times in the document tree: {resources:?}"
);
}
}
#[test]
fn authored_shadow_css_bytes_are_not_lowered_or_trimmed() {
let authored =
" \n:host(.ready){color:red}:host-context(body){color:blue}::slotted(*){color:green}\n ";
for strategy in [CssStrategy::Link, CssStrategy::Style, CssStrategy::Module] {
let app = create_app_dir(&[
("index.html", "<my-card></my-card>"),
(
"my-card.html",
r#"<template shadowrootmode="open"><p>content</p></template>"#,
),
("my-card.css", authored),
]);
let mut options = default_options(app.path());
options.css = strategy;
let result = build(options).unwrap();
match strategy {
CssStrategy::Link => assert_eq!(result.css_files[0].1, authored),
CssStrategy::Style | CssStrategy::Module => {
assert_eq!(result.protocol.components["my-card"].css, authored);
assert_eq!(
result.protocol.component_style_resource("my-card"),
Some(authored)
);
}
}
assert_eq!(
result
.protocol
.style_closure("my-card")
.expect("component closure"),
["my-card"],
"the authored Shadow root owns its exact CSS resource"
);
let protocol = Protocol::new(result.protocol.clone());
let mut writer = StringWriter { buf: String::new() };
WebUIHandler::new()
.render(
&protocol,
&serde_json::json!({}),
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
match strategy {
CssStrategy::Link => {
let href = &result.protocol.components["my-card"].css_href;
assert!(writer.buf.contains(&format!(
r#"<template shadowrootmode="open"><link rel="stylesheet" href="{href}" data-webui-resource="my-card" data-webui-strategy="link">"#
)));
}
CssStrategy::Style | CssStrategy::Module => {
let kind = if strategy == CssStrategy::Module {
"module"
} else {
"style"
};
assert!(writer.buf.contains(&format!(
r#"<style data-webui-resource="my-card" data-webui-strategy="{kind}">{authored}</style>"#
)));
let template_start = writer
.buf
.find(r#"<template shadowrootmode="open""#)
.expect("declarative Shadow root");
let style_start = writer
.buf
.find(&format!(
r#"<style data-webui-resource="my-card" data-webui-strategy="{kind}">"#
))
.expect("tree-local style");
let content_start = writer.buf.find("<p>content</p>").expect("component body");
assert!(template_start < style_start && style_start < content_start);
}
}
}
}
#[test]
fn test_build_strips_non_legal_css_comments_from_output_file() {
let app = create_app_dir(&[
("index.html", "<my-card>Hello</my-card>"),
(
"my-card.html",
r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
),
(
"my-card.css",
"/* remove var(--ignored) */ .card { color: var(--textColor); }",
),
]);
let result = build(default_options(app.path())).unwrap();
assert_eq!(result.css_files[0].1, " .card { color: var(--textColor); }");
assert_eq!(result.protocol.tokens, vec!["textColor"]);
}
#[test]
fn test_build_preserves_legal_css_comments_by_default() {
let app = create_app_dir(&[
("index.html", "<my-card>Hello</my-card>"),
(
"my-card.html",
r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
),
(
"my-card.css",
"/*! @license MIT */ .card { color: red; } /* remove */",
),
]);
let result = build(default_options(app.path())).unwrap();
assert_eq!(
result.css_files[0].1,
"/*! @license MIT */ .card { color: red; } "
);
}
#[test]
fn test_build_legal_comments_none_strips_legal_css_comments() {
let app = create_app_dir(&[
("index.html", "<my-card>Hello</my-card>"),
(
"my-card.html",
r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
),
("my-card.css", "/*! @license MIT */ .card { color: red; }"),
]);
let mut options = default_options(app.path());
options.legal_comments = LegalComments::None;
let result = build(options).unwrap();
assert_eq!(result.css_files[0].1, " .card { color: red; }");
}
#[test]
fn test_build_with_css_hashed_filename_template() {
let app = create_app_dir(&[
("index.html", "<my-card>Hello</my-card>"),
(
"my-card.html",
r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
),
("my-card.css", ".card { color: red; }"),
]);
let mut options = default_options(app.path());
options.css_file_name_template = "[name]-[hash].[ext]".to_string();
let result = build(options).unwrap();
assert_eq!(result.css_files.len(), 1);
let generated = &result.css_files[0].0;
assert!(generated.starts_with("my-card-"));
assert!(generated.ends_with(".css"));
assert_eq!(generated.len(), "my-card-".len() + 8 + ".css".len());
assert_eq!(
result
.protocol
.components
.get("my-card")
.map(|c| c.css_href.as_str()),
Some(generated.as_str())
);
}
#[test]
fn test_css_public_base_prefixes_css_href_only() {
let app = create_app_dir(&[
("index.html", "<my-card>Hello</my-card>"),
(
"my-card.html",
r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
),
("my-card.css", ".card { color: red; }"),
]);
let mut options = default_options(app.path());
options.css_file_name_template = "[name]-[hash].[ext]".to_string();
options.css_public_base = Some("https://cdn.example.com/assets".to_string());
let result = build(options).unwrap();
let filename = &result.css_files[0].0;
assert!(filename.starts_with("my-card-"));
let expected_href = format!("https://cdn.example.com/assets/{filename}");
assert_eq!(
result
.protocol
.components
.get("my-card")
.map(|c| c.css_href.as_str()),
Some(expected_href.as_str())
);
}
#[test]
fn test_css_public_base_emits_protocol_handler_and_payload_resources() {
let app = create_app_dir(&[
(
"index.html",
"<html><head></head><body><my-card>Hello</my-card></body></html>",
),
(
"my-card.html",
r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
),
("my-card.css", ".card { color: red; }"),
]);
let mut options = default_options(app.path());
options.css_file_name_template = "[name]-[hash].[ext]".to_string();
options.css_public_base = Some("https://cdn.example.com/assets".to_string());
let result = build(options).unwrap();
let filename = &result.css_files[0].0;
let expected_href = format!("https://cdn.example.com/assets/{filename}");
assert_eq!(
result.protocol.component_style_resource("my-card"),
Some(expected_href.as_str())
);
assert_eq!(
result
.protocol
.style_closure("my-card")
.expect("component closure"),
["my-card"]
);
let handler = WebUIHandler::new();
let protocol = Protocol::new(result.protocol.clone());
let mut writer = StringWriter { buf: String::new() };
handler
.render(
&protocol,
&serde_json::json!({}),
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert!(
writer.buf.contains(&format!(
r#"<link rel="stylesheet" href="{}" data-webui-resource="my-card" data-webui-strategy="link">"#,
expected_href.replace('/', "/")
)),
"handler Shadow-root resource should use CDN href: {}",
writer.buf
);
let payload = protocol
.render_component_templates(&["my-card"], "")
.expect("component resource payload");
assert_eq!(
payload["componentStyles"]["resources"]["my-card"]["href"],
expected_href
);
}
#[test]
fn test_css_public_base_keeps_webui_template_style_free() {
let app = create_app_dir(&[
("index.html", "<my-card>Hello</my-card>"),
(
"my-card.html",
r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
),
("my-card.css", ".card { color: red; }"),
("my-card.ts", "export {};"),
]);
let mut options = default_options(app.path());
options.plugin = Some(Plugin::WebUI);
options.css_file_name_template = "[name]-[hash].[ext]".to_string();
options.css_public_base = Some("https://cdn.example.com/assets".to_string());
let result = build(options).unwrap();
let filename = &result.css_files[0].0;
let expected_href = format!("https://cdn.example.com/assets/{filename}");
let template = &result.protocol.components["my-card"].template_json;
assert_eq!(
result.protocol.component_style_resource("my-card"),
Some(expected_href.as_str())
);
assert!(
!template.contains(&expected_href),
"WebUI templates must not duplicate handler-owned CSS links: {template}"
);
}
#[test]
fn test_invalid_css_template_is_rejected() {
let app = create_app_dir(&[
("index.html", "<my-card>Hello</my-card>"),
(
"my-card.html",
r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
),
("my-card.css", ".card { color: red; }"),
]);
let mut options = default_options(app.path());
options.css_file_name_template = "[name]-[bogus].[ext]".to_string();
let result = build(options);
assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
}
#[test]
fn test_css_filename_collision_is_rejected() {
let app = create_app_dir(&[
("index.html", "<card-a>A</card-a><card-b>B</card-b>"),
(
"card-a.html",
r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
),
("card-a.css", ".x { color: red; }"),
(
"card-b.html",
r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
),
("card-b.css", ".x { color: red; }"),
]);
let mut options = default_options(app.path());
options.css_file_name_template = "[hash].[ext]".to_string();
let result = build(options);
assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
}
#[test]
fn test_build_returns_static_component_asset_files() {
let app = create_app_dir(&[
("index.html", "<app-shell></app-shell>"),
("app-shell.html", "<div></div>"),
(
"lazy-panel.html",
r#"<if condition="ready"><p>{{title}}</p></if>"#,
),
("lazy-panel.ts", "export {};"),
]);
let mut options = default_options(app.path());
options.plugin = Some(Plugin::WebUI);
options.component_asset_roots = vec!["lazy-panel".to_string()];
let result = build(options).unwrap();
assert_eq!(result.component_asset_files.len(), 2);
assert_eq!(result.component_asset_files[0].name, "lazy-panel.webui.js");
assert!(result.component_asset_files[0]
.content
.contains(r#""type":"webui-component-asset""#));
assert!(result.component_asset_files[0]
.content
.contains(r#""version":4"#));
assert!(result.component_asset_files[0]
.content
.contains(r#""root":"lazy-panel""#));
assert!(result.component_asset_files[1]
.content
.contains(r#""templateFunctions":{"lazy-panel":"#));
let root = &result.component_asset_files[0].content;
let payload = &result.component_asset_files[1].content;
for field in ["kind", "components", "requiredComponents"] {
assert!(!root.contains(&format!("\"{field}\":")));
assert!(!payload.contains(&format!("\"{field}\":")));
}
for field in ["type", "version\":4", "externalComponents", "imports"] {
assert!(!payload.contains(&format!("\"{field}")));
}
assert!(!root.contains("\"templates\":"));
assert!(!result.protocol.fragments.contains_key("lazy-panel"));
assert!(
!result
.protocol
.fragments
.keys()
.any(|key| key.starts_with("__webui_asset_root_")),
"synthetic asset root fragments must not be serialized"
);
}
#[test]
fn component_assets_emit_enhanced_style_catalog_for_all_strategies() {
for (strategy, kind) in [
(CssStrategy::Link, "link"),
(CssStrategy::Style, "style"),
(CssStrategy::Module, "module"),
] {
let app = create_app_dir(&[
("index.html", "<app-shell></app-shell>"),
("app-shell.html", "<p>Entry</p>"),
("lazy-panel.html", "<p>Lazy</p>"),
("lazy-panel.css", ".lazy{color:red}"),
]);
let mut options = default_options(app.path());
options.plugin = Some(Plugin::WebUI);
options.css = strategy;
options.component_asset_roots = vec!["lazy-panel".to_string()];
let result = build(options).unwrap();
let root = &result.component_asset_files[0].content;
let component = &result.component_asset_files[1].content;
assert!(root.contains(r#""version":4"#));
assert!(component.contains(&format!(
r#""componentStyles":{{"version":1,"strategy":"{kind}""#
)));
assert!(component.contains(&format!(r#""lazy-panel":{{"kind":"{kind}""#)));
assert!(component.contains(r#""closures":{"lazy-panel":["lazy-panel"]}"#));
if strategy == CssStrategy::Module {
assert!(component
.contains(r#""lazy-panel":{"kind":"module","specifier":"lazy-panel","css":"#,));
}
if strategy == CssStrategy::Link {
assert!(component.contains(r#""href":"lazy-panel.css""#));
assert!(!component.contains("import.meta.url"));
}
}
}
#[test]
fn test_build_emits_semantic_component_modules_and_keeps_them_out_of_protocol() {
let app = create_app_dir(&[
("index.html", "<app-shell></app-shell>"),
("app-shell.html", "<entry-badge></entry-badge>"),
("entry-badge.html", "<span>Entry</span>"),
("entry-badge.css", ":host { display: inline-block; }"),
(
"lazy-panel.html",
"<entry-badge></entry-badge><shared-detail></shared-detail><panel-only></panel-only>",
),
(
"secondary-panel.html",
"<entry-badge></entry-badge><shared-detail></shared-detail><secondary-only></secondary-only>",
),
("shared-detail.html", "<p>Shared</p>"),
("shared-detail.css", ":host { display: block; }"),
("lazy-panel.css", ":host { color: green; }"),
("secondary-panel.css", ":host { color: blue; }"),
("panel-only.html", "<p>Panel</p>"),
("secondary-only.html", "<p>Secondary</p>"),
]);
let mut options = default_options(app.path());
options.plugin = Some(Plugin::WebUI);
options.component_asset_roots =
vec!["lazy-panel".to_string(), "secondary-panel".to_string()];
let result = build(options).unwrap();
for tag in [
"lazy-panel",
"secondary-panel",
"shared-detail",
"panel-only",
"secondary-only",
] {
assert!(
!result.protocol.fragments.contains_key(tag),
"asset-only fragment <{tag}> must not be serialized"
);
assert!(
!result.protocol.components.contains_key(tag),
"asset-only component <{tag}> must not be serialized"
);
}
assert!(result.protocol.fragments.contains_key("app-shell"));
assert!(result.protocol.components.contains_key("entry-badge"));
assert_eq!(
result.protocol.component_asset_style_preloads,
vec![
webui_protocol::ComponentAssetStylePreload {
root: "lazy-panel".to_string(),
style_hrefs: vec![
"lazy-panel.css".to_string(),
"shared-detail.css".to_string(),
],
},
webui_protocol::ComponentAssetStylePreload {
root: "secondary-panel".to_string(),
style_hrefs: vec![
"secondary-panel.css".to_string(),
"shared-detail.css".to_string(),
],
},
]
);
assert_eq!(result.component_asset_files.len(), 7);
assert_eq!(result.component_asset_files[0].name, "lazy-panel.webui.js");
assert_eq!(
result.component_asset_files[1].name,
"secondary-panel.webui.js"
);
let lazy = &result.component_asset_files[0].content;
assert!(lazy.contains(r#""version":4"#));
assert!(lazy.contains(r#""root":"lazy-panel""#));
assert!(lazy.contains(r#""externalComponents":["entry-badge"]"#));
assert!(lazy.contains(r#""entry-badge":{"kind":"link","href":"entry-badge.css"}"#));
assert!(!lazy.contains(
r#""entry-badge":{"kind":"link","href":new URL("entry-badge.css",import.meta.url).href}"#
));
for tag in ["lazy-panel", "panel-only", "shared-detail"] {
let payload = component_payload(&result.component_asset_files, tag);
assert!(lazy.contains(&payload.name));
}
assert!(!lazy.contains("@microsoft/webui-framework/component-asset-runtime.js"));
assert!(!lazy.contains("export const preload"));
assert!(!lazy.contains("export const create"));
assert!(!lazy.contains(r#""templates":{"entry-badge":"#));
assert!(!lazy.contains(r#""templates":{"shared-detail":"#));
let shared = component_payload(&result.component_asset_files, "shared-detail");
assert!(!shared.content.contains(r#""kind":"component""#));
assert!(shared.content.contains(r#""templates":{"shared-detail":"#));
assert!(shared.content.contains(r#""href":"shared-detail.css""#));
assert!(!shared.content.contains("import.meta.url"));
}
#[test]
fn build_to_disk_creates_component_asset_parent_directories() {
let app = create_app_dir(&[
("index.html", "<app-shell></app-shell>"),
("app-shell.html", "<p>Entry</p>"),
("lazy-panel.html", "<p>Lazy</p>"),
]);
let out = tempfile::tempdir().unwrap();
let mut options = default_options(app.path());
options.plugin = Some(Plugin::WebUI);
options.component_asset_roots = vec!["lazy-panel".to_string()];
build_to_disk(options, out.path()).unwrap();
assert!(out.path().join("lazy-panel.webui.js").is_file());
assert!(fs::read_dir(out.path().join("components"))
.unwrap()
.any(|entry| entry
.unwrap()
.file_name()
.to_string_lossy()
.starts_with("lazy-panel.")));
}
#[test]
fn build_to_disk_preserves_noop_inputs_and_leaves_older_payloads() {
let app = create_app_dir(&[
("index.html", "<main>Entry</main>"),
("lazy-panel.html", "<p>Initial</p>"),
]);
let out = tempfile::tempdir().unwrap();
let options = || {
let mut options = default_options(app.path());
options.plugin = Some(Plugin::WebUI);
options.component_asset_roots = vec!["lazy-panel".to_string()];
options
};
build_to_disk(options(), out.path()).unwrap();
let root = out.path().join("lazy-panel.webui.js");
let before = fs::metadata(&root).unwrap().modified().unwrap();
build_to_disk(options(), out.path()).unwrap();
assert_eq!(before, fs::metadata(&root).unwrap().modified().unwrap());
for generation in 0..5 {
fs::write(
app.path().join("lazy-panel.html"),
format!("<p>{generation}</p>"),
)
.unwrap();
build_to_disk(options(), out.path()).unwrap();
}
assert_eq!(
fs::read_dir(out.path().join("components")).unwrap().count(),
6
);
}
#[test]
fn test_component_asset_graph_emits_each_component_once() {
let app = create_app_dir(&[
("index.html", "<app-shell></app-shell>"),
("app-shell.html", "<p>Entry</p>"),
(
"root-a.html",
"<shared-ab></shared-ab><shared-all></shared-all><only-a></only-a>",
),
(
"root-b.html",
"<shared-ab></shared-ab><shared-all></shared-all><only-b></only-b>",
),
("root-c.html", "<shared-all></shared-all><only-c></only-c>"),
("shared-ab.html", "<p>AB</p>"),
("shared-all.html", "<p>All</p>"),
("only-a.html", "<p>A</p>"),
("only-b.html", "<p>B</p>"),
("only-c.html", "<p>C</p>"),
]);
let mut options = default_options(app.path());
options.plugin = Some(Plugin::WebUI);
options.component_asset_roots = vec![
"root-a".to_string(),
"root-b".to_string(),
"root-c".to_string(),
];
let result = build(options).unwrap();
assert_eq!(result.component_asset_files.len(), 11);
assert_eq!(result.component_asset_files[0].name, "root-a.webui.js");
assert_eq!(result.component_asset_files[1].name, "root-b.webui.js");
assert_eq!(result.component_asset_files[2].name, "root-c.webui.js");
let only_a = component_payload(&result.component_asset_files, "only-a");
assert!(result.component_asset_files[0]
.content
.contains(&only_a.name));
assert!(
component_payload(&result.component_asset_files, "shared-ab")
.content
.contains(r#""templates":{"shared-ab":"#)
);
assert!(
component_payload(&result.component_asset_files, "shared-all")
.content
.contains(r#""templates":{"shared-all":"#)
);
}
#[test]
fn test_component_asset_graph_is_root_order_independent_with_stable_imports() {
let app = create_app_dir(&[
("index.html", "<app-shell></app-shell>"),
("app-shell.html", "<p>Entry</p>"),
("root-a.html", "<shared-detail></shared-detail>"),
("root-b.html", "<shared-detail></shared-detail>"),
("shared-detail.html", "<p>Shared</p>"),
]);
let build_files = |roots: Vec<String>| {
let mut options = default_options(app.path());
options.plugin = Some(Plugin::WebUI);
options.component_asset_roots = roots;
options.css_file_name_template = "[name]-[hash].[ext]".to_string();
build(options).unwrap().component_asset_files
};
let forward = build_files(vec!["root-a".to_string(), "root-b".to_string()]);
let reverse = build_files(vec!["root-b".to_string(), "root-a".to_string()]);
assert_eq!(forward, reverse);
let shared_name = &component_payload(&forward, "shared-detail").name;
assert!(forward[0].content.contains(shared_name));
assert!(forward[1].content.contains(shared_name));
}
#[test]
fn test_component_asset_style_preloads_use_final_css_hrefs() {
let app = create_app_dir(&[
("index.html", "<app-shell></app-shell>"),
("app-shell.html", "<p>Entry</p>"),
("lazy-panel.html", "<p>Lazy</p>"),
("lazy-panel.css", ":host { display: block; }"),
]);
let mut options = default_options(app.path());
options.plugin = Some(Plugin::WebUI);
options.component_asset_roots = vec!["lazy-panel".to_string()];
options.css_file_name_template = "[name]-[hash].[ext]".to_string();
options.css_public_base = Some("https://cdn.example.com/webui".to_string());
let result = build(options).unwrap();
let preload = &result.protocol.component_asset_style_preloads[0];
assert_eq!(preload.root, "lazy-panel");
assert_eq!(preload.style_hrefs.len(), 1);
assert!(preload.style_hrefs[0].starts_with("https://cdn.example.com/webui/lazy-panel-"));
assert!(preload.style_hrefs[0].ends_with(".css"));
}
#[test]
fn test_light_component_asset_styles_are_retained_for_document_links() {
let app = create_app_dir(&[
("index.html", "<app-shell></app-shell>"),
("app-shell.html", "<p>Entry</p>"),
("z-lazy-panel.html", "<a-lazy-child></a-lazy-child>"),
("z-lazy-panel.css", ".z-lazy-panel { display: block; }"),
("a-lazy-child.html", "<p>Child</p>"),
("a-lazy-child.css", ".a-lazy-child { color: green; }"),
]);
let mut options = default_options(app.path());
options.plugin = Some(Plugin::WebUI);
options.dom = DomStrategy::Light;
options.component_asset_roots = vec!["z-lazy-panel".to_string()];
let result = build(options).unwrap();
assert_eq!(
result.protocol.component_asset_style_preloads,
vec![webui_protocol::ComponentAssetStylePreload {
root: "z-lazy-panel".to_string(),
style_hrefs: vec![
"z-lazy-panel.css".to_string(),
"a-lazy-child.css".to_string(),
],
}]
);
}
#[test]
fn test_metafile_describes_static_import_graph() {
let app = create_app_dir(&[
("index.html", "<app-shell></app-shell>"),
("app-shell.html", "<p>Entry</p>"),
("root-a.html", "<shared-detail></shared-detail>"),
("root-b.html", "<shared-detail></shared-detail>"),
("shared-detail.html", "<p>Shared</p>"),
]);
let mut options = default_options(app.path());
options.plugin = Some(Plugin::WebUI);
options.component_asset_roots = vec!["root-a".to_string(), "root-b".to_string()];
options.metafile = true;
let result = build(options).unwrap();
let metafile = result.metafile.as_deref().unwrap();
let value: serde_json::Value = serde_json::from_str(metafile).unwrap();
assert!(value["inputs"]
.get("webui:component/shared-detail")
.is_some());
assert_eq!(
value["outputs"]["root-a.webui.js"]["entryPoint"],
"webui:component/root-a"
);
let shared = component_payload(&result.component_asset_files, "shared-detail");
assert_eq!(
value["outputs"]["root-a.webui.js"]["imports"][1]["path"],
shared.name
);
assert_eq!(
value["outputs"]["root-a.webui.js"]["imports"][1]["kind"],
"import-statement"
);
assert_eq!(
value["inputs"]["webui:component/root-a"]["imports"][0]["kind"],
"import-statement"
);
assert_eq!(
value["outputs"]["root-a.webui.js"]["exports"],
serde_json::json!(["default"])
);
assert_eq!(
value["outputs"][&shared.name]["bytes"],
shared.content.len()
);
}
#[test]
fn test_component_assets_reject_routes_with_stable_diagnostic() {
let app = create_app_dir(&[
(
"index.html",
r#"<route path="/" component="app-shell" exact />"#,
),
("app-shell.html", "<p>Entry</p>"),
("lazy-panel.html", "<p>Lazy</p>"),
]);
let mut options = default_options(app.path());
options.plugin = Some(Plugin::WebUI);
options.component_asset_roots = vec!["lazy-panel".to_string()];
let error = build(options).unwrap_err();
match error {
WebUIError::ComponentAssets(diagnostic) => {
assert_eq!(
diagnostic.error_code(),
Some(webui_parser::codes::COMPONENT_ASSETS_WITH_ROUTES)
);
assert!(diagnostic.help_text().is_some());
}
other => panic!("expected route diagnostic, received {other}"),
}
}
#[test]
fn test_component_asset_input_name_is_independent_from_css_template() {
let app = create_app_dir(&[
("index.html", "<app-shell></app-shell>"),
("app-shell.html", "<div></div>"),
("lazy-panel.html", "<p>Lazy</p>"),
("lazy-panel.css", ".panel { color: red; }"),
]);
let mut options = default_options(app.path());
options.plugin = Some(Plugin::WebUI);
options.component_asset_roots = vec!["lazy-panel".to_string()];
options.css_file_name_template = "[name]".to_string();
let result = build(options).unwrap();
assert!(result
.component_asset_files
.iter()
.any(|file| file.name == "lazy-panel.webui.js"));
assert!(result
.css_files
.iter()
.any(|(name, _)| name == "lazy-panel"));
}
#[test]
fn test_build_to_disk_rejects_protocol_name_collision_before_writing() {
let app = create_app_dir(&[
("index.html", "<app-shell></app-shell>"),
("app-shell.html", "<div></div>"),
("lazy-panel.html", "<p>Lazy</p>"),
("lazy-panel.css", ".panel { color: red; }"),
]);
let out = TempDir::new().unwrap();
let mut options = default_options(app.path());
options.plugin = Some(Plugin::WebUI);
options.component_asset_roots = vec!["lazy-panel".to_string()];
options.css_file_name_template = "protocol.bin".to_string();
let result = build_to_disk(options, out.path());
assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
assert!(!out.path().join("protocol.bin").exists());
}
#[test]
fn test_css_href_set_for_light_dom_link_strategy() {
let app = create_app_dir(&[
("index.html", "<has-css>A</has-css><no-css>B</no-css>"),
("has-css.html", "<p>styled</p>"),
("has-css.css", ".yes { color: green; }"),
("no-css.html", "<p>plain</p>"),
]);
let options = default_options(app.path());
let result = build(options).unwrap();
let href = result
.protocol
.components
.get("has-css")
.map(|c| c.css_href.as_str())
.unwrap_or("");
assert_eq!(
href, "has-css.css",
"Light×Link component with CSS should have css_href"
);
let no_href = result
.protocol
.components
.get("no-css")
.map(|c| c.css_href.as_str())
.unwrap_or("");
assert!(
no_href.is_empty(),
"Component without CSS should have empty css_href"
);
}
#[test]
fn test_plugin_free_build_persists_component_shadow_usage() {
let app = create_app_dir(&[
(
"index.html",
"<light-card></light-card><shadow-card></shadow-card>",
),
("light-card.html", "<p>light</p>"),
(
"shadow-card.html",
"<template shadowrootmode=\"open\"><slot></slot></template>",
),
]);
let mut options = default_options(app.path());
options.dom = DomStrategy::Light;
options.plugin = None;
let result = build(options).unwrap();
assert!(!result.protocol.component_uses_shadow_dom("light-card"));
assert!(result.protocol.component_uses_shadow_dom("shadow-card"));
assert!(!result.protocol.components["light-card"].uses_shadow_dom);
assert!(result.protocol.components["shadow-card"].uses_shadow_dom);
let entry_html: String = result.protocol.fragments["index.html"]
.fragments
.iter()
.filter_map(|fragment| match fragment.fragment.as_ref() {
Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
_ => None,
})
.collect();
assert!(entry_html.contains("<light-card>"));
assert!(entry_html.contains("<shadow-card>"));
assert!(!entry_html.contains("<shadow-card data-wl"));
}
#[test]
fn test_authored_shadow_under_light_matches_ssr_protocol_and_webui_metadata() {
let app = create_app_dir(&[
("index.html", "<shadow-card>projected</shadow-card>"),
(
"shadow-card.html",
"<!-- lead --><template shadowrootmode=\"open\"><slot></slot></template><!-- tail -->",
),
]);
let mut options = default_options(app.path());
options.plugin = Some(Plugin::WebUI);
let result = build(options).unwrap();
assert!(result.protocol.component_uses_shadow_dom("shadow-card"));
assert!(result.protocol.components["shadow-card"]
.template_json
.contains("\"sd\":1"));
let component_html: String = result.protocol.fragments["shadow-card"]
.fragments
.iter()
.filter_map(|fragment| match fragment.fragment.as_ref() {
Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
_ => None,
})
.collect();
assert_eq!(
component_html,
"<template shadowrootmode=\"open\"><slot></slot></template>"
);
let entry_html: String = result.protocol.fragments["index.html"]
.fragments
.iter()
.filter_map(|fragment| match fragment.fragment.as_ref() {
Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
_ => None,
})
.collect();
assert!(!entry_html.contains("data-wl"));
}
#[test]
fn test_shadow_dom_link_strategy_sets_css_href() {
let app = create_app_dir(&[
("index.html", "<my-card>A</my-card>"),
(
"my-card.html",
r#"<template shadowrootmode="open"><p><slot></slot></p></template>"#,
),
("my-card.css", ".card { color: red; }"),
]);
let result = build(default_options(app.path())).unwrap();
let comp = result.protocol.components.get("my-card").unwrap();
assert_eq!(
comp.css_href, "my-card.css",
"Shadow×Link should set css_href"
);
assert_eq!(
result.protocol.css_strategy(),
webui_protocol::CssStrategy::Link,
);
assert_eq!(
result.css_files.len(),
1,
"CSS file should still be produced"
);
}
#[test]
fn test_css_href_empty_for_style_strategy() {
let app = create_app_dir(&[
("index.html", "<my-card>A</my-card>"),
(
"my-card.html",
r#"<template shadowrootmode="open"><p><slot></slot></p></template>"#,
),
("my-card.css", ".card { color: red; }"),
]);
let mut options = default_options(app.path());
options.css = CssStrategy::Style;
let result = build(options).unwrap();
let href = result
.protocol
.components
.get("my-card")
.map(|c| c.css_href.as_str())
.unwrap_or("");
assert!(href.is_empty(), "Style-strategy should not set css_href");
}
#[test]
fn test_css_href_empty_for_module_strategy() {
let app = create_app_dir(&[
("index.html", "<my-card>A</my-card>"),
(
"my-card.html",
r#"<template shadowrootmode="open"><p><slot></slot></p></template>"#,
),
("my-card.css", ".card { color: red; }"),
]);
let mut options = default_options(app.path());
options.css = CssStrategy::Module;
let result = build(options).unwrap();
let href = result
.protocol
.components
.get("my-card")
.map(|c| c.css_href.as_str())
.unwrap_or("");
assert!(href.is_empty(), "Module-strategy should not set css_href");
assert!(
!result.protocol.components["my-card"].css.is_empty(),
"Module-strategy should still populate css content"
);
}
#[test]
fn test_build_to_disk_writes_files() {
let app = create_app_dir(&[
("index.html", "<my-card>Hello</my-card>"),
(
"my-card.html",
r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
),
("my-card.css", ".card { color: red; }"),
("my-card.ts", "export {};"),
]);
let out = TempDir::new().unwrap();
let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
assert!(out.path().join("protocol.bin").exists());
assert!(out.path().join("my-card.css").exists());
assert_eq!(stats.css_file_count, 1);
assert!(stats.fragment_count > 0);
}
#[test]
fn test_build_to_disk_creates_nested_output_dir() {
let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
let out = TempDir::new().unwrap();
let nested = out.path().join("nested").join("output");
let stats = build_to_disk(default_options(app.path()), &nested).unwrap();
assert!(nested.join("protocol.bin").exists());
assert!(stats.fragment_count > 0);
}
#[test]
fn test_build_missing_entry() {
let app = create_app_dir(&[("other.html", "<h1>Not index</h1>")]);
let result = build(default_options(app.path()));
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, WebUIError::Io { .. }));
}
#[test]
fn test_build_stats_populated() {
let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
let result = build(default_options(app.path())).unwrap();
assert!(result.stats.fragment_count > 0);
assert!(result.stats.protocol_size_bytes > 0);
assert_eq!(result.stats.css_file_count, 0);
}
#[test]
fn test_build_inline_css() {
let app = create_app_dir(&[
("index.html", "<my-card>Hello</my-card>"),
(
"my-card.html",
r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
),
("my-card.css", ".card { color: red; }"),
]);
let mut options = default_options(app.path());
options.css = CssStrategy::Style;
let result = build(options).unwrap();
assert!(result.css_files.is_empty());
assert_eq!(result.stats.css_file_count, 0);
assert!(result.stats.fragment_count > 0);
}
#[test]
fn test_build_module_css() {
let app = create_app_dir(&[
("index.html", "<my-card>Hello</my-card>"),
(
"my-card.html",
r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
),
("my-card.css", ".card { color: red; }"),
]);
let mut options = default_options(app.path());
options.css = CssStrategy::Module;
let result = build(options).unwrap();
assert!(result.css_files.is_empty());
assert_eq!(result.stats.css_file_count, 0);
assert!(result.stats.fragment_count > 0);
}
#[test]
fn test_build_custom_entry() {
let app = create_app_dir(&[("page.html", "<h1>Custom</h1>")]);
let mut options = default_options(app.path());
options.entry = "page.html".to_string();
let result = build(options).unwrap();
assert!(result.protocol.fragments.contains_key("page.html"));
assert!(!result.protocol.fragments.contains_key("index.html"));
}
#[test]
fn test_build_protocol_roundtrip() {
let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
let result = build(default_options(app.path())).unwrap();
let restored = WebUIProtocol::from_protobuf(&result.protocol_bytes).unwrap();
assert!(restored.fragments.contains_key("index.html"));
}
#[test]
fn protocol_is_available_from_facade() {
let protocol = Protocol::new(WebUIProtocol::new(HashMap::new()));
assert!(protocol.tokens().is_empty());
}
#[test]
fn test_inspect_bytes_valid() {
let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
let result = build(default_options(app.path())).unwrap();
let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert!(parsed.get("fragments").is_some());
}
#[test]
fn test_inspect_bytes_invalid() {
let result = inspect_bytes(b"not a protobuf");
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), WebUIError::Protocol(_)));
}
#[test]
fn test_inspect_file() {
let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
let out = TempDir::new().unwrap();
build_to_disk(default_options(app.path()), out.path()).unwrap();
let json_str = inspect(&out.path().join("protocol.bin")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert!(parsed["fragments"]["index.html"]["fragments"].is_array());
}
#[test]
fn test_inspect_missing_file() {
let result = inspect(Path::new("/nonexistent/protocol.bin"));
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), WebUIError::Io { .. }));
}
#[test]
fn test_build_with_components_local_path() {
let app = create_app_dir(&[("index.html", "<ext-card>Hello</ext-card>")]);
let ext_dir = TempDir::new().unwrap();
fs::write(
ext_dir.path().join("ext-card.html"),
r#"<template shadowrootmode="open"><div class="card"><slot></slot></div></template>"#,
)
.unwrap();
fs::write(
ext_dir.path().join("ext-card.css"),
".card { border: 1px solid #ccc; }",
)
.unwrap();
let mut options = default_options(app.path());
options.components = vec![ext_dir.path().to_string_lossy().to_string()];
let result = build(options).unwrap();
assert!(result.protocol.fragments.contains_key("index.html"));
assert_eq!(result.css_files.len(), 1);
assert!(result.css_files[0].1.contains("border"));
}
#[test]
fn test_build_with_scriptless_component_path_emits_dormant_template() {
let app = create_app_dir(&[("index.html", "<ext-card></ext-card>")]);
let ext_dir = TempDir::new().unwrap();
fs::write(ext_dir.path().join("ext-card.html"), "<p>{{title}}</p>").unwrap();
let mut options = default_options(app.path());
options.plugin = Some(Plugin::WebUI);
options.components = vec![ext_dir.path().to_string_lossy().to_string()];
let result = build(options).unwrap();
assert!(result
.component_templates
.iter()
.any(|template| template.tag_name == "ext-card"));
let component = result
.protocol
.components
.get("ext-card")
.expect("external scriptless component metadata should be retained");
assert!(component.hydration_keys.is_empty());
assert_eq!(component.navigation_keys, ["title"]);
assert!(component.template_json.contains(r#""th":1"#));
}
#[test]
fn test_build_with_scripted_component_path_emits_client_artifact() {
let app = create_app_dir(&[("index.html", "<ext-card></ext-card>")]);
let ext_dir = TempDir::new().unwrap();
fs::write(ext_dir.path().join("ext-card.html"), "<p>{{title}}</p>").unwrap();
fs::write(ext_dir.path().join("ext-card.ts"), "export {};").unwrap();
let mut options = default_options(app.path());
options.plugin = Some(Plugin::WebUI);
options.components = vec![ext_dir.path().to_string_lossy().to_string()];
let result = build(options).unwrap();
let template = &result.protocol.components["ext-card"].template_json;
assert!(!template.is_empty(), "template should be emitted");
}
#[test]
fn test_build_rejects_projection_manifest_with_non_webui_plugin() {
let app = create_app_dir(&[
("index.html", "<demo-card></demo-card>"),
("demo-card.html", "<p>{{name}}</p>"),
("demo-card.ts", "export {};"),
]);
let manifest_json = projection::test_support::build_valid_manifest_json(
app.path(),
&[("demo-card.ts", "export {};")],
&[("projection-bundle.js", "output")],
&[(
"demo-card",
"demo-card.ts",
&["projection-bundle.js"],
&["name"],
&["name"],
)],
);
let manifest = projection::test_support::write_manifest(
app.path(),
"webui-projection.json",
&manifest_json,
);
let mut options = default_options(app.path());
options.projection_manifests = vec![manifest.into()];
let err = build(options).expect_err("non-WebUI plugin + manifest should be rejected");
assert!(
err.chain_message().contains("PROJ-B002"),
"msg: {}",
err.chain_message()
);
}
#[test]
fn test_build_rejects_missing_scripted_component_coverage() {
let app = create_app_dir(&[
("index.html", "<demo-card></demo-card>"),
("demo-card.html", "<p>{{name}}</p>"),
("demo-card.ts", "export {};"),
]);
let manifest_json =
projection::test_support::build_valid_manifest_json(app.path(), &[], &[], &[]);
let manifest = projection::test_support::write_manifest(
app.path(),
"webui-projection.json",
&manifest_json,
);
let mut options = default_options(app.path());
options.plugin = Some(Plugin::WebUI);
options.projection_manifests = vec![manifest.into()];
let err = build(options).expect_err("missing coverage should fail the build");
let message = err.chain_message();
assert!(message.contains("PROJ-B001"), "msg: {message}");
assert!(message.contains("demo-card"), "msg: {message}");
}
#[test]
fn test_build_allows_unused_discovered_scripted_component_without_coverage() {
let app = create_app_dir(&[
("index.html", "<demo-card></demo-card>"),
("demo-card.html", "<p>{{name}}</p>"),
("demo-card.ts", "export {};"),
("unused-card.html", "<p>{{title}}</p>"),
("unused-card.ts", "export {};"),
]);
let manifest_json = projection::test_support::build_valid_manifest_json(
app.path(),
&[("demo-card.ts", "export {};")],
&[("projection-bundle.js", "output")],
&[(
"demo-card",
"demo-card.ts",
&["projection-bundle.js"],
&["name"],
&["name"],
)],
);
let manifest = projection::test_support::write_manifest(
app.path(),
"webui-projection.json",
&manifest_json,
);
let mut options = default_options(app.path());
options.plugin = Some(Plugin::WebUI);
options.projection_manifests = vec![manifest.into()];
let result = build(options).unwrap();
assert!(!result.protocol.fragments.contains_key("unused-card"));
let component = result.protocol.components.get("demo-card").unwrap();
assert_eq!(component.hydration_keys, vec!["name"]);
}
#[test]
fn test_build_allows_asset_only_scripted_component_without_projection_coverage() {
let app = create_app_dir(&[
("index.html", "<app-shell></app-shell>"),
("app-shell.html", "<p>Entry</p>"),
("lazy-panel.html", "<p>{{title}}</p>"),
("lazy-panel.ts", "export {};"),
]);
let manifest_json =
projection::test_support::build_valid_manifest_json(app.path(), &[], &[], &[]);
let manifest = projection::test_support::write_manifest(
app.path(),
"webui-projection.json",
&manifest_json,
);
let mut options = default_options(app.path());
options.plugin = Some(Plugin::WebUI);
options.component_asset_roots = vec!["lazy-panel".to_string()];
options.projection_manifests = vec![manifest.into()];
let result = build(options).unwrap();
assert_eq!(result.component_asset_files.len(), 2);
assert!(!result.protocol.fragments.contains_key("lazy-panel"));
assert!(!result.protocol.components.contains_key("lazy-panel"));
}
#[test]
fn test_build_merges_multiple_projection_manifest_fragments() {
let app = create_app_dir(&[
(
"index.html",
"<html><body><card-a></card-a><card-b></card-b></body></html>",
),
("card-a.html", "<p>{{alpha}}</p>"),
("card-a.ts", "export {};"),
("card-b.html", "<p>{{beta}}</p>"),
("card-b.ts", "export {};"),
]);
let json_a = projection::test_support::build_valid_manifest_json(
app.path(),
&[("card-a.ts", "export {};")],
&[("card-a.js", "output-a")],
&[(
"card-a",
"card-a.ts",
&["card-a.js"],
&["alpha"],
&["alpha"],
)],
);
let manifest_a =
projection::test_support::write_manifest(app.path(), "fragment-a.json", &json_a);
let json_b = projection::test_support::build_valid_manifest_json(
app.path(),
&[("card-b.ts", "export {};")],
&[("card-b.js", "output-b")],
&[("card-b", "card-b.ts", &["card-b.js"], &["beta"], &["beta"])],
);
let manifest_b =
projection::test_support::write_manifest(app.path(), "fragment-b.json", &json_b);
let mut options = default_options(app.path());
options.plugin = Some(Plugin::WebUI);
options.projection_manifests = vec![manifest_a.into(), manifest_b.into()];
let result = build(options).unwrap();
assert_eq!(
result.protocol.initial_state_strategy,
webui_protocol::InitialStateStrategy::Components as i32
);
let card_a = result.protocol.components.get("card-a").unwrap();
assert_eq!(card_a.hydration_keys, vec!["alpha"]);
let card_b = result.protocol.components.get("card-b").unwrap();
assert_eq!(card_b.hydration_keys, vec!["beta"]);
}
#[test]
fn test_build_hello_world_example() {
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let app_dir = manifest_dir.join("../../examples/app/hello-world/src");
let result = build(BuildOptions {
app_dir,
entry: "index.html".to_string(),
..BuildOptions::default()
})
.unwrap();
let index = &result.protocol.fragments["index.html"].fragments;
assert!(index.iter().any(
|f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(fl)) if fl.collection == "people")
));
assert!(index
.iter()
.any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
}
#[test]
fn test_css_filename_sanitizes_path_separators() {
let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
let result = build(default_options(app.path())).unwrap();
for (filename, _) in &result.css_files {
assert!(
!filename.contains('/') && !filename.contains('\\'),
"CSS filename contains path separator: {filename}"
);
}
}
#[test]
fn test_build_to_disk_css_stays_in_output_dir() {
let app = create_app_dir(&[
("index.html", "<my-card>Hello</my-card>"),
(
"my-card.html",
r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
),
("my-card.css", ".card { color: red; }"),
]);
let out = TempDir::new().unwrap();
build_to_disk(default_options(app.path()), out.path()).unwrap();
let css_path = out.path().join("my-card.css");
assert!(css_path.exists());
let canonical = css_path.canonicalize().unwrap();
let out_canonical = out.path().canonicalize().unwrap();
assert!(
canonical.starts_with(&out_canonical),
"CSS file escaped output directory"
);
}
#[test]
fn test_build_empty_html() {
let app = create_app_dir(&[("index.html", "")]);
let result = build(default_options(app.path())).unwrap();
assert!(result.protocol.fragments.contains_key("index.html"));
assert_eq!(result.stats.fragment_count, 0);
assert!(result.stats.protocol_size_bytes > 0);
}
#[test]
fn test_build_large_fragment_count() {
let mut html = String::with_capacity(2000);
for i in 0..50 {
html.push_str(&format!("<p>Item {i}</p>\n"));
}
let app = create_app_dir(&[("index.html", &html)]);
let result = build(default_options(app.path())).unwrap();
assert!(result.stats.fragment_count > 0);
assert_eq!(result.stats.css_file_count, 0);
}
#[test]
fn test_build_to_disk_inline_mode_no_css_files() {
let app = create_app_dir(&[
("index.html", "<my-card>Hello</my-card>"),
(
"my-card.html",
r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
),
("my-card.css", ".card { color: red; }"),
]);
let out = TempDir::new().unwrap();
let mut options = default_options(app.path());
options.css = CssStrategy::Style;
let stats = build_to_disk(options, out.path()).unwrap();
assert!(out.path().join("protocol.bin").exists());
assert!(!out.path().join("my-card.css").exists());
assert_eq!(stats.css_file_count, 0);
}
#[test]
fn test_build_stats_duration_is_nonzero() {
let app = create_app_dir(&[("index.html", "<h1>Hello {{name}}</h1>")]);
let result = build(default_options(app.path())).unwrap();
assert!(!result.stats.duration.is_zero());
}
#[test]
fn test_build_multiple_components_css() {
let app = create_app_dir(&[
("index.html", "<card-a>A</card-a><card-b>B</card-b>"),
(
"card-a.html",
r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
),
("card-a.css", ".a { color: red; }"),
(
"card-b.html",
r#"<template shadowrootmode="open"><span><slot></slot></span></template>"#,
),
("card-b.css", ".b { color: blue; }"),
]);
let result = build(default_options(app.path())).unwrap();
assert_eq!(result.css_files.len(), 2);
assert_eq!(result.stats.css_file_count, 2);
let filenames: Vec<&str> = result.css_files.iter().map(|(n, _)| n.as_str()).collect();
assert!(filenames.contains(&"card-a.css"));
assert!(filenames.contains(&"card-b.css"));
}
#[test]
fn test_build_unused_component_css_not_emitted() {
let app = create_app_dir(&[
("index.html", "<card-a>A</card-a>"),
(
"card-a.html",
r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
),
("card-a.css", ".a { color: red; }"),
(
"card-b.html",
r#"<template shadowrootmode="open"><span><slot></slot></span></template>"#,
),
("card-b.css", ".b { color: blue; }"),
]);
let result = build(default_options(app.path())).unwrap();
assert_eq!(result.css_files.len(), 1);
assert_eq!(result.css_files[0].0, "card-a.css");
}
#[test]
fn test_build_unused_component_render_policy_not_emitted() {
let app = create_app_dir(&[
("index.html", "<card-a>A</card-a>"),
(
"card-a.html",
r#"<template w-render="lazy" w-reserve-block-size="10px"><div>a</div></template>"#,
),
(
"card-b.html",
r#"<template w-render="lazy" w-reserve-block-size="20px"><div>b</div></template>"#,
),
]);
let result = build(default_options(app.path())).unwrap();
assert!(result.protocol.component_render_css.contains("card-a"));
assert!(!result.protocol.component_render_css.contains("card-b"));
}
#[test]
fn test_build_persists_interaction_work_policy() {
let app = create_app_dir(&[
("index.html", "<interaction-shell></interaction-shell>"),
(
"interaction-shell.html",
r#"<template w-hydrate="interaction"><button>Open</button></template>"#,
),
(
"interaction-shell.ts",
"customElements.define('interaction-shell', class extends HTMLElement {});",
),
]);
for plugin in [Plugin::WebUI, Plugin::FastV3] {
let is_webui = matches!(plugin, Plugin::WebUI);
let mut options = default_options(app.path());
options.plugin = Some(plugin);
let result = build(options).expect("interaction policy build");
let component = &result.protocol.components["interaction-shell"];
assert_eq!(
component.work_policy,
webui_protocol::ComponentWorkPolicy::Interaction as i32
);
if is_webui {
assert!(component.template_json.contains(r#""wp":3"#));
}
}
}
#[test]
fn test_build_persists_combined_render_interaction_policy() {
let app = create_app_dir(&[
("index.html", "<interaction-panel></interaction-panel>"),
(
"interaction-panel.html",
concat!(
r#"<template w-render="lazy" w-reserve-block-size="18rem" "#,
r#"w-hydrate="interaction"><button>Open</button></template>"#,
),
),
(
"interaction-panel.ts",
"customElements.define('interaction-panel', class extends HTMLElement {});",
),
]);
for plugin in [Plugin::WebUI, Plugin::FastV3] {
let is_webui = matches!(plugin, Plugin::WebUI);
let mut options = default_options(app.path());
options.plugin = Some(plugin);
let result = build(options).expect("combined interaction policy build");
let component = &result.protocol.components["interaction-panel"];
assert_eq!(
component.work_policy,
webui_protocol::ComponentWorkPolicy::LazyRenderInteraction as i32
);
assert!(result
.protocol
.component_render_css
.contains("interaction-panel"));
if is_webui {
assert!(component.template_json.contains(r#""wp":4"#));
}
}
}
#[test]
fn test_inspect_roundtrip_preserves_content() {
let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
let result = build(default_options(app.path())).unwrap();
let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
let fragments = &parsed["fragments"]["index.html"]["fragments"];
assert!(fragments.is_array());
assert!(!fragments.as_array().unwrap().is_empty());
}
#[test]
fn test_build_nonexistent_app_dir() {
let options = BuildOptions {
app_dir: PathBuf::from("/nonexistent/path/that/does/not/exist"),
..BuildOptions::default()
};
let result = build(options);
assert!(result.is_err());
}
#[test]
fn test_build_allows_fallback_chain_when_all_theme_tokens_exist() {
let app = create_app_dir(&[
("index.html", "<my-card></my-card>"),
("my-card.html", "<div>Card</div>"),
(
"my-card.css",
":host { color: var(--token-a, var(--token-b, var(--token-c))); }",
),
]);
let mut options = default_options(app.path());
options.theme = Some(TokenFile {
themes: HashMap::from([(
"light".to_string(),
HashMap::from([
("token-a".to_string(), "red".to_string()),
("token-b".to_string(), "blue".to_string()),
("token-c".to_string(), "green".to_string()),
]),
)]),
});
let result = build(options).expect("all fallback tokens are defined");
assert!(result.protocol.tokens.contains(&"token-a".to_string()));
assert!(result.protocol.tokens.contains(&"token-b".to_string()));
assert!(result.protocol.tokens.contains(&"token-c".to_string()));
}
#[test]
fn test_build_allows_literal_fallback_token_absent_from_theme() {
let app = create_app_dir(&[
("index.html", "<my-card></my-card>"),
("my-card.html", "<div>Card</div>"),
("my-card.css", ":host { color: var(--brand, #000); }"),
]);
let mut options = default_options(app.path());
options.theme = Some(TokenFile {
themes: HashMap::from([("light".to_string(), HashMap::new())]),
});
let result = build(options).expect("literal fallback should not fail the build");
assert!(result.protocol.tokens.contains(&"brand".to_string()));
assert_eq!(result.warnings.len(), 1, "warnings: {:?}", result.warnings);
assert!(result.warnings[0].body().contains("--brand"));
}
#[test]
fn test_build_ancestor_inline_style_definition_after_comment_satisfies_descendant() {
let app = create_app_dir(&[
(
"index.html",
"<html><head><style>:root {\n /*{{{tokens.light}}}*/\n --foo-bar: 100px;\n}</style></head><body><my-card></my-card></body></html>",
),
("my-card.html", "<div>Card</div>"),
("my-card.css", ":host { padding: var(--foo-bar); }"),
]);
let mut options = default_options(app.path());
options.theme = Some(TokenFile {
themes: HashMap::from([("light".to_string(), HashMap::new())]),
});
let result = build(options)
.expect("ancestor inline-style definition (after a comment) should satisfy the child");
assert!(
!result.protocol.tokens.contains(&"foo-bar".to_string()),
"ancestor-defined token should not be hoisted: {:?}",
result.protocol.tokens
);
}
#[test]
fn test_build_no_warning_when_literal_fallback_token_is_themed() {
let app = create_app_dir(&[
("index.html", "<my-card></my-card>"),
("my-card.html", "<div>Card</div>"),
("my-card.css", ":host { color: var(--brand, #000); }"),
]);
let mut options = default_options(app.path());
options.theme = Some(TokenFile {
themes: HashMap::from([(
"light".to_string(),
HashMap::from([("brand".to_string(), "#123456".to_string())]),
)]),
});
let result = build(options).expect("themed literal-fallback token builds");
assert!(
result.warnings.is_empty(),
"a themed token must not warn: {:?}",
result.warnings
);
}
#[test]
fn test_build_without_theme_produces_no_warnings() {
let app = create_app_dir(&[
("index.html", "<my-card></my-card>"),
("my-card.html", "<div>Card</div>"),
("my-card.css", ":host { color: var(--brand, #000); }"),
]);
let result = build(default_options(app.path())).unwrap();
assert!(result.warnings.is_empty());
}
#[test]
fn test_build_warns_for_multiple_outlets_in_route_component() {
let app = create_app_dir(&[
(
"index.html",
r#"<route path="/" component="app-shell"><route path="child" component="child-page" exact /></route>"#,
),
(
"app-shell.html",
r#"<main><outlet /></main><aside><outlet /></aside>"#,
),
("child-page.html", "<p>Child</p>"),
]);
let result = build(default_options(app.path())).unwrap();
assert_eq!(result.warnings.len(), 1, "warnings: {:?}", result.warnings);
let warning = &result.warnings[0];
assert_eq!(warning.severity(), Severity::Warning);
assert_eq!(
warning.error_code(),
Some(webui_parser::codes::MULTIPLE_OUTLETS)
);
assert_eq!(warning.component_name(), Some("app-shell"));
assert!(warning.help_text().is_some());
}
#[test]
fn test_build_warns_for_outlets_across_component_templates() {
let app = create_app_dir(&[
(
"index.html",
r#"<route path="/" component="app-shell"><route path="child" component="child-page" exact /></route>"#,
),
(
"app-shell.html",
r#"<nested-layout></nested-layout><aside><outlet /></aside>"#,
),
("nested-layout.html", "<main><outlet /></main>"),
("child-page.html", "<p>Matched child</p>"),
]);
let result = build(default_options(app.path())).unwrap();
let html = render_html(&result.protocol, "/child");
assert_eq!(html.matches("<p>Matched child</p>").count(), 1, "{html}");
assert!(html.contains("<aside></aside>"), "{html}");
assert_eq!(result.warnings.len(), 1, "warnings: {:?}", result.warnings);
assert_eq!(
result.warnings[0].error_code(),
Some(webui_parser::codes::MULTIPLE_OUTLETS)
);
assert_eq!(result.warnings[0].component_name(), Some("app-shell"));
}
#[test]
fn test_build_excludes_tokens_defined_by_ancestor_component_css() {
let app = create_app_dir(&[
("index.html", "<component-a></component-a>"),
("component-a.html", "<component-b></component-b>"),
("component-a.css", ":host { --brand-color: red; }"),
("component-b.html", "<div>Child</div>"),
("component-b.css", ":host { color: var(--brand-color); }"),
]);
let mut options = default_options(app.path());
options.theme = Some(TokenFile {
themes: HashMap::from([("light".to_string(), HashMap::new())]),
});
let result =
build(options).expect("ancestor component custom property should satisfy child");
assert!(
!result.protocol.tokens.contains(&"brand-color".to_string()),
"ancestor-defined token should not be exposed: {:?}",
result.protocol.tokens
);
}
#[test]
fn test_build_scoped_parent_overrides_preserve_theme_defaults() {
for selector in [
".green",
":host([split]) plain-control",
":host([active])",
"@media (width > 10000px) { :host",
"@supports (display: unknown) { :host",
"@container (width > 10000px) { :host",
] {
let mut css = format!("{selector} {{ --brand: var(--green); }}");
if selector.starts_with('@') {
css.push('}');
}
let app = create_app_dir(&[
("index.html", "<layout-container></layout-container>"),
(
"layout-container.html",
"<plain-control></plain-control><div class=\"green\"><plain-control></plain-control></div>",
),
("layout-container.css", &css),
("plain-control.html", "<button>Primary</button>"),
("plain-control.css", "button { color: var(--brand); }"),
]);
let mut options = default_options(app.path());
options.theme = Some(TokenFile {
themes: HashMap::from([(
"light".to_string(),
HashMap::from([
("brand".to_string(), "black".to_string()),
("green".to_string(), "green".to_string()),
]),
)]),
});
let result = build(options).expect("scoped overrides should build");
assert_eq!(result.protocol.tokens, ["brand", "green"], "{selector}");
let mut options = default_options(app.path());
options.theme = Some(TokenFile {
themes: HashMap::from([(
"light".to_string(),
HashMap::from([("green".to_string(), "green".to_string())]),
)]),
});
let error = build(options).expect_err("uncovered child still requires --brand");
assert!(error.chain_message().contains("missing-theme-token"));
}
}
#[test]
fn test_build_scoped_inline_overrides_preserve_theme_defaults() {
let app = create_app_dir(&[
(
"index.html",
"<style>.green { --brand: var(--green); }</style><plain-control></plain-control><div class=\"green\"><plain-control></plain-control></div>",
),
("plain-control.html", "<button>Primary</button>"),
("plain-control.css", "button { color: var(--brand); }"),
]);
let result = build(default_options(app.path())).expect("build");
assert_eq!(result.protocol.tokens, ["brand", "green"]);
}
#[test]
fn test_build_does_not_use_sibling_component_definitions_for_theme_validation() {
let app = create_app_dir(&[
(
"index.html",
"<component-a></component-a><component-b></component-b>",
),
("component-a.html", "<div>A</div>"),
("component-a.css", ":host { --brand-color: red; }"),
("component-b.html", "<div>B</div>"),
("component-b.css", ":host { color: var(--brand-color); }"),
]);
let mut options = default_options(app.path());
options.theme = Some(TokenFile {
themes: HashMap::from([("light".to_string(), HashMap::new())]),
});
let Err(err) = build(options) else {
panic!("sibling custom property must not satisfy component-b");
};
let message = err.chain_message();
assert!(message.contains("missing theme token"), "msg: {message}");
assert!(message.contains("missing-theme-token"), "msg: {message}");
assert!(message.contains("--brand-color"), "msg: {message}");
}
#[test]
fn test_build_rejects_missing_theme_token_after_local_definition_filter() {
let app = create_app_dir(&[
("index.html", "<my-card></my-card>"),
("my-card.html", "<div>Card</div>"),
(
"my-card.css",
":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c))); }",
),
]);
let mut options = default_options(app.path());
options.theme = Some(TokenFile {
themes: HashMap::from([(
"light".to_string(),
HashMap::from([("token-b".to_string(), "green".to_string())]),
)]),
});
let Err(err) = build(options) else {
panic!("missing --token-c in the theme must fail the build");
};
let message = err.chain_message();
assert!(message.contains("missing-theme-token"), "msg: {message}");
assert!(message.contains("--token-c"), "msg: {message}");
assert!(!message.contains("add --token-a"), "msg: {message}");
}
mod package_discovery {
use super::*;
type TestResult = Result<(), Box<dyn std::error::Error>>;
fn write(root: &Path, name: &str, content: &str) -> TestResult {
let path = root.join(name);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, content)?;
Ok(())
}
fn package_fixture(package: &Path, catalog: Option<&str>) -> TestResult {
let mut metadata = serde_json::json!({
"name": "@fixture/catalog", "customElements": "custom-elements.json"
});
if let Some(catalog) = catalog {
metadata["webui"] = serde_json::json!({"components": [catalog]});
}
write(package, "package.json", &metadata.to_string())?;
let catalog = package.join(catalog.unwrap_or("components"));
write(&catalog, "fixture-card.html", "<p>Package component</p>")?;
write(&catalog, "fixture-card.css", "p { color: blue; }")?;
write(
&catalog,
"fixture-card.v2.html",
"<span>Dotted component</span>",
)?;
write(
package,
"custom-elements.json",
r#"{"modules":[{"path":"dist/manifest-card.js","declarations":[{"name":"ManifestCard","tagName":"manifest-card"}]}]}"#,
)?;
write(package, "dist/manifest-card.js", "export {};")?;
write(
package,
"dist/manifest-card.template-webui.html",
r#"<f-template name="manifest-card"><template><p>Manifest component</p></template></f-template>"#,
)?;
for report in [
"test_results/coverage/lcov-report/core/initial-state.ts.html",
"test_results/coverage/lcov-report/initial-state.ts.html",
] {
write(
package,
report,
"<!doctype html><html><body>Generated coverage report</body></html>",
)?;
}
Ok(())
}
fn assert_build(root: &Path, plugin: Option<Plugin>) -> TestResult {
let manifest = matches!(plugin, Some(Plugin::FastV2 | Plugin::FastV3));
let app = root.join("app/src");
write(&app, "app-local.html", "<span>App component</span>")?;
write(
&app,
"index.html",
"<app-local></app-local><fixture-card></fixture-card><fixture-card.v2></fixture-card.v2><manifest-card></manifest-card>",
)?;
let options = BuildOptions {
app_dir: app,
components: vec!["@fixture/catalog".to_string()],
plugin,
css: CssStrategy::Style,
..BuildOptions::default()
};
for _ in 0..2 {
let result = build(options.clone())?;
assert_eq!(result.stats.component_count, if manifest { 4 } else { 3 });
assert!(!result.protocol.fragments.contains_key("initial-state.ts"));
let mut writer = StringWriter { buf: String::new() };
WebUIHandler::new().render(
&Protocol::new(result.protocol),
&serde_json::json!({}),
&RenderOptions::new("index.html", "/"),
&mut writer,
)?;
for text in ["App component", "Package component", "Dotted component"] {
assert!(writer.buf.contains(text), "Missing rendered {text}");
}
assert!(writer.buf.contains("color: blue"));
assert!(!writer.buf.contains("Generated coverage report"));
assert_eq!(writer.buf.contains("Manifest component"), manifest);
}
Ok(())
}
#[test]
fn package_build_excludes_reports_for_default_and_explicit_catalogs() -> TestResult {
for (plugin, catalog) in [
(None, None),
(Some(Plugin::WebUI), Some("./src/components")),
(Some(Plugin::FastV2), None),
(Some(Plugin::FastV3), Some("./src/components")),
] {
let root = tempfile::tempdir()?;
package_fixture(&root.path().join("node_modules/@fixture/catalog"), catalog)?;
assert_build(root.path(), plugin)?;
}
Ok(())
}
#[cfg(unix)]
#[test]
fn symlinked_workspace_package_build_excludes_generated_reports() -> TestResult {
let root = tempfile::tempdir()?;
let package = root.path().join("packages/catalog");
package_fixture(&package, Some("./src/components"))?;
fs::create_dir_all(root.path().join("node_modules/@fixture"))?;
std::os::unix::fs::symlink(
&package,
root.path().join("node_modules/@fixture/catalog"),
)?;
assert_build(root.path(), Some(Plugin::FastV3))
}
#[test]
fn distinct_files_and_overlapping_roots_preserve_duplicate_errors() -> TestResult {
for (roots, second_file) in [
(["./a", "./b"], Some("b/fixture-card.html")),
(["./a", "./a"], None),
(["./", "./a"], None),
(["./a", "./"], None),
] {
let root = tempfile::tempdir()?;
let package = root.path().join("node_modules/@fixture/catalog");
write(
&package,
"package.json",
&serde_json::json!({"webui":{"components":roots}}).to_string(),
)?;
write(&package, "a/fixture-card.html", "<p>First</p>")?;
if let Some(path) = second_file {
write(&package, path, "<p>Second</p>")?;
}
let app = root.path().join("app");
write(&app, "index.html", "<fixture-card></fixture-card>")?;
let error = build(BuildOptions {
app_dir: app,
components: vec!["@fixture/catalog".to_string()],
..BuildOptions::default()
})
.expect_err("Duplicate component names must remain errors");
let message = error.chain_message();
assert!(message.contains("fixture-card"), "{message}");
assert!(message.contains("already registered"), "{message}");
}
Ok(())
}
}
#[test]
fn test_build_to_disk_returns_accurate_stats() {
let app = create_app_dir(&[
("index.html", "<my-card>Hello</my-card><p>{{name}}</p>"),
(
"my-card.html",
r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
),
("my-card.css", ".card { color: red; }"),
]);
let out = TempDir::new().unwrap();
let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
assert!(stats.fragment_count > 0);
assert_eq!(stats.css_file_count, 1);
assert!(stats.component_count > 0);
assert!(stats.protocol_size_bytes > 0);
assert!(!stats.duration.is_zero());
}
}