1use napi::bindgen_prelude::{Buffer, Function};
32use napi::Error as NapiError;
33use napi_derive::napi;
34use serde_json::Value;
35use webui_handler::plugin::fast_v2::FastV2HydrationPlugin;
36use webui_handler::plugin::fast_v3::FastV3HydrationPlugin;
37use webui_handler::plugin::webui::WebUIHydrationPlugin;
38use webui_handler::{RenderOptions, ResponseWriter, WebUIHandler};
39use webui_protocol::WebUIProtocol;
40
41#[napi(object)]
43pub struct JsBuildStats {
44 pub duration_ms: f64,
46 pub fragment_count: u32,
48 pub component_count: u32,
50 pub css_file_count: u32,
52 pub protocol_size_bytes: u32,
54 pub token_count: u32,
56}
57
58#[napi(object)]
60pub struct JsBuildResult {
61 pub protocol: Buffer,
63 pub css_files: Vec<String>,
65 pub component_asset_files: Vec<String>,
67 pub warnings: Vec<String>,
70 pub stats: JsBuildStats,
72}
73
74#[napi(object)]
76pub struct JsBuildOptions {
77 pub app_dir: String,
79 pub entry: Option<String>,
81 pub css: Option<String>,
83 pub dom: Option<String>,
85 pub plugin: Option<String>,
87 pub components: Option<Vec<String>>,
89 pub component_asset_roots: Option<Vec<String>>,
91 pub css_file_name_template: Option<String>,
93 pub css_public_base: Option<String>,
95 pub legal_comments: Option<String>,
97 pub theme: Option<String>,
99}
100
101#[napi]
105#[allow(clippy::cast_possible_truncation)] pub fn build(options: JsBuildOptions) -> napi::Result<JsBuildResult> {
107 let css = options
108 .css
109 .map(|s| s.parse::<webui::CssStrategy>())
110 .transpose()
111 .map_err(NapiError::from_reason)?
112 .unwrap_or_default();
113
114 let dom = options
115 .dom
116 .map(|s| s.parse::<webui::DomStrategy>())
117 .transpose()
118 .map_err(NapiError::from_reason)?
119 .unwrap_or_default();
120
121 let plugin = options
122 .plugin
123 .map(|s| s.parse::<webui::Plugin>())
124 .transpose()
125 .map_err(NapiError::from_reason)?;
126
127 let legal_comments = options
128 .legal_comments
129 .map(|s| s.parse::<webui::LegalComments>())
130 .transpose()
131 .map_err(NapiError::from_reason)?
132 .unwrap_or_default();
133
134 let app_dir = std::path::PathBuf::from(&options.app_dir);
135 let theme = options
136 .theme
137 .as_deref()
138 .map(|theme| load_theme(theme, &app_dir))
139 .transpose()?;
140
141 let build_options = webui::BuildOptions {
142 app_dir,
143 entry: options.entry.unwrap_or_else(|| "index.html".to_string()),
144 css,
145 dom,
146 plugin,
147 components: options.components.unwrap_or_default(),
148 component_asset_roots: options.component_asset_roots.unwrap_or_default(),
149 css_file_name_template: options
150 .css_file_name_template
151 .unwrap_or_else(|| webui::DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string()),
152 css_public_base: options.css_public_base,
153 legal_comments,
154 theme,
155 };
156
157 let result = webui::build(build_options)
158 .map_err(|e| NapiError::from_reason(format!("Build error: {}", e.chain_message())))?;
159
160 let css_files: Vec<String> = result
162 .css_files
163 .into_iter()
164 .flat_map(|(name, content)| [name, content])
165 .collect();
166 let component_asset_files: Vec<String> = result
167 .component_asset_files
168 .into_iter()
169 .flat_map(|file| [file.name, file.content])
170 .collect();
171 let warnings: Vec<String> = result.warnings.iter().map(|d| d.to_string()).collect();
172
173 Ok(JsBuildResult {
174 protocol: Buffer::from(result.protocol_bytes),
175 css_files,
176 component_asset_files,
177 warnings,
178 stats: JsBuildStats {
179 duration_ms: result.stats.duration.as_secs_f64() * 1000.0,
180 fragment_count: result.stats.fragment_count as u32,
181 component_count: result.stats.component_count as u32,
182 css_file_count: result.stats.css_file_count as u32,
183 protocol_size_bytes: result.stats.protocol_size_bytes as u32,
184 token_count: result.stats.token_count as u32,
185 },
186 })
187}
188
189fn load_theme(theme: &str, search_root: &std::path::Path) -> napi::Result<webui::TokenFile> {
190 let resolved = webui::resolve_theme_path(theme, search_root)
191 .map_err(|e| NapiError::from_reason(format!("Theme resolution error: {e}")))?;
192 webui::load_token_file(&resolved).map_err(|e| {
193 NapiError::from_reason(format!("Theme load error for {}: {e}", resolved.display()))
194 })
195}
196
197#[napi]
199pub fn inspect(protocol_data: Buffer) -> napi::Result<String> {
200 webui::inspect_bytes(&protocol_data)
201 .map_err(|e| NapiError::from_reason(format!("Inspect error: {e}")))
202}
203
204#[napi]
212pub fn render_partial(
213 protocol_data: Buffer,
214 state_json: String,
215 entry_id: String,
216 request_path: String,
217 inventory_hex: String,
218) -> napi::Result<String> {
219 let protocol = WebUIProtocol::from_protobuf(&protocol_data)
220 .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
221
222 let state: serde_json::Value = serde_json::from_str(&state_json)
223 .map_err(|e| NapiError::from_reason(format!("invalid state JSON: {e}")))?;
224
225 let mut index = webui_handler::route_handler::ProtocolIndex::new(&protocol);
230
231 let mut result = webui_handler::route_handler::render_partial(
232 &protocol,
233 &entry_id,
234 &request_path,
235 &inventory_hex,
236 &mut index,
237 )
238 .map_err(|e| NapiError::from_reason(format!("render_partial failed: {e}")))?;
239 if let Some(obj) = result.as_object_mut() {
240 obj.insert("state".into(), state);
241 }
242
243 serde_json::to_string(&result)
244 .map_err(|e| NapiError::from_reason(format!("JSON serialize error: {e}")))
245}
246
247#[napi]
248pub fn render_component_templates(
249 protocol_data: Buffer,
250 component_tags_json: String,
251 inventory_hex: String,
252) -> napi::Result<String> {
253 let protocol = WebUIProtocol::from_protobuf(&protocol_data)
254 .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
255
256 let tags: Vec<String> = serde_json::from_str(&component_tags_json)
257 .map_err(|e| NapiError::from_reason(format!("invalid tags JSON: {e}")))?;
258 let tag_refs: Vec<&str> = tags.iter().map(|s| s.as_str()).collect();
259
260 let mut index = webui_handler::route_handler::ProtocolIndex::new(&protocol);
262
263 let result = webui_handler::route_handler::render_component_templates(
264 &protocol,
265 &tag_refs,
266 &inventory_hex,
267 &mut index,
268 )
269 .map_err(|e| NapiError::from_reason(format!("render_component_templates failed: {e}")))?;
270
271 serde_json::to_string(&result)
272 .map_err(|e| NapiError::from_reason(format!("JSON serialize error: {e}")))
273}
274
275#[napi]
280pub fn protocol_tokens(protocol_data: Buffer) -> napi::Result<Vec<String>> {
281 let protocol = WebUIProtocol::from_protobuf(&protocol_data)
282 .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
283
284 Ok(protocol.tokens)
285}
286
287struct CallbackWriter<'a, 'env> {
289 callback: &'a Function<'env, String, ()>,
290 error: Option<String>,
291}
292
293impl<'a, 'env> CallbackWriter<'a, 'env> {
294 fn new(callback: &'a Function<'env, String, ()>) -> Self {
295 Self {
296 callback,
297 error: None,
298 }
299 }
300}
301
302impl ResponseWriter for CallbackWriter<'_, '_> {
303 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
304 if self.error.is_some() {
305 return Ok(());
306 }
307 if let Err(e) = self.callback.call(content.to_owned()) {
308 let msg = format!("{e}");
311 if !msg.contains("Value is not undefined") {
312 self.error = Some(msg);
313 }
314 }
315 Ok(())
316 }
317
318 fn end(&mut self) -> webui_handler::Result<()> {
319 Ok(())
320 }
321}
322
323#[napi]
333#[allow(clippy::too_many_arguments)]
334pub fn render(
335 protocol_data: Buffer,
336 state_json: String,
337 entry: String,
338 request_path: String,
339 on_chunk: Function<String, ()>,
340 plugin: Option<String>,
341) -> napi::Result<()> {
342 let protocol = WebUIProtocol::from_protobuf(&protocol_data)
343 .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
344
345 let state: Value = serde_json::from_str(&state_json)
346 .map_err(|e| NapiError::from_reason(format!("State JSON error: {e}")))?;
347
348 let mut writer = CallbackWriter::new(&on_chunk);
349 let plugin = plugin
350 .map(|s| s.parse::<webui::Plugin>())
351 .transpose()
352 .map_err(NapiError::from_reason)?;
353 let handler = match plugin {
354 Some(webui::Plugin::Fast | webui::Plugin::FastV2) => {
355 WebUIHandler::with_plugin(|| Box::new(FastV2HydrationPlugin::new()))
356 }
357 Some(webui::Plugin::FastV3) => {
358 WebUIHandler::with_plugin(|| Box::new(FastV3HydrationPlugin::new()))
359 }
360 Some(webui::Plugin::WebUI) => {
361 WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()))
362 }
363 None => WebUIHandler::new(),
364 };
365 handler
366 .render(
367 &protocol,
368 &state,
369 &RenderOptions::new(&entry, &request_path),
370 &mut writer,
371 )
372 .map_err(|e| NapiError::from_reason(format!("Render error: {e}")))?;
373
374 if let Some(err) = writer.error {
375 return Err(NapiError::from_reason(format!("Callback error: {err}")));
376 }
377
378 Ok(())
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384 use webui_parser::HtmlParser;
385
386 fn build_protocol(html: &str) -> Vec<u8> {
388 let mut parser = HtmlParser::new();
389 parser.parse("index.html", html).expect("parse failed");
390 let tokens = parser.take_tokens();
391 let protocol = WebUIProtocol::with_tokens(parser.into_fragment_records(), tokens);
392 protocol.to_protobuf().expect("protobuf encode failed")
393 }
394
395 fn render_to_string(protocol_bytes: &[u8], state_json: &str) -> Result<String, String> {
397 let protocol = WebUIProtocol::from_protobuf(protocol_bytes).map_err(|e| e.to_string())?;
398 let state: Value = serde_json::from_str(state_json).map_err(|e| e.to_string())?;
399
400 let mut output = String::with_capacity(1024);
401 let handler = WebUIHandler::new();
402
403 struct StringWriter<'a> {
404 output: &'a mut String,
405 }
406 impl ResponseWriter for StringWriter<'_> {
407 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
408 self.output.push_str(content);
409 Ok(())
410 }
411 fn end(&mut self) -> webui_handler::Result<()> {
412 Ok(())
413 }
414 }
415
416 let mut writer = StringWriter {
417 output: &mut output,
418 };
419 handler
420 .render(
421 &protocol,
422 &state,
423 &RenderOptions::new("index.html", "/"),
424 &mut writer,
425 )
426 .map_err(|e| e.to_string())?;
427 Ok(output)
428 }
429
430 #[test]
431 fn test_simple_passthrough() {
432 let proto = build_protocol("<p>Hello</p>");
433 let result = render_to_string(&proto, "{}");
434 assert_eq!(result.as_deref(), Ok("<p>Hello</p>"));
435 }
436
437 #[test]
438 fn test_signal_substitution() {
439 let proto = build_protocol("Hello, {{name}}!");
440 let result = render_to_string(&proto, r#"{"name": "WebUI"}"#);
441 assert_eq!(result.as_deref(), Ok("Hello, WebUI!"));
442 }
443
444 #[test]
445 fn test_for_loop() {
446 let proto = build_protocol("<ul><for each=\"item in items\"><li>{{item}}</li></for></ul>");
447 let result = render_to_string(&proto, r#"{"items": ["a", "b", "c"]}"#);
448 assert_eq!(
449 result.as_deref(),
450 Ok("<ul><li>a</li><li>b</li><li>c</li></ul>")
451 );
452 }
453
454 #[test]
455 fn test_if_condition_true() {
456 let proto = build_protocol("<if condition=\"show\"><p>Visible</p></if>");
457 let result = render_to_string(&proto, r#"{"show": true}"#);
458 assert_eq!(result.as_deref(), Ok("<p>Visible</p>"));
459 }
460
461 #[test]
462 fn test_if_condition_false() {
463 let proto = build_protocol("<if condition=\"show\"><p>Hidden</p></if>");
464 let result = render_to_string(&proto, r#"{"show": false}"#);
465 assert_eq!(result.as_deref(), Ok(""));
466 }
467
468 #[test]
469 fn test_html_escaping() {
470 let proto = build_protocol("<div>{{content}}</div>");
471 let state = r#"{"content": "<script>alert('xss')</script>"}"#;
472 let result = render_to_string(&proto, state).expect("render should succeed");
473 assert!(!result.contains("<script>"));
474 assert!(result.contains("<script>"));
475 }
476
477 #[test]
478 fn test_raw_signal() {
479 let proto = build_protocol("<div>{{{content}}}</div>");
480 let result = render_to_string(&proto, r#"{"content": "<b>bold</b>"}"#);
481 assert_eq!(result.as_deref(), Ok("<div><b>bold</b></div>"));
482 }
483
484 #[test]
485 fn test_invalid_json() {
486 let proto = build_protocol("<p>hi</p>");
487 let result = render_to_string(&proto, "NOT JSON");
488 assert!(result.is_err());
489 }
490
491 #[test]
492 fn test_empty_state() {
493 let proto = build_protocol("<p>static</p>");
494 let result = render_to_string(&proto, "{}");
495 assert_eq!(result.as_deref(), Ok("<p>static</p>"));
496 }
497
498 #[test]
499 fn test_nested_object_signal() {
500 let proto = build_protocol("{{user.name}}");
501 let result = render_to_string(&proto, r#"{"user": {"name": "Alice"}}"#);
502 assert_eq!(result.as_deref(), Ok("Alice"));
503 }
504
505 #[test]
506 fn test_invalid_protobuf() {
507 let result = render_to_string(&[0xFF, 0xFF, 0xFF], "{}");
508 assert!(result.is_err());
509 }
510
511 #[test]
514 fn test_build_simple_app() {
515 let dir = tempfile::TempDir::new().unwrap();
516 std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
517
518 let options = JsBuildOptions {
519 app_dir: dir.path().to_string_lossy().to_string(),
520 entry: None,
521 css: None,
522 dom: None,
523 plugin: None,
524 components: None,
525 component_asset_roots: None,
526 css_file_name_template: None,
527 css_public_base: None,
528 legal_comments: None,
529 theme: None,
530 };
531
532 let result = build(options).unwrap();
533 assert!(!result.protocol.is_empty());
534 assert!(result.stats.fragment_count > 0);
535 assert!(result.stats.protocol_size_bytes > 0);
536 assert!(result.stats.duration_ms >= 0.0);
537 }
538
539 #[test]
540 fn test_build_with_custom_entry() {
541 let dir = tempfile::TempDir::new().unwrap();
542 std::fs::write(dir.path().join("page.html"), "<p>Custom</p>").unwrap();
543
544 let options = JsBuildOptions {
545 app_dir: dir.path().to_string_lossy().to_string(),
546 entry: Some("page.html".to_string()),
547 css: None,
548 dom: None,
549 plugin: None,
550 components: None,
551 component_asset_roots: None,
552 css_file_name_template: None,
553 css_public_base: None,
554 legal_comments: None,
555 theme: None,
556 };
557
558 let result = build(options).unwrap();
559 assert!(!result.protocol.is_empty());
560 }
561
562 #[test]
563 fn test_build_missing_app_dir() {
564 let options = JsBuildOptions {
565 app_dir: "/nonexistent/path".to_string(),
566 entry: None,
567 css: None,
568 dom: None,
569 plugin: None,
570 components: None,
571 component_asset_roots: None,
572 css_file_name_template: None,
573 css_public_base: None,
574 legal_comments: None,
575 theme: None,
576 };
577
578 let result = build(options);
579 assert!(result.is_err());
580 }
581
582 #[test]
583 fn test_build_invalid_css() {
584 let dir = tempfile::TempDir::new().unwrap();
585 std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
586
587 let options = JsBuildOptions {
588 app_dir: dir.path().to_string_lossy().to_string(),
589 entry: None,
590 css: Some("bogus".to_string()),
591 dom: None,
592 plugin: None,
593 components: None,
594 component_asset_roots: None,
595 css_file_name_template: None,
596 css_public_base: None,
597 legal_comments: None,
598 theme: None,
599 };
600
601 let result = build(options);
602 assert!(result.is_err());
603 }
604
605 #[test]
606 fn test_build_with_components_css() {
607 let dir = tempfile::TempDir::new().unwrap();
608 std::fs::write(dir.path().join("index.html"), "<my-card>Hello</my-card>").unwrap();
609 std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
610 std::fs::write(dir.path().join("my-card.css"), ".card { color: red; }").unwrap();
611
612 let options = JsBuildOptions {
613 app_dir: dir.path().to_string_lossy().to_string(),
614 entry: None,
615 css: Some("link".to_string()),
616 dom: None,
617 plugin: None,
618 components: None,
619 component_asset_roots: None,
620 css_file_name_template: None,
621 css_public_base: None,
622 legal_comments: None,
623 theme: None,
624 };
625
626 let result = build(options).unwrap();
627 assert_eq!(result.css_files.len(), 2);
629 assert_eq!(result.css_files[0], "my-card.css");
630 assert!(result.css_files[1].contains("color: red"));
631 assert_eq!(result.stats.css_file_count, 1);
632 }
633
634 #[test]
635 fn test_build_with_theme_missing_token_errors() {
636 let dir = tempfile::TempDir::new().unwrap();
637 std::fs::write(dir.path().join("index.html"), "<my-card></my-card>").unwrap();
638 std::fs::write(dir.path().join("my-card.html"), "<div>Card</div>").unwrap();
639 std::fs::write(
640 dir.path().join("my-card.css"),
641 ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c))); }",
642 )
643 .unwrap();
644 let theme_path = dir.path().join("theme.json");
645 std::fs::write(&theme_path, r#"{"themes":{"light":{"token-b":"green"}}}"#).unwrap();
646
647 let options = JsBuildOptions {
648 app_dir: dir.path().to_string_lossy().to_string(),
649 entry: None,
650 css: Some("link".to_string()),
651 dom: None,
652 plugin: None,
653 components: None,
654 component_asset_roots: None,
655 css_file_name_template: None,
656 css_public_base: None,
657 legal_comments: None,
658 theme: Some(theme_path.to_string_lossy().to_string()),
659 };
660
661 let Err(err) = build(options) else {
662 panic!("missing theme token must fail");
663 };
664 let message = err.to_string();
665 assert!(message.contains("missing-theme-token"), "msg: {message}");
666 assert!(message.contains("--token-c"), "msg: {message}");
667 }
668
669 #[test]
670 fn test_build_returns_component_asset_files() {
671 let dir = tempfile::TempDir::new().unwrap();
672 std::fs::write(dir.path().join("index.html"), "<app-shell></app-shell>").unwrap();
673 std::fs::write(dir.path().join("app-shell.html"), "<div></div>").unwrap();
674 std::fs::write(dir.path().join("lazy-panel.html"), "<p>{{title}}</p>").unwrap();
675
676 let options = JsBuildOptions {
677 app_dir: dir.path().to_string_lossy().to_string(),
678 entry: None,
679 css: Some("link".to_string()),
680 dom: None,
681 plugin: Some("webui".to_string()),
682 components: None,
683 component_asset_roots: Some(vec!["lazy-panel".to_string()]),
684 css_file_name_template: None,
685 css_public_base: None,
686 legal_comments: None,
687 theme: None,
688 };
689
690 let result = build(options).unwrap();
691
692 assert_eq!(result.component_asset_files.len(), 2);
693 assert_eq!(result.component_asset_files[0], "lazy-panel.webui.js");
694 assert!(result.component_asset_files[1].contains("webui-component-asset"));
695 assert!(result.component_asset_files[1].contains("export default asset;"));
696 }
697
698 #[test]
699 fn test_build_legal_comments_none_strips_legal_css() {
700 let dir = tempfile::TempDir::new().unwrap();
701 std::fs::write(dir.path().join("index.html"), "<my-card>Hello</my-card>").unwrap();
702 std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
703 std::fs::write(
704 dir.path().join("my-card.css"),
705 "/*! @license MIT */ .card { color: red; }",
706 )
707 .unwrap();
708
709 let options = JsBuildOptions {
710 app_dir: dir.path().to_string_lossy().to_string(),
711 entry: None,
712 css: Some("link".to_string()),
713 dom: None,
714 plugin: None,
715 components: None,
716 component_asset_roots: None,
717 css_file_name_template: None,
718 css_public_base: None,
719 legal_comments: Some("none".to_string()),
720 theme: None,
721 };
722
723 let result = build(options).unwrap();
724 assert_eq!(result.css_files[1], " .card { color: red; }");
725 }
726
727 #[test]
728 fn test_build_invalid_legal_comments() {
729 let dir = tempfile::TempDir::new().unwrap();
730 std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
731
732 let options = JsBuildOptions {
733 app_dir: dir.path().to_string_lossy().to_string(),
734 entry: None,
735 css: None,
736 dom: None,
737 plugin: None,
738 components: None,
739 component_asset_roots: None,
740 css_file_name_template: None,
741 css_public_base: None,
742 legal_comments: Some("linked".to_string()),
743 theme: None,
744 };
745
746 let result = build(options);
747 assert!(result.is_err());
748 }
749
750 #[test]
751 fn test_build_with_light_dom_omits_shadow_root_template() {
752 let dir = tempfile::TempDir::new().unwrap();
753 std::fs::write(dir.path().join("index.html"), "<my-card>Hi</my-card>").unwrap();
754 std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
755
756 let options = JsBuildOptions {
757 app_dir: dir.path().to_string_lossy().to_string(),
758 entry: None,
759 css: None,
760 dom: Some("light".to_string()),
761 plugin: None,
762 components: None,
763 component_asset_roots: None,
764 css_file_name_template: None,
765 css_public_base: None,
766 legal_comments: None,
767 theme: None,
768 };
769
770 let result = build(options).unwrap();
771 let json = inspect(result.protocol).unwrap();
772 assert!(
773 !json.contains("shadowrootmode"),
774 "light DOM build should not emit shadowrootmode wrappers, got: {json}"
775 );
776 }
777
778 #[test]
779 fn test_build_with_shadow_dom_emits_shadow_root_template() {
780 let dir = tempfile::TempDir::new().unwrap();
781 std::fs::write(dir.path().join("index.html"), "<my-card>Hi</my-card>").unwrap();
782 std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
783
784 let options = JsBuildOptions {
785 app_dir: dir.path().to_string_lossy().to_string(),
786 entry: None,
787 css: None,
788 dom: Some("shadow".to_string()),
789 plugin: None,
790 components: None,
791 component_asset_roots: None,
792 css_file_name_template: None,
793 css_public_base: None,
794 legal_comments: None,
795 theme: None,
796 };
797
798 let result = build(options).unwrap();
799 let json = inspect(result.protocol).unwrap();
800 assert!(
801 json.contains("shadowrootmode"),
802 "shadow DOM build should emit shadowrootmode wrappers, got: {json}"
803 );
804 }
805
806 #[test]
807 fn test_build_invalid_dom() {
808 let dir = tempfile::TempDir::new().unwrap();
809 std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
810
811 let options = JsBuildOptions {
812 app_dir: dir.path().to_string_lossy().to_string(),
813 entry: None,
814 css: None,
815 dom: Some("bogus".to_string()),
816 plugin: None,
817 components: None,
818 component_asset_roots: None,
819 css_file_name_template: None,
820 css_public_base: None,
821 legal_comments: None,
822 theme: None,
823 };
824
825 let result = build(options);
826 assert!(result.is_err());
827 }
828
829 #[test]
830 fn test_inspect_valid_protocol() {
831 let proto = build_protocol("<h1>Hello {{name}}</h1>");
832 let json = inspect(napi::bindgen_prelude::Buffer::from(proto)).unwrap();
833 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
834 assert!(parsed.get("fragments").is_some());
835 }
836
837 #[test]
838 fn test_inspect_invalid_protocol() {
839 let result = inspect(napi::bindgen_prelude::Buffer::from(vec![0xFF, 0xFF]));
840 assert!(result.is_err());
841 }
842
843 #[test]
846 fn test_protocol_tokens_empty() {
847 let proto = build_protocol("<p>Hello</p>");
848 let tokens = protocol_tokens(napi::bindgen_prelude::Buffer::from(proto)).unwrap();
849 assert!(tokens.is_empty());
850 }
851
852 #[test]
853 fn test_protocol_tokens_returns_parsed_tokens() {
854 let mut parser = HtmlParser::new();
856 parser.parse("index.html", "<p>Hi</p>").expect("parse");
857 let protocol = WebUIProtocol::with_tokens(
858 parser.into_fragment_records(),
859 vec![
860 "colorBrandBackground".to_string(),
861 "fontSizeBase300".to_string(),
862 ],
863 );
864 let proto = protocol.to_protobuf().expect("encode");
865 let tokens = protocol_tokens(napi::bindgen_prelude::Buffer::from(proto)).unwrap();
866 assert_eq!(tokens, vec!["colorBrandBackground", "fontSizeBase300"]);
867 }
868
869 #[test]
870 fn test_protocol_tokens_invalid_protobuf() {
871 let result = protocol_tokens(napi::bindgen_prelude::Buffer::from(vec![0xFF, 0xFF]));
872 assert!(result.is_err());
873 }
874}