1use std::sync::Arc;
31
32use napi::bindgen_prelude::{Buffer, Either, External, Function};
33use napi::Error as NapiError;
34use napi_derive::napi;
35use serde_json::Value;
36use webui_handler::plugin::fast_v2::FastV2HydrationPlugin;
37use webui_handler::plugin::fast_v3::FastV3HydrationPlugin;
38use webui_handler::plugin::webui::WebUIHydrationPlugin;
39use webui_handler::{
40 BoundaryDescriptor, BoundaryInstanceId, BoundaryKey, BoundaryMode, HandlerError,
41 Protocol as HandlerProtocol, RenderCapacityHints, RenderOptions, ResponseWriter,
42 SessionOptions, StreamStep as HandlerStreamStep, StreamingSession as HandlerStreamingSession,
43 WebUIHandler,
44};
45#[cfg(test)]
46use webui_protocol::WebUIProtocol;
47
48const STREAM_CHUNK_SIZE: usize = 16 * 1024;
49
50#[napi(object)]
52pub struct JsBuildStats {
53 pub duration_ms: f64,
55 pub fragment_count: u32,
57 pub component_count: u32,
59 pub css_file_count: u32,
61 pub protocol_size_bytes: u32,
63 pub token_count: u32,
65}
66
67#[napi(object)]
69pub struct JsBuildResult {
70 pub protocol: Buffer,
72 pub css_files: Vec<String>,
74 pub component_asset_files: Vec<String>,
76 pub metafile: Option<String>,
78 pub warnings: Vec<String>,
81 pub stats: JsBuildStats,
83}
84
85#[napi(object)]
87pub struct JsProjectionManifest {
88 pub path: String,
90 pub json: String,
92}
93
94#[napi(object)]
96pub struct JsBuildOptions {
97 pub app_dir: String,
99 pub entry: Option<String>,
101 pub css: Option<String>,
103 pub dom: Option<String>,
105 pub css_bundle: Option<bool>,
110 pub plugin: Option<String>,
112 pub components: Option<Vec<String>>,
114 pub component_asset_roots: Option<Vec<String>>,
116 pub metafile: Option<bool>,
118 pub css_file_name_template: Option<String>,
120 pub css_public_base: Option<String>,
122 pub legal_comments: Option<String>,
124 pub theme: Option<String>,
126 pub projection_manifests: Option<Vec<String>>,
128 pub projection_manifest_objects: Option<Vec<JsProjectionManifest>>,
130}
131
132#[napi]
136#[allow(clippy::cast_possible_truncation)] pub fn build(options: JsBuildOptions) -> napi::Result<JsBuildResult> {
138 let css = options
139 .css
140 .map(|s| s.parse::<webui::CssStrategy>())
141 .transpose()
142 .map_err(NapiError::from_reason)?
143 .unwrap_or_default();
144 let dom = options
145 .dom
146 .map(|value| value.parse::<webui::DomStrategy>())
147 .transpose()
148 .map_err(NapiError::from_reason)?
149 .unwrap_or_default();
150
151 let plugin = options
152 .plugin
153 .map(|s| s.parse::<webui::Plugin>())
154 .transpose()
155 .map_err(NapiError::from_reason)?;
156
157 let legal_comments = options
158 .legal_comments
159 .map(|s| s.parse::<webui::LegalComments>())
160 .transpose()
161 .map_err(NapiError::from_reason)?
162 .unwrap_or_default();
163
164 let app_dir = std::path::PathBuf::from(&options.app_dir);
165 let theme = options
166 .theme
167 .as_deref()
168 .map(|theme| load_theme(theme, &app_dir))
169 .transpose()?;
170 let mut projection_manifests: Vec<webui::ProjectionManifestSource> = options
171 .projection_manifests
172 .unwrap_or_default()
173 .into_iter()
174 .map(std::path::PathBuf::from)
175 .map(Into::into)
176 .collect();
177 projection_manifests.extend(
178 options
179 .projection_manifest_objects
180 .unwrap_or_default()
181 .into_iter()
182 .map(|manifest| webui::ProjectionManifestSource::Inline {
183 manifest_path: std::path::PathBuf::from(manifest.path),
184 json: manifest.json,
185 }),
186 );
187
188 let build_options = webui::BuildOptions {
189 app_dir,
190 entry: options.entry.unwrap_or_else(|| "index.html".to_string()),
191 css,
192 dom,
193 css_bundle: options.css_bundle.unwrap_or(false),
194 plugin,
195 components: options.components.unwrap_or_default(),
196 component_asset_roots: options.component_asset_roots.unwrap_or_default(),
197 metafile: options.metafile.unwrap_or(false),
198 css_file_name_template: options
199 .css_file_name_template
200 .unwrap_or_else(|| webui::DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string()),
201 css_public_base: options.css_public_base,
202 legal_comments,
203 theme,
204 projection_manifests,
205 };
206
207 let result = webui::build(build_options)
208 .map_err(|e| NapiError::from_reason(format!("Build error: {}", e.chain_message())))?;
209
210 let css_files: Vec<String> = result
212 .css_files
213 .into_iter()
214 .flat_map(|(name, content)| [name, content])
215 .collect();
216 let component_asset_files: Vec<String> = result
217 .component_asset_files
218 .into_iter()
219 .flat_map(|file| [file.name, file.content])
220 .collect();
221 let warnings: Vec<String> = result.warnings.iter().map(|d| d.to_string()).collect();
222
223 Ok(JsBuildResult {
224 protocol: Buffer::from(result.protocol_bytes),
225 css_files,
226 component_asset_files,
227 metafile: result.metafile,
228 warnings,
229 stats: JsBuildStats {
230 duration_ms: result.stats.duration.as_secs_f64() * 1000.0,
231 fragment_count: result.stats.fragment_count as u32,
232 component_count: result.stats.component_count as u32,
233 css_file_count: result.stats.css_file_count as u32,
234 protocol_size_bytes: result.stats.protocol_size_bytes as u32,
235 token_count: result.stats.token_count as u32,
236 },
237 })
238}
239
240fn load_theme(theme: &str, search_root: &std::path::Path) -> napi::Result<webui::TokenFile> {
241 let resolved = webui::resolve_theme_path(theme, search_root)
242 .map_err(|e| NapiError::from_reason(format!("Theme resolution error: {e}")))?;
243 webui::load_token_file(&resolved).map_err(|e| {
244 NapiError::from_reason(format!("Theme load error for {}: {e}", resolved.display()))
245 })
246}
247
248#[napi]
250pub fn inspect(protocol_data: Buffer) -> napi::Result<String> {
251 webui::inspect_bytes(&protocol_data)
252 .map_err(|e| NapiError::from_reason(format!("Inspect error: {e}")))
253}
254
255#[napi]
261pub struct Protocol {
262 inner: Arc<HandlerProtocol>,
263 handler: Arc<WebUIHandler>,
264 output_capacity_hints: RenderCapacityHints,
265}
266
267#[napi]
268impl Protocol {
269 #[napi(constructor)]
271 pub fn new(protocol_data: Buffer, plugin: Option<String>) -> napi::Result<Self> {
272 let inner = Arc::new(decode_protocol(&protocol_data)?);
273 let handler = Arc::new(create_handler(plugin)?);
274 Ok(Self {
275 inner,
276 handler,
277 output_capacity_hints: RenderCapacityHints::new(),
278 })
279 }
280
281 #[napi]
283 pub fn render(
284 &self,
285 state_json: String,
286 entry: String,
287 request_path: String,
288 ) -> napi::Result<Buffer> {
289 let state = parse_state_json(&state_json)?;
290 let options = RenderOptions::new(&entry, &request_path);
291 self.render_buffer(&state, &options)
292 }
293
294 #[napi]
299 pub fn prepare_state(&self, state_json: String) -> napi::Result<External<Value>> {
300 let size_hint = state_json.len().saturating_mul(2);
301 Ok(External::new_with_size_hint(
302 parse_state_json(&state_json)?,
303 size_hint,
304 ))
305 }
306
307 #[napi]
309 pub fn render_prepared(
310 &self,
311 state: &External<Value>,
312 entry: String,
313 request_path: String,
314 ) -> napi::Result<Buffer> {
315 let options = RenderOptions::new(&entry, &request_path);
316 self.render_buffer(state.as_ref(), &options)
317 }
318
319 #[napi]
321 pub fn render_stream(
322 &self,
323 state_json: String,
324 entry: String,
325 request_path: String,
326 on_chunk: Function<String>,
327 ) -> napi::Result<()> {
328 let state = parse_state_json(&state_json)?;
329 let options = RenderOptions::new(&entry, &request_path);
330 render_to_callback(&self.handler, &self.inner, &state, &options, &on_chunk)
331 }
332
333 #[napi]
335 pub fn render_partial(
336 &self,
337 state_json: String,
338 entry_id: String,
339 request_path: String,
340 inventory_hex: String,
341 ) -> napi::Result<String> {
342 self.inner
343 .render_partial_json(&state_json, &entry_id, &request_path, &inventory_hex)
344 .map_err(|e| NapiError::from_reason(format!("render_partial failed: {e}")))
345 }
346
347 #[napi]
349 pub fn render_component_templates(
350 &self,
351 component_tags: Vec<String>,
352 inventory_hex: String,
353 ) -> napi::Result<String> {
354 let tag_refs: Vec<&str> = component_tags.iter().map(String::as_str).collect();
355 let result = self
356 .inner
357 .render_component_templates(&tag_refs, &inventory_hex)
358 .map_err(|e| {
359 NapiError::from_reason(format!("render_component_templates failed: {e}"))
360 })?;
361 serde_json::to_string(&result)
362 .map_err(|e| NapiError::from_reason(format!("JSON serialize error: {e}")))
363 }
364
365 #[napi]
367 pub fn tokens(&self) -> Vec<String> {
368 self.inner.tokens().to_vec()
369 }
370
371 #[napi]
377 pub fn stream_response(
378 &self,
379 entry: String,
380 request_path: String,
381 options: Option<JsStreamOptions>,
382 ) -> napi::Result<StreamingSession> {
383 let options = options.unwrap_or_default();
384 let mut session_options = SessionOptions::new(entry, request_path);
385 session_options.nonce = options.nonce;
386 session_options.head_inject = options.head_inject;
387 session_options.body_inject = options.body_inject;
388 let inner = HandlerStreamingSession::new(
389 Arc::clone(&self.handler),
390 Arc::clone(&self.inner),
391 session_options,
392 )
393 .map_err(streaming_error)?;
394 Ok(StreamingSession { inner })
395 }
396}
397
398impl Protocol {
399 fn render_buffer(&self, state: &Value, options: &RenderOptions<'_>) -> napi::Result<Buffer> {
400 let capacity = self
401 .output_capacity_hints
402 .load(options.entry_id, options.request_path);
403 let mut html = render_to_string(&self.handler, &self.inner, state, options, capacity)?;
404 self.output_capacity_hints
405 .store(options.entry_id, options.request_path, html.len());
406 if html.capacity() > html.len().saturating_mul(2) {
408 html.shrink_to_fit();
409 }
410 Ok(Buffer::from(html.into_bytes()))
412 }
413}
414
415#[napi(object)]
417#[derive(Default)]
418pub struct JsStreamOptions {
419 pub nonce: Option<String>,
421 pub head_inject: Option<String>,
423 pub body_inject: Option<String>,
425}
426
427#[napi]
448pub struct StreamingSession {
449 inner: HandlerStreamingSession,
450}
451
452#[napi(object)]
454pub struct JsBoundaryDescriptor {
455 pub instance_id: u32,
457 pub declaration_id: u32,
459 pub owner: String,
461 pub name: String,
463 pub key: Option<Either<String, f64>>,
465}
466
467#[napi(object)]
469pub struct JsStreamStep {
470 pub bytes: Buffer,
472 pub done: bool,
474 pub boundary: Option<JsBoundaryDescriptor>,
476}
477
478#[napi]
479impl StreamingSession {
480 #[napi]
482 pub fn start(&mut self, state_json: String) -> napi::Result<JsStreamStep> {
483 let state = parse_state_json(&state_json)?;
484 self.inner
485 .start(state)
486 .and_then(stream_step)
487 .map_err(streaming_error)
488 }
489
490 #[napi]
495 pub fn resume(
496 &mut self,
497 instance_id: u32,
498 state_json: String,
499 mode: Option<String>,
500 ) -> napi::Result<JsStreamStep> {
501 let state = parse_state_json(&state_json)?;
502 let mode = parse_boundary_mode(mode.as_deref())?;
503 self.inner
504 .resume(BoundaryInstanceId::from_raw(instance_id), state, mode)
505 .and_then(stream_step)
506 .map_err(streaming_error)
507 }
508
509 #[napi]
514 pub fn advance(&mut self) -> napi::Result<JsStreamStep> {
515 self.inner
516 .advance()
517 .and_then(stream_step)
518 .map_err(streaming_error)
519 }
520
521 #[napi]
523 pub fn update(&mut self, instance_id: u32, patch_json: String) -> napi::Result<Buffer> {
524 let patch = parse_state_json(&patch_json)?;
525 self.inner
526 .update(BoundaryInstanceId::from_raw(instance_id), &patch)
527 .map(Buffer::from)
528 .map_err(streaming_error)
529 }
530}
531
532fn stream_step(step: HandlerStreamStep) -> webui_handler::Result<JsStreamStep> {
533 Ok(JsStreamStep {
534 bytes: Buffer::from(step.bytes),
535 done: step.done,
536 boundary: step.boundary.map(boundary_descriptor).transpose()?,
537 })
538}
539
540fn boundary_descriptor(
541 boundary: BoundaryDescriptor,
542) -> webui_handler::Result<JsBoundaryDescriptor> {
543 Ok(JsBoundaryDescriptor {
544 instance_id: boundary.instance_id.raw(),
545 declaration_id: boundary.declaration_id,
546 owner: boundary.owner.to_string(),
547 name: boundary.name.to_string(),
548 key: boundary.key.map(boundary_key).transpose()?,
549 })
550}
551
552fn boundary_key(key: BoundaryKey) -> webui_handler::Result<Either<String, f64>> {
553 match key {
554 BoundaryKey::String(value) => Ok(Either::A(value)),
555 BoundaryKey::Number(value) => value.as_f64().map(Either::B).ok_or_else(|| {
556 HandlerError::Invariant(
557 "boundary key cannot be represented as a JavaScript number".into(),
558 )
559 }),
560 }
561}
562
563fn parse_boundary_mode(mode: Option<&str>) -> napi::Result<BoundaryMode> {
564 match mode {
565 None | Some("final") => Ok(BoundaryMode::Final),
566 Some("updatable") => Ok(BoundaryMode::Updatable),
567 Some(other) => Err(NapiError::from_reason(format!(
568 "unknown boundary mode '{other}'; expected 'final' or 'updatable'"
569 ))),
570 }
571}
572
573fn streaming_error(error: HandlerError) -> NapiError {
574 NapiError::from_reason(error.to_string())
575}
576
577fn decode_protocol(protocol_data: &[u8]) -> napi::Result<HandlerProtocol> {
578 HandlerProtocol::from_protobuf(protocol_data)
579 .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))
580}
581
582fn parse_state_json(state_json: &str) -> napi::Result<Value> {
583 serde_json::from_str(state_json)
584 .map_err(|e| NapiError::from_reason(format!("State JSON error: {e}")))
585}
586
587fn create_handler(plugin: Option<String>) -> napi::Result<WebUIHandler> {
588 let plugin = plugin
589 .map(|value| value.parse::<webui::Plugin>())
590 .transpose()
591 .map_err(NapiError::from_reason)?;
592 Ok(match plugin {
593 Some(webui::Plugin::Fast | webui::Plugin::FastV2) => {
594 WebUIHandler::with_plugin(|| Box::new(FastV2HydrationPlugin::new()))
595 }
596 Some(webui::Plugin::FastV3) => {
597 WebUIHandler::with_plugin(|| Box::new(FastV3HydrationPlugin::new()))
598 }
599 Some(webui::Plugin::WebUI) => {
600 WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()))
601 }
602 None => WebUIHandler::new(),
603 })
604}
605
606webui_handler::define_string_response_writer!(BufferedWriter, output);
607
608fn render_to_string(
609 handler: &WebUIHandler,
610 protocol: &HandlerProtocol,
611 state: &Value,
612 options: &RenderOptions<'_>,
613 capacity: usize,
614) -> napi::Result<String> {
615 let mut writer = BufferedWriter::with_capacity(capacity);
616 handler
617 .render(protocol, state, options, &mut writer)
618 .map_err(|e| NapiError::from_reason(format!("Render error: {e}")))?;
619 Ok(writer.output)
620}
621
622struct CallbackWriter<F> {
624 callback: F,
625 buffer: String,
626 error: Option<NapiError>,
627}
628
629impl<F> CallbackWriter<F>
630where
631 F: FnMut(String) -> napi::Result<()>,
632{
633 fn new(callback: F) -> Self {
634 Self {
635 callback,
636 buffer: String::with_capacity(STREAM_CHUNK_SIZE),
637 error: None,
638 }
639 }
640
641 fn flush(&mut self) -> webui_handler::Result<()> {
642 if self.error.is_some() {
643 return Err(callback_writer_error());
644 }
645 if self.buffer.is_empty() {
646 return Ok(());
647 }
648
649 let chunk = std::mem::replace(&mut self.buffer, String::with_capacity(STREAM_CHUNK_SIZE));
650 if let Err(error) = (self.callback)(chunk) {
651 self.error = Some(error);
652 return Err(callback_writer_error());
653 }
654 Ok(())
655 }
656}
657
658impl<F> ResponseWriter for CallbackWriter<F>
659where
660 F: FnMut(String) -> napi::Result<()>,
661{
662 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
663 if self.error.is_some() {
664 return Err(callback_writer_error());
665 }
666 self.buffer.push_str(content);
667 if self.buffer.len() >= STREAM_CHUNK_SIZE {
668 self.flush()?;
669 }
670 Ok(())
671 }
672
673 fn write_attribute(&mut self, name: &str, value: &str) -> webui_handler::Result<()> {
674 if self.error.is_some() {
675 return Err(callback_writer_error());
676 }
677 webui_handler::append_attribute_to_string(&mut self.buffer, name, value);
678 if self.buffer.len() >= STREAM_CHUNK_SIZE {
679 self.flush()?;
680 }
681 Ok(())
682 }
683
684 fn write_boolean_attribute(&mut self, name: &str) -> webui_handler::Result<()> {
685 if self.error.is_some() {
686 return Err(callback_writer_error());
687 }
688 webui_handler::append_boolean_attribute_to_string(&mut self.buffer, name);
689 if self.buffer.len() >= STREAM_CHUNK_SIZE {
690 self.flush()?;
691 }
692 Ok(())
693 }
694
695 fn end(&mut self) -> webui_handler::Result<()> {
696 self.flush()
697 }
698}
699
700#[cold]
701#[inline(never)]
702fn callback_writer_error() -> HandlerError {
703 HandlerError::Writer("Node chunk callback failed".to_owned())
704}
705
706fn render_to_callback(
707 handler: &WebUIHandler,
708 protocol: &HandlerProtocol,
709 state: &Value,
710 options: &RenderOptions<'_>,
711 on_chunk: &Function<String>,
712) -> napi::Result<()> {
713 let mut writer = CallbackWriter::new(|chunk| on_chunk.call(chunk).map(drop));
714 let render_result = handler.render(protocol, state, options, &mut writer);
715 if let Some(error) = writer.error {
716 return Err(error);
717 }
718 render_result.map_err(|e| NapiError::from_reason(format!("Render error: {e}")))
719}
720
721#[cfg(test)]
722mod tests {
723 use super::*;
724 use std::collections::BTreeMap;
725 use webui_parser::HtmlParser;
726 use webui_protocol::projection_manifest::{
727 ProjectionAdapter, ProjectionComponent, ProjectionManifest, ProjectionProducer,
728 PRODUCER_NAME, SCHEMA_ID,
729 };
730
731 fn build_protocol(html: &str) -> Vec<u8> {
733 let mut parser = HtmlParser::new();
734 parser.parse("index.html", html).expect("parse failed");
735 let tokens = parser.take_tokens();
736 let protocol = WebUIProtocol::with_tokens(parser.into_fragment_records(), tokens);
737 protocol.to_protobuf().expect("protobuf encode failed")
738 }
739
740 fn render_to_string(protocol_bytes: &[u8], state_json: &str) -> Result<String, String> {
742 let protocol = HandlerProtocol::from_protobuf(protocol_bytes).map_err(|e| e.to_string())?;
743 let state: Value = serde_json::from_str(state_json).map_err(|e| e.to_string())?;
744
745 let mut output = String::with_capacity(1024);
746 let handler = WebUIHandler::new();
747
748 struct StringWriter<'a> {
749 output: &'a mut String,
750 }
751 impl ResponseWriter for StringWriter<'_> {
752 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
753 self.output.push_str(content);
754 Ok(())
755 }
756 fn end(&mut self) -> webui_handler::Result<()> {
757 Ok(())
758 }
759 }
760
761 let mut writer = StringWriter {
762 output: &mut output,
763 };
764 handler
765 .render(
766 &protocol,
767 &state,
768 &RenderOptions::new("index.html", "/"),
769 &mut writer,
770 )
771 .map_err(|e| e.to_string())?;
772 Ok(output)
773 }
774
775 #[test]
776 fn learned_render_capacity_avoids_string_growth_without_changing_bytes() {
777 struct ReallocationWriter {
778 output: String,
779 reallocations: usize,
780 }
781
782 impl ResponseWriter for ReallocationWriter {
783 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
784 let previous_capacity = self.output.capacity();
785 self.output.push_str(content);
786 self.reallocations += usize::from(self.output.capacity() != previous_capacity);
787 Ok(())
788 }
789
790 fn end(&mut self) -> webui_handler::Result<()> {
791 Ok(())
792 }
793 }
794
795 fn render_with_capacity(protocol: &HandlerProtocol, capacity: usize) -> (String, usize) {
796 let mut writer = ReallocationWriter {
797 output: String::with_capacity(capacity),
798 reallocations: 0,
799 };
800 WebUIHandler::new()
801 .render(
802 protocol,
803 &Value::Null,
804 &RenderOptions::new("index.html", "/"),
805 &mut writer,
806 )
807 .expect("render should succeed");
808 (writer.output, writer.reallocations)
809 }
810
811 let source = format!("<main>{}</main>", "<p>capacity</p>".repeat(1024));
812 let protocol =
813 HandlerProtocol::from_protobuf(&build_protocol(&source)).expect("protocol should load");
814 let (cold_output, cold_reallocations) = render_with_capacity(
815 &protocol,
816 RenderCapacityHints::new().load("index.html", "/"),
817 );
818 let (warm_output, warm_reallocations) = render_with_capacity(&protocol, cold_output.len());
819
820 assert!(cold_reallocations > 0);
821 assert_eq!(warm_reallocations, 0);
822 assert_eq!(warm_output, cold_output);
823 }
824
825 #[test]
826 fn test_simple_passthrough() {
827 let proto = build_protocol("<p>Hello</p>");
828 let result = render_to_string(&proto, "{}");
829 assert_eq!(result.as_deref(), Ok("<p>Hello</p>"));
830 }
831
832 #[test]
833 fn test_signal_substitution() {
834 let proto = build_protocol("Hello, {{name}}!");
835 let result = render_to_string(&proto, r#"{"name": "WebUI"}"#);
836 assert_eq!(result.as_deref(), Ok("Hello, WebUI!"));
837 }
838
839 #[test]
840 fn protocol_reuses_decoded_protocol_for_json_state() {
841 let proto = build_protocol("Hello, {{name}}!");
842 let protocol = Protocol::new(Buffer::from(proto), None).expect("protocol should load");
843
844 let first = protocol
845 .render(
846 r#"{"name":"First"}"#.to_string(),
847 "index.html".to_string(),
848 "/".to_string(),
849 )
850 .expect("first render should succeed");
851 let second = protocol
852 .render(
853 r#"{"name":"世界"}"#.to_string(),
854 "index.html".to_string(),
855 "/".to_string(),
856 )
857 .expect("second render should succeed");
858
859 assert_eq!(first.as_ref(), b"Hello, First!");
860 assert_eq!(second.as_ref(), "Hello, 世界!".as_bytes());
861 }
862
863 #[test]
864 fn streaming_steps_preserve_key_types_and_checkpoint_segments() {
865 let proto = build_protocol(concat!(
866 "<html><head></head><body>",
867 r#"<boundary name="first" key="{{firstId}}"><p>{{firstLabel}}</p></boundary>"#,
868 "<span>between</span>",
869 r#"<boundary name="second" key="{{secondId}}"><p>{{secondLabel}}</p></boundary>"#,
870 "<footer>tail</footer>",
871 "</body></html>",
872 ));
873 let protocol = Protocol::new(Buffer::from(proto), None).expect("protocol should load");
874 let mut session = protocol
875 .stream_response("index.html".to_string(), "/".to_string(), None)
876 .expect("session should open");
877 let state = r#"{"firstId":"alpha","firstLabel":"a","secondId":20,"secondLabel":"b"}"#;
878
879 let first = session
880 .start(state.to_string())
881 .expect("start should discover first boundary");
882 assert!(!first.done);
883 assert!(!first.bytes.is_empty());
884 let first_boundary = first.boundary.expect("first boundary should be returned");
885 assert_eq!(first_boundary.instance_id, 0);
886 assert_eq!(first_boundary.declaration_id, 0);
887 assert_eq!(first_boundary.owner, "index.html");
888 assert_eq!(first_boundary.name, "first");
889 assert!(matches!(
890 first_boundary.key,
891 Some(Either::A(value)) if value == "alpha"
892 ));
893
894 let resumed = session
895 .resume(
896 first_boundary.instance_id,
897 state.to_string(),
898 Some("final".to_string()),
899 )
900 .expect("resume should commit first boundary");
901 assert!(!resumed.done);
902 assert!(resumed.boundary.is_none());
903 let resumed_text =
904 std::str::from_utf8(&resumed.bytes).expect("resume output should be UTF-8");
905 assert!(resumed_text.contains(">a<"));
906 assert!(!resumed_text.contains("between"));
907
908 let next = session
909 .advance()
910 .expect("advance should discover second boundary");
911 assert!(!next.done);
912 let next_text = std::str::from_utf8(&next.bytes).expect("advance output should be UTF-8");
913 assert!(next_text.contains("between"));
914 assert!(!next_text.contains(">b<"));
915 let second_boundary = next.boundary.expect("second boundary should be returned");
916 assert_eq!(second_boundary.instance_id, 1);
917 assert_eq!(second_boundary.declaration_id, 1);
918 assert_eq!(second_boundary.name, "second");
919 assert!(matches!(
920 second_boundary.key,
921 Some(Either::B(value)) if value == 20.0
922 ));
923
924 let resumed = session
925 .resume(second_boundary.instance_id, state.to_string(), None)
926 .expect("resume should commit second boundary");
927 assert!(!resumed.done);
928 assert!(resumed.boundary.is_none());
929 let resumed_text =
930 std::str::from_utf8(&resumed.bytes).expect("resume output should be UTF-8");
931 assert!(resumed_text.contains(">b<"));
932 assert!(!resumed_text.contains("tail"));
933
934 let done = session.advance().expect("final advance should complete");
935 assert!(done.done);
936 assert!(done.boundary.is_none());
937 assert!(std::str::from_utf8(&done.bytes)
938 .expect("advance output should be UTF-8")
939 .contains("tail"));
940 }
941
942 #[test]
943 fn streaming_update_returns_buffer_for_updatable_occurrence() {
944 let proto = build_protocol(concat!(
945 "<html><head></head><body>",
946 r#"<boundary name="first"><p>{{count}}</p></boundary>"#,
947 r#"<boundary name="second"><p>done</p></boundary>"#,
948 "</body></html>",
949 ));
950 let protocol = Protocol::new(Buffer::from(proto), None).expect("protocol should load");
951 let mut session = protocol
952 .stream_response("index.html".to_string(), "/".to_string(), None)
953 .expect("session should open");
954 let first = session
955 .start(r#"{"count":1}"#.to_string())
956 .expect("start should discover first boundary")
957 .boundary
958 .expect("first boundary should be returned");
959 let resumed = session
960 .resume(
961 first.instance_id,
962 r#"{"count":1}"#.to_string(),
963 Some("updatable".to_string()),
964 )
965 .expect("resume should commit updatable boundary");
966 assert!(!resumed.done);
967 assert!(resumed.boundary.is_none());
968
969 let update = session
970 .update(first.instance_id, r#"{"count":2}"#.to_string())
971 .expect("update should render");
972 assert!(!update.is_empty());
973 let update_text = std::str::from_utf8(&update).expect("update should be UTF-8");
974 assert!(update_text.contains(r#""count":2"#));
975
976 let second = session
977 .advance()
978 .expect("advance should discover second boundary")
979 .boundary
980 .expect("second boundary should be returned");
981 let resumed = session
982 .resume(second.instance_id, "{}".to_string(), None)
983 .expect("second resume should commit boundary");
984 assert!(!resumed.done);
985 assert!(resumed.boundary.is_none());
986 let done = session.advance().expect("final advance should complete");
987 assert!(done.done);
988 }
989
990 #[test]
991 fn streaming_advance_rejects_out_of_order_calls() {
992 let proto = build_protocol(concat!(
993 "<html><head></head><body>",
994 r#"<boundary name="first"><p>first</p></boundary>"#,
995 "</body></html>",
996 ));
997 let protocol = Protocol::new(Buffer::from(proto), None).expect("protocol should load");
998 let mut session = protocol
999 .stream_response("index.html".to_string(), "/".to_string(), None)
1000 .expect("session should open");
1001
1002 let before_start = session
1003 .advance()
1004 .err()
1005 .expect("advance before start should fail");
1006 assert!(before_start
1007 .to_string()
1008 .contains("start must be called before this operation"));
1009
1010 let start = session
1011 .start("{}".to_string())
1012 .expect("start should succeed");
1013 let before_resume = session
1014 .advance()
1015 .err()
1016 .expect("advance before resume should fail");
1017 assert!(before_resume
1018 .to_string()
1019 .contains("there is no committed boundary to advance past"));
1020
1021 let boundary = start.boundary.expect("first boundary should be returned");
1022 session
1023 .resume(boundary.instance_id, "{}".to_string(), None)
1024 .expect("resume should still succeed after rejected advance");
1025 assert!(session.advance().expect("advance should complete").done);
1026 }
1027
1028 #[test]
1029 fn streaming_start_returns_done_for_boundary_free_document() {
1030 let proto = build_protocol("<html><head></head><body><p>done</p></body></html>");
1031 let protocol = Protocol::new(Buffer::from(proto), None).expect("protocol should load");
1032 let mut session = protocol
1033 .stream_response("index.html".to_string(), "/".to_string(), None)
1034 .expect("session should open");
1035
1036 let step = session
1037 .start("{}".to_string())
1038 .expect("boundary-free start should complete");
1039 assert!(step.done);
1040 assert!(step.boundary.is_none());
1041 assert!(std::str::from_utf8(&step.bytes)
1042 .expect("output should be UTF-8")
1043 .contains("<p>done</p>"));
1044 }
1045
1046 #[test]
1047 fn test_for_loop() {
1048 let proto = build_protocol("<ul><for each=\"item in items\"><li>{{item}}</li></for></ul>");
1049 let result = render_to_string(&proto, r#"{"items": ["a", "b", "c"]}"#);
1050 assert_eq!(
1051 result.as_deref(),
1052 Ok("<ul><li>a</li><li>b</li><li>c</li></ul>")
1053 );
1054 }
1055
1056 #[test]
1057 fn test_if_condition_true() {
1058 let proto = build_protocol("<if condition=\"show\"><p>Visible</p></if>");
1059 let result = render_to_string(&proto, r#"{"show": true}"#);
1060 assert_eq!(result.as_deref(), Ok("<p>Visible</p>"));
1061 }
1062
1063 #[test]
1064 fn test_if_condition_false() {
1065 let proto = build_protocol("<if condition=\"show\"><p>Hidden</p></if>");
1066 let result = render_to_string(&proto, r#"{"show": false}"#);
1067 assert_eq!(result.as_deref(), Ok(""));
1068 }
1069
1070 #[test]
1071 fn test_html_escaping() {
1072 let proto = build_protocol("<div>{{content}}</div>");
1073 let state = r#"{"content": "<script>alert('xss')</script>"}"#;
1074 let result = render_to_string(&proto, state).expect("render should succeed");
1075 assert!(!result.contains("<script>"));
1076 assert!(result.contains("<script>"));
1077 }
1078
1079 #[test]
1080 fn test_raw_signal() {
1081 let proto = build_protocol("<div>{{{content}}}</div>");
1082 let result = render_to_string(&proto, r#"{"content": "<b>bold</b>"}"#);
1083 assert_eq!(result.as_deref(), Ok("<div><b>bold</b></div>"));
1084 }
1085
1086 #[test]
1087 fn test_invalid_json() {
1088 let proto = build_protocol("<p>hi</p>");
1089 let result = render_to_string(&proto, "NOT JSON");
1090 assert!(result.is_err());
1091 }
1092
1093 #[test]
1094 fn test_empty_state() {
1095 let proto = build_protocol("<p>static</p>");
1096 let result = render_to_string(&proto, "{}");
1097 assert_eq!(result.as_deref(), Ok("<p>static</p>"));
1098 }
1099
1100 #[test]
1101 fn test_nested_object_signal() {
1102 let proto = build_protocol("{{user.name}}");
1103 let result = render_to_string(&proto, r#"{"user": {"name": "Alice"}}"#);
1104 assert_eq!(result.as_deref(), Ok("Alice"));
1105 }
1106
1107 #[test]
1108 fn test_invalid_protobuf() {
1109 let result = render_to_string(&[0xFF, 0xFF, 0xFF], "{}");
1110 assert!(result.is_err());
1111 }
1112
1113 fn build_projected_protocol(html: &str, schema: &[&str]) -> Vec<u8> {
1115 let mut parser = HtmlParser::new();
1116 parser.parse("index.html", html).expect("parse failed");
1117 let tokens = parser.take_tokens();
1118 let mut protocol = WebUIProtocol::with_tokens(parser.into_fragment_records(), tokens);
1119 protocol.fragments.insert(
1120 "client-card".to_string(),
1121 webui_protocol::FragmentList {
1122 fragments: vec![webui_protocol::WebUIFragment::raw("<p>client</p>")],
1123 contains_boundary: false,
1124 },
1125 );
1126 protocol
1127 .fragments
1128 .get_mut("index.html")
1129 .expect("index fragment should exist")
1130 .fragments
1131 .insert(1, webui_protocol::WebUIFragment::component("client-card"));
1132 protocol.initial_state_strategy = webui_protocol::InitialStateStrategy::Components as i32;
1133 protocol.components.insert(
1134 "client-card".to_string(),
1135 webui_protocol::ComponentData {
1136 hydration_mode: webui_protocol::StateProjectionMode::Keys as i32,
1137 hydration_keys: schema.iter().map(|key| (*key).to_string()).collect(),
1138 ..Default::default()
1139 },
1140 );
1141 protocol.to_protobuf().expect("protobuf encode failed")
1142 }
1143
1144 fn render_with_webui_plugin(protocol_bytes: &[u8], state_json: &str) -> Result<String, String> {
1148 let protocol = HandlerProtocol::from_protobuf(protocol_bytes).map_err(|e| e.to_string())?;
1149 let state: Value = serde_json::from_str(state_json).map_err(|e| e.to_string())?;
1150
1151 let mut output = String::with_capacity(1024);
1152 struct StringWriter<'a> {
1153 output: &'a mut String,
1154 }
1155 impl ResponseWriter for StringWriter<'_> {
1156 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
1157 self.output.push_str(content);
1158 Ok(())
1159 }
1160 fn end(&mut self) -> webui_handler::Result<()> {
1161 Ok(())
1162 }
1163 }
1164 let mut writer = StringWriter {
1165 output: &mut output,
1166 };
1167 let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
1168 handler
1169 .render(
1170 &protocol,
1171 &state,
1172 &RenderOptions::new("index.html", "/"),
1173 &mut writer,
1174 )
1175 .map_err(|e| e.to_string())?;
1176 Ok(output)
1177 }
1178
1179 #[test]
1180 fn render_projects_state_to_component_hydration_keys() {
1181 let bytes =
1184 build_projected_protocol("<html><body><p>{{kept}}</p></body></html>", &["kept"]);
1185 let out =
1186 render_with_webui_plugin(&bytes, r#"{"kept":"KEPT_VALUE","dropped":"DROPPED_VALUE"}"#)
1187 .expect("render should succeed");
1188
1189 assert!(
1191 out.contains(r#""kept":"KEPT_VALUE""#),
1192 "hydratable key missing from bootstrap state: {out}"
1193 );
1194 assert!(
1196 !out.contains("DROPPED_VALUE"),
1197 "server-only value leaked: {out}"
1198 );
1199 assert!(
1200 !out.contains("dropped"),
1201 "server-only key name leaked: {out}"
1202 );
1203 }
1204
1205 fn projection_manifest_json() -> String {
1208 const EMPTY_SHA256: &str =
1209 "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
1210 let mut manifest = ProjectionManifest {
1211 schema: SCHEMA_ID.to_string(),
1212 producer: ProjectionProducer {
1213 name: PRODUCER_NAME.to_string(),
1214 version: "0.0.18".to_string(),
1215 },
1216 adapter: ProjectionAdapter {
1217 name: "test".to_string(),
1218 bundler: "test@1.0.0".to_string(),
1219 },
1220 root: ".".to_string(),
1221 analysis_hash: format!("sha256:{}", "1".repeat(64)),
1222 build_id: String::new(),
1223 inputs: BTreeMap::from([("demo-card.ts".to_string(), EMPTY_SHA256.to_string())]),
1224 outputs: BTreeMap::from([("bundle.js".to_string(), EMPTY_SHA256.to_string())]),
1225 components: BTreeMap::from([(
1226 "demo-card".to_string(),
1227 ProjectionComponent {
1228 module: "demo-card.ts".to_string(),
1229 outputs: vec!["bundle.js".to_string()],
1230 hydration_keys: vec!["name".to_string()],
1231 navigation_keys: vec!["label".to_string(), "name".to_string()],
1232 },
1233 )]),
1234 entry_closures: BTreeMap::new(),
1235 };
1236 manifest.build_id = manifest.compute_build_id();
1237 serde_json::to_string(&manifest).unwrap()
1238 }
1239
1240 fn projection_build_options(app_dir: &std::path::Path) -> JsBuildOptions {
1241 JsBuildOptions {
1242 app_dir: app_dir.to_string_lossy().to_string(),
1243 entry: None,
1244 css: None,
1245 dom: None,
1246 css_bundle: None,
1247 plugin: Some("webui".to_string()),
1248 components: None,
1249 component_asset_roots: None,
1250 metafile: None,
1251 css_file_name_template: None,
1252 css_public_base: None,
1253 legal_comments: None,
1254 theme: None,
1255 projection_manifests: None,
1256 projection_manifest_objects: None,
1257 }
1258 }
1259
1260 #[test]
1261 fn test_build_accepts_projection_paths_and_inline_objects() {
1262 let dir = tempfile::TempDir::new().unwrap();
1263 std::fs::write(dir.path().join("index.html"), "<demo-card></demo-card>").unwrap();
1264 std::fs::write(dir.path().join("demo-card.html"), "<p>{{name}}</p>").unwrap();
1265 std::fs::write(dir.path().join("demo-card.ts"), "").unwrap();
1266 std::fs::write(dir.path().join("bundle.js"), "").unwrap();
1267 let manifest_path = dir.path().join("projection.json");
1268 let json = projection_manifest_json();
1269 std::fs::write(&manifest_path, &json).unwrap();
1270
1271 let mut path_options = projection_build_options(dir.path());
1272 path_options.projection_manifests = Some(vec![manifest_path.to_string_lossy().to_string()]);
1273 let path_result = build(path_options).unwrap();
1274 let path_protocol = WebUIProtocol::from_protobuf(&path_result.protocol).unwrap();
1275 assert_eq!(
1276 path_protocol.components["demo-card"].hydration_keys,
1277 ["name"]
1278 );
1279
1280 std::fs::remove_file(&manifest_path).unwrap();
1281 let mut inline_options = projection_build_options(dir.path());
1282 inline_options.projection_manifest_objects = Some(vec![JsProjectionManifest {
1283 path: manifest_path.to_string_lossy().to_string(),
1284 json,
1285 }]);
1286 let inline_result = build(inline_options).unwrap();
1287 let inline_protocol = WebUIProtocol::from_protobuf(&inline_result.protocol).unwrap();
1288 assert_eq!(
1289 inline_protocol.components["demo-card"].navigation_keys,
1290 ["label", "name"]
1291 );
1292 }
1293
1294 #[test]
1295 fn test_build_simple_app() {
1296 let dir = tempfile::TempDir::new().unwrap();
1297 std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
1298
1299 let options = JsBuildOptions {
1300 app_dir: dir.path().to_string_lossy().to_string(),
1301 entry: None,
1302 css: None,
1303 dom: None,
1304 css_bundle: None,
1305 plugin: None,
1306 components: None,
1307 component_asset_roots: None,
1308 metafile: None,
1309 css_file_name_template: None,
1310 css_public_base: None,
1311 legal_comments: None,
1312 theme: None,
1313 projection_manifests: None,
1314 projection_manifest_objects: None,
1315 };
1316
1317 let result = build(options).unwrap();
1318 assert!(!result.protocol.is_empty());
1319 assert!(result.stats.fragment_count > 0);
1320 assert!(result.stats.protocol_size_bytes > 0);
1321 assert!(result.stats.duration_ms >= 0.0);
1322 }
1323
1324 #[test]
1325 fn test_build_with_custom_entry() {
1326 let dir = tempfile::TempDir::new().unwrap();
1327 std::fs::write(dir.path().join("page.html"), "<p>Custom</p>").unwrap();
1328
1329 let options = JsBuildOptions {
1330 app_dir: dir.path().to_string_lossy().to_string(),
1331 entry: Some("page.html".to_string()),
1332 css: None,
1333 dom: None,
1334 css_bundle: None,
1335 plugin: None,
1336 components: None,
1337 component_asset_roots: None,
1338 metafile: None,
1339 css_file_name_template: None,
1340 css_public_base: None,
1341 legal_comments: None,
1342 theme: None,
1343 projection_manifests: None,
1344 projection_manifest_objects: None,
1345 };
1346
1347 let result = build(options).unwrap();
1348 assert!(!result.protocol.is_empty());
1349 }
1350
1351 #[test]
1352 fn test_build_missing_app_dir() {
1353 let options = JsBuildOptions {
1354 app_dir: "/nonexistent/path".to_string(),
1355 entry: None,
1356 css: None,
1357 dom: None,
1358 css_bundle: None,
1359 plugin: None,
1360 components: None,
1361 component_asset_roots: None,
1362 metafile: None,
1363 css_file_name_template: None,
1364 css_public_base: None,
1365 legal_comments: None,
1366 theme: None,
1367 projection_manifests: None,
1368 projection_manifest_objects: None,
1369 };
1370
1371 let result = build(options);
1372 assert!(result.is_err());
1373 }
1374
1375 #[test]
1376 fn test_build_invalid_css() {
1377 let dir = tempfile::TempDir::new().unwrap();
1378 std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
1379
1380 let options = JsBuildOptions {
1381 app_dir: dir.path().to_string_lossy().to_string(),
1382 entry: None,
1383 css: Some("bogus".to_string()),
1384 dom: None,
1385 css_bundle: None,
1386 plugin: None,
1387 components: None,
1388 component_asset_roots: None,
1389 metafile: None,
1390 css_file_name_template: None,
1391 css_public_base: None,
1392 legal_comments: None,
1393 theme: None,
1394 projection_manifests: None,
1395 projection_manifest_objects: None,
1396 };
1397
1398 let result = build(options);
1399 assert!(result.is_err());
1400 }
1401
1402 #[test]
1403 fn test_build_invalid_dom_strategy() {
1404 let dir = tempfile::TempDir::new().unwrap();
1405 std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
1406
1407 let options = JsBuildOptions {
1408 app_dir: dir.path().to_string_lossy().to_string(),
1409 entry: None,
1410 css: None,
1411 dom: Some("closed".to_string()),
1412 css_bundle: None,
1413 plugin: None,
1414 components: None,
1415 component_asset_roots: None,
1416 metafile: None,
1417 css_file_name_template: None,
1418 css_public_base: None,
1419 legal_comments: None,
1420 theme: None,
1421 projection_manifests: None,
1422 projection_manifest_objects: None,
1423 };
1424
1425 assert!(build(options).is_err());
1426 }
1427
1428 #[test]
1429 fn test_build_with_components_css() {
1430 let dir = tempfile::TempDir::new().unwrap();
1431 std::fs::write(dir.path().join("index.html"), "<my-card>Hello</my-card>").unwrap();
1432 std::fs::write(dir.path().join("my-card.html"), "<div>content</div>").unwrap();
1433 std::fs::write(dir.path().join("my-card.css"), ".card { color: red; }").unwrap();
1434
1435 let options = JsBuildOptions {
1436 app_dir: dir.path().to_string_lossy().to_string(),
1437 entry: None,
1438 css: Some("link".to_string()),
1439 dom: None,
1440 css_bundle: None,
1441 plugin: None,
1442 components: None,
1443 component_asset_roots: None,
1444 metafile: None,
1445 css_file_name_template: None,
1446 css_public_base: None,
1447 legal_comments: None,
1448 theme: None,
1449 projection_manifests: None,
1450 projection_manifest_objects: None,
1451 };
1452
1453 let result = build(options).unwrap();
1454 assert_eq!(result.css_files.len(), 2);
1456 assert_eq!(result.css_files[0], "my-card.css");
1457 assert!(result.css_files[1].contains("color: red"));
1458 assert_eq!(result.stats.css_file_count, 1);
1459 }
1460
1461 #[test]
1462 fn test_build_with_theme_missing_token_errors() {
1463 let dir = tempfile::TempDir::new().unwrap();
1464 std::fs::write(dir.path().join("index.html"), "<my-card></my-card>").unwrap();
1465 std::fs::write(dir.path().join("my-card.html"), "<div>Card</div>").unwrap();
1466 std::fs::write(
1467 dir.path().join("my-card.css"),
1468 ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c))); }",
1469 )
1470 .unwrap();
1471 let theme_path = dir.path().join("theme.json");
1472 std::fs::write(&theme_path, r#"{"themes":{"light":{"token-b":"green"}}}"#).unwrap();
1473
1474 let options = JsBuildOptions {
1475 app_dir: dir.path().to_string_lossy().to_string(),
1476 entry: None,
1477 css: Some("link".to_string()),
1478 dom: None,
1479 css_bundle: None,
1480 plugin: None,
1481 components: None,
1482 component_asset_roots: None,
1483 metafile: None,
1484 css_file_name_template: None,
1485 css_public_base: None,
1486 legal_comments: None,
1487 theme: Some(theme_path.to_string_lossy().to_string()),
1488 projection_manifests: None,
1489 projection_manifest_objects: None,
1490 };
1491
1492 let Err(err) = build(options) else {
1493 panic!("missing theme token must fail");
1494 };
1495 let message = err.to_string();
1496 assert!(message.contains("missing-theme-token"), "msg: {message}");
1497 assert!(message.contains("--token-c"), "msg: {message}");
1498 }
1499
1500 #[test]
1501 fn test_build_returns_component_asset_files() {
1502 let dir = tempfile::TempDir::new().unwrap();
1503 std::fs::write(dir.path().join("index.html"), "<app-shell></app-shell>").unwrap();
1504 std::fs::write(dir.path().join("app-shell.html"), "<div></div>").unwrap();
1505 std::fs::write(dir.path().join("lazy-panel.html"), "<p>{{title}}</p>").unwrap();
1506 std::fs::write(dir.path().join("lazy-panel.ts"), "export {};").unwrap();
1507
1508 let options = JsBuildOptions {
1509 app_dir: dir.path().to_string_lossy().to_string(),
1510 entry: None,
1511 css: Some("link".to_string()),
1512 dom: None,
1513 css_bundle: None,
1514 plugin: Some("webui".to_string()),
1515 components: None,
1516 component_asset_roots: Some(vec!["lazy-panel".to_string()]),
1517 metafile: Some(true),
1518 css_file_name_template: None,
1519 css_public_base: None,
1520 legal_comments: None,
1521 theme: None,
1522 projection_manifests: None,
1523 projection_manifest_objects: None,
1524 };
1525
1526 let result = build(options).unwrap();
1527
1528 assert_eq!(result.component_asset_files.len(), 4);
1529 assert_eq!(result.component_asset_files[0], "lazy-panel.webui.js");
1530 assert!(result.component_asset_files[1].contains("webui-component-asset"));
1531 assert!(result.component_asset_files[1].contains("export default asset;"));
1532 assert!(!result.component_asset_files[1].contains("defineComponentAsset"));
1533 assert!(result.component_asset_files[2].starts_with("components/lazy-panel."));
1534 assert!(result.component_asset_files[2].ends_with(".webui.js"));
1535 assert!(result.component_asset_files[3].contains("export default asset;"));
1536 let metafile = result
1537 .metafile
1538 .as_deref()
1539 .expect("requested metafile must be returned");
1540 let parsed: serde_json::Value = serde_json::from_str(metafile).unwrap();
1541 assert!(parsed["inputs"].get("webui:component/lazy-panel").is_some());
1542 assert!(parsed["outputs"].get("lazy-panel.webui.js").is_some());
1543 }
1544
1545 #[test]
1546 fn test_build_legal_comments_none_strips_legal_css() {
1547 let dir = tempfile::TempDir::new().unwrap();
1548 std::fs::write(dir.path().join("index.html"), "<my-card>Hello</my-card>").unwrap();
1549 std::fs::write(
1550 dir.path().join("my-card.html"),
1551 r#"<template shadowrootmode="open"><div>content</div></template>"#,
1552 )
1553 .unwrap();
1554 std::fs::write(
1555 dir.path().join("my-card.css"),
1556 "/*! @license MIT */ .card { color: red; }",
1557 )
1558 .unwrap();
1559
1560 let options = JsBuildOptions {
1561 app_dir: dir.path().to_string_lossy().to_string(),
1562 entry: None,
1563 css: Some("link".to_string()),
1564 dom: None,
1565 css_bundle: None,
1566 plugin: None,
1567 components: None,
1568 component_asset_roots: None,
1569 metafile: None,
1570 css_file_name_template: None,
1571 css_public_base: None,
1572 legal_comments: Some("none".to_string()),
1573 theme: None,
1574 projection_manifests: None,
1575 projection_manifest_objects: None,
1576 };
1577
1578 let result = build(options).unwrap();
1579 assert_eq!(result.css_files[1], " .card { color: red; }");
1580 }
1581
1582 #[test]
1583 fn test_build_invalid_legal_comments() {
1584 let dir = tempfile::TempDir::new().unwrap();
1585 std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
1586
1587 let options = JsBuildOptions {
1588 app_dir: dir.path().to_string_lossy().to_string(),
1589 entry: None,
1590 css: None,
1591 dom: None,
1592 css_bundle: None,
1593 plugin: None,
1594 components: None,
1595 component_asset_roots: None,
1596 metafile: None,
1597 css_file_name_template: None,
1598 css_public_base: None,
1599 legal_comments: Some("linked".to_string()),
1600 theme: None,
1601 projection_manifests: None,
1602 projection_manifest_objects: None,
1603 };
1604
1605 let result = build(options);
1606 assert!(result.is_err());
1607 }
1608
1609 #[test]
1610 fn test_build_with_unwrapped_component_defaults_to_shadow() {
1611 let dir = tempfile::TempDir::new().unwrap();
1612 std::fs::write(dir.path().join("index.html"), "<my-card>Hi</my-card>").unwrap();
1613 std::fs::write(dir.path().join("my-card.html"), "<div>content</div>").unwrap();
1614
1615 let options = JsBuildOptions {
1616 app_dir: dir.path().to_string_lossy().to_string(),
1617 entry: None,
1618 css: None,
1619 dom: None,
1620 css_bundle: None,
1621 plugin: None,
1622 components: None,
1623 component_asset_roots: None,
1624 metafile: None,
1625 css_file_name_template: None,
1626 css_public_base: None,
1627 legal_comments: None,
1628 theme: None,
1629 projection_manifests: None,
1630 projection_manifest_objects: None,
1631 };
1632
1633 let result = build(options).unwrap();
1634 let json = inspect(result.protocol).unwrap();
1635 assert!(json.contains("shadowrootmode"));
1636 }
1637
1638 #[test]
1639 fn test_build_with_light_dom_omits_generated_shadow_root() {
1640 let dir = tempfile::TempDir::new().unwrap();
1641 std::fs::write(dir.path().join("index.html"), "<my-card>Hi</my-card>").unwrap();
1642 std::fs::write(dir.path().join("my-card.html"), "<div>content</div>").unwrap();
1643
1644 let options = JsBuildOptions {
1645 app_dir: dir.path().to_string_lossy().to_string(),
1646 entry: None,
1647 css: None,
1648 dom: Some("light".to_string()),
1649 css_bundle: None,
1650 plugin: None,
1651 components: None,
1652 component_asset_roots: None,
1653 metafile: None,
1654 css_file_name_template: None,
1655 css_public_base: None,
1656 legal_comments: None,
1657 theme: None,
1658 projection_manifests: None,
1659 projection_manifest_objects: None,
1660 };
1661
1662 let result = build(options).unwrap();
1663 let json = inspect(result.protocol).unwrap();
1664 assert!(!json.contains("shadowrootmode"));
1665 }
1666
1667 #[test]
1668 fn test_build_with_authored_shadow_dom_preserves_shadow_root_template() {
1669 let dir = tempfile::TempDir::new().unwrap();
1670 std::fs::write(dir.path().join("index.html"), "<my-card>Hi</my-card>").unwrap();
1671 std::fs::write(
1672 dir.path().join("my-card.html"),
1673 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
1674 )
1675 .unwrap();
1676
1677 let options = JsBuildOptions {
1678 app_dir: dir.path().to_string_lossy().to_string(),
1679 entry: None,
1680 css: None,
1681 dom: None,
1682 css_bundle: None,
1683 plugin: None,
1684 components: None,
1685 component_asset_roots: None,
1686 metafile: None,
1687 css_file_name_template: None,
1688 css_public_base: None,
1689 legal_comments: None,
1690 theme: None,
1691 projection_manifests: None,
1692 projection_manifest_objects: None,
1693 };
1694
1695 let result = build(options).unwrap();
1696 let json = inspect(result.protocol).unwrap();
1697 assert!(
1698 json.contains("shadowrootmode"),
1699 "authored Shadow DOM wrapper should be preserved, got: {json}"
1700 );
1701 }
1702
1703 #[test]
1704 fn test_build_rejects_invalid_shadow_wrapper() {
1705 let dir = tempfile::TempDir::new().unwrap();
1706 std::fs::write(dir.path().join("index.html"), "<my-card></my-card>").unwrap();
1707 std::fs::write(
1708 dir.path().join("my-card.html"),
1709 r#"<template shadowrootmode="closed"><p>invalid</p></template>"#,
1710 )
1711 .unwrap();
1712
1713 let options = JsBuildOptions {
1714 app_dir: dir.path().to_string_lossy().to_string(),
1715 entry: None,
1716 css: None,
1717 dom: None,
1718 css_bundle: None,
1719 plugin: None,
1720 components: None,
1721 component_asset_roots: None,
1722 metafile: None,
1723 css_file_name_template: None,
1724 css_public_base: None,
1725 legal_comments: None,
1726 theme: None,
1727 projection_manifests: None,
1728 projection_manifest_objects: None,
1729 };
1730
1731 let result = build(options);
1732 assert!(result.is_err());
1733 }
1734
1735 #[test]
1736 fn test_inspect_valid_protocol() {
1737 let proto = build_protocol("<h1>Hello {{name}}</h1>");
1738 let json = inspect(napi::bindgen_prelude::Buffer::from(proto)).unwrap();
1739 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1740 assert!(parsed.get("fragments").is_some());
1741 }
1742
1743 #[test]
1744 fn test_inspect_invalid_protocol() {
1745 let result = inspect(napi::bindgen_prelude::Buffer::from(vec![0xFF, 0xFF]));
1746 assert!(result.is_err());
1747 }
1748
1749 #[test]
1750 fn test_protocol_tokens_empty() {
1751 let proto = build_protocol("<p>Hello</p>");
1752 let protocol = Protocol::new(Buffer::from(proto), None).unwrap();
1753 let tokens = protocol.tokens();
1754 assert!(tokens.is_empty());
1755 }
1756
1757 #[test]
1758 fn test_protocol_tokens_returns_parsed_tokens() {
1759 let mut parser = HtmlParser::new();
1761 parser.parse("index.html", "<p>Hi</p>").expect("parse");
1762 let protocol = WebUIProtocol::with_tokens(
1763 parser.into_fragment_records(),
1764 vec![
1765 "colorBrandBackground".to_string(),
1766 "fontSizeBase300".to_string(),
1767 ],
1768 );
1769 let proto = protocol.to_protobuf().expect("encode");
1770 let protocol = Protocol::new(Buffer::from(proto), None).unwrap();
1771 let tokens = protocol.tokens();
1772 assert_eq!(tokens, vec!["colorBrandBackground", "fontSizeBase300"]);
1773 }
1774
1775 #[test]
1776 fn test_protocol_tokens_invalid_protobuf() {
1777 let result = Protocol::new(Buffer::from(vec![0xFF, 0xFF]), None);
1778 assert!(result.is_err());
1779 }
1780}