mod component_assets;
mod error;
mod module_preload;
mod projection;
pub mod server;
pub mod streaming;
pub use component_assets::{render_component_assets, ComponentAssetFile};
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, BoundaryId, BoundaryMode, FlushWriter, HandlerError, Protocol,
RenderOptions, ResponseWriter, StreamingResponse, 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_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 plugin: Option<Plugin>,
pub components: Vec<String>,
pub component_asset_roots: Vec<String>,
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,
plugin: None,
components: Vec::new(),
component_asset_roots: Vec::new(),
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 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,
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,
})?;
}
for file in &result.component_asset_files {
fs::write(out_dir.join(&file.name), &file.content).map_err(|source| WebUIError::Io {
context: format!(
"Failed to write component asset {} to {}",
file.name,
out_dir.display()
),
source,
})?;
}
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>,
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());
}
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),
};
parser
.component_registry_mut()
.register_from_paths(&[&options.app_dir])
.map_err(|e| {
WebUIError::ComponentRegistration(format!(
"Failed to register components from {}: {e}",
options.app_dir.display()
))
})?;
for source in &options.components {
let result = webui_discovery::discover_source(source, &options.app_dir).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(|e| {
WebUIError::ComponentRegistration(format!(
"Failed to register component '{}' from {}: {e}",
comp.tag_name, comp.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 boundary_count = parser.boundary_count();
let boundary_names = parser.boundary_names().to_vec();
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 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 token_analysis = parser.token_analysis();
let mut warnings: Vec<Diagnostic> = Vec::new();
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 = 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 fragment_count: usize = fragment_records.values().map(|v| v.fragments.len()).sum();
let merged_manifest = projection::load_and_merge(&options.projection_manifests)?;
let mut protocol = WebUIProtocol::with_tokens(fragment_records, token_analysis.protocol_tokens);
if !boundary_names.is_empty() {
protocol.streaming_boundaries.insert(
options.entry.clone(),
webui_protocol::StreamingBoundaryList {
names: boundary_names,
},
);
}
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);
}
if let Some(merged) = &merged_manifest {
let compiled_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, &compiled_scripted_tags)?;
}
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,
});
protocol.set_dom_strategy(match options.dom {
DomStrategy::Shadow => webui_protocol::DomStrategy::Shadow,
DomStrategy::Light => webui_protocol::DomStrategy::Light,
});
let is_module = options.css == CssStrategy::Module;
let is_link = options.css == CssStrategy::Link;
let mut css_files: Vec<(String, String)> = Vec::new();
let mut emitted_names: HashSet<String> = HashSet::new();
for (tag, css) in css_snapshot {
if !protocol.fragments.contains_key(&tag) {
continue;
}
if is_module {
protocol.components.entry(tag).or_default().css = css.trim().to_string();
} else if is_link {
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));
}
}
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 component_asset_files = component_assets::render_component_assets(
&protocol,
&options.component_asset_roots,
&options.css_file_name_template,
)?;
validate_generated_file_names(&css_files, &component_asset_files)?;
Ok(RawBuildOutput {
protocol,
css_files,
component_asset_files,
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()
))
}
#[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()
}
}
#[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 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
);
assert_eq!(
result.protocol.streaming_boundaries["index.html"].names,
["a", "b"]
);
let decoded = WebUIProtocol::from_protobuf(&result.protocol_bytes).unwrap();
assert_eq!(decoded.streaming_boundaries["index.html"].names, ["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 test_build_with_component_css() {
let app = create_app_dir(&[
("index.html", "<my-card>Hello</my-card>"),
("my-card.html", "<div><slot></slot></div>"),
("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 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", "<div><slot></slot></div>"),
(
"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", "<div><slot></slot></div>"),
(
"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", "<div><slot></slot></div>"),
("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", "<div><slot></slot></div>"),
("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", "<div><slot></slot></div>"),
("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_parser_and_handler_links() {
let app = create_app_dir(&[
(
"index.html",
"<html><head></head><body><my-card>Hello</my-card></body></html>",
),
("my-card.html", "<div><slot></slot></div>"),
("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}");
let component_html = 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::<String>();
assert!(
component_html.contains(&format!(
r#"<link rel="stylesheet" href="{expected_href}">"#
)),
"parser-generated component template should use CDN href: {component_html}"
);
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="preload" href="{expected_href}" as="style" data-webui-ssr-preload="style">"#
)),
"handler head preload should use CDN href: {}",
writer.buf
);
}
#[test]
fn test_css_public_base_emits_webui_plugin_template_links() {
let app = create_app_dir(&[
("index.html", "<my-card>Hello</my-card>"),
("my-card.html", "<div><slot></slot></div>"),
("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!(
template.contains(&format!(r#"href=\"{expected_href}\""#)),
"plugin component template should use CDN href: {template}"
);
}
#[test]
fn test_css_public_base_emits_fast_plugin_template_links() {
let app = create_app_dir(&[
("index.html", "<my-card>Hello</my-card>"),
("my-card.html", "<div><slot></slot></div>"),
("my-card.css", ".card { color: red; }"),
]);
let mut options = default_options(app.path());
options.plugin = Some(Plugin::FastV3);
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;
assert!(
template.contains(&format!(r#"href="{expected_href}""#)),
"FAST component template should use CDN href: {template}"
);
}
#[test]
fn test_invalid_css_template_is_rejected() {
let app = create_app_dir(&[
("index.html", "<my-card>Hello</my-card>"),
("my-card.html", "<div><slot></slot></div>"),
("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", "<div><slot></slot></div>"),
("card-a.css", ".x { color: red; }"),
("card-b.html", "<div><slot></slot></div>"),
("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(), 1);
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#""templateFunctions":{"lazy-panel":"#));
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 test_component_asset_filename_collision_with_css_is_rejected() {
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);
assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
}
#[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>"),
]);
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><slot></slot></p>"),
("has-css.css", ".yes { color: green; }"),
("no-css.html", "<p><slot></slot></p>"),
]);
let mut options = default_options(app.path());
options.dom = DomStrategy::Light;
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_shadow_dom_link_strategy_sets_css_href() {
let app = create_app_dir(&[
("index.html", "<my-card>A</my-card>"),
("my-card.html", "<p><slot></slot></p>"),
("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.protocol.dom_strategy(),
webui_protocol::DomStrategy::Shadow,
);
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", "<p><slot></slot></p>"),
("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", "<p><slot></slot></p>"),
("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", "<div><slot></slot></div>"),
("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", "<div><slot></slot></div>"),
("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", "<div><slot></slot></div>"),
("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"),
"<div class=\"card\"><slot></slot></div>",
)
.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_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", "<div><slot></slot></div>"),
("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_with_fast_plugin() {
let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
let mut options = default_options(app.path());
options.plugin = Some(Plugin::FastV3);
let result = build(options).unwrap();
assert!(result.protocol.fragments.contains_key("index.html"));
assert_eq!(
result.protocol.initial_state_strategy,
webui_protocol::InitialStateStrategy::Full as i32
);
}
#[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", "<div><slot></slot></div>"),
("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", "<div><slot></slot></div>"),
("card-a.css", ".a { color: red; }"),
("card-b.html", "<span><slot></slot></span>"),
("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", "<div><slot></slot></div>"),
("card-a.css", ".a { color: red; }"),
("card-b.html", "<span><slot></slot></span>"),
("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_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_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_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}");
}
#[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", "<div><slot></slot></div>"),
("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());
}
}