Skip to main content

webui_node/
lib.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Node.js native addon for the WebUI framework via napi-rs.
5//!
6//! Provides high-performance server-side rendering by compiling the Rust
7//! WebUI handler directly into a `.node` native addon — no C ABI intermediary.
8//!
9//! The `render` function accepts pre-compiled protobuf protocol data (from
10//! `webui build`) and streams rendered HTML fragments via a callback, enabling
11//! true streaming SSR without buffering the entire response.
12//!
13//! ## Usage (from Node.js)
14//!
15//! ```js
16//! import fs from 'node:fs';
17//!
18//! // Load the native addon
19//! const mod = { exports: {} };
20//! process.dlopen(mod, './target/release/libwebui_node.dylib');
21//! const { render } = mod.exports;
22//!
23//! // Read pre-compiled protocol (from `webui build`)
24//! const protocol = fs.readFileSync('./dist/protocol.bin');
25//! const state = '{"name": "WebUI"}';
26//!
27//! // Stream rendered fragments
28//! render(protocol, state, (chunk) => process.stdout.write(chunk));
29//! ```
30
31use 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/// Build statistics returned from the build function.
42#[napi(object)]
43pub struct JsBuildStats {
44    /// Build duration in milliseconds.
45    pub duration_ms: f64,
46    /// Total number of protocol fragments.
47    pub fragment_count: u32,
48    /// Number of registered components.
49    pub component_count: u32,
50    /// Number of CSS files produced.
51    pub css_file_count: u32,
52    /// Size of the serialized protocol in bytes.
53    pub protocol_size_bytes: u32,
54    /// Number of unique CSS tokens discovered.
55    pub token_count: u32,
56}
57
58/// Result of a successful build operation.
59#[napi(object)]
60pub struct JsBuildResult {
61    /// Serialized protocol (protobuf binary).
62    pub protocol: Buffer,
63    /// CSS files as alternating [filename, content, filename, content, ...].
64    pub css_files: Vec<String>,
65    /// Build statistics.
66    pub stats: JsBuildStats,
67}
68
69/// Build options for the webui build API.
70#[napi(object)]
71pub struct JsBuildOptions {
72    /// Path to the application folder containing templates.
73    pub app_dir: String,
74    /// Entry HTML file name (defaults to "index.html").
75    pub entry: Option<String>,
76    /// CSS mode: "link" (default) or "style".
77    pub css: Option<String>,
78    /// DOM strategy for component rendering: "shadow" (default) or "light".
79    pub dom: Option<String>,
80    /// Plugin identifier (see crate documentation for available identifiers).
81    pub plugin: Option<String>,
82    /// Additional component sources (npm packages or local paths).
83    pub components: Option<Vec<String>>,
84    /// Link-mode CSS filename template using [name], [hash], [ext].
85    pub css_file_name_template: Option<String>,
86    /// Optional base URL/path prefix for Link-mode css hrefs.
87    pub css_public_base: Option<String>,
88    /// Legal comment handling: "inline" (default) or "none".
89    pub legal_comments: Option<String>,
90}
91
92/// Build a WebUI application from an app directory.
93///
94/// Returns the compiled protocol bytes, CSS files, and build statistics.
95#[napi]
96#[allow(clippy::cast_possible_truncation)] // stats are bounded by component/file counts
97pub fn build(options: JsBuildOptions) -> napi::Result<JsBuildResult> {
98    let css = options
99        .css
100        .map(|s| s.parse::<webui::CssStrategy>())
101        .transpose()
102        .map_err(NapiError::from_reason)?
103        .unwrap_or_default();
104
105    let dom = options
106        .dom
107        .map(|s| s.parse::<webui::DomStrategy>())
108        .transpose()
109        .map_err(NapiError::from_reason)?
110        .unwrap_or_default();
111
112    let plugin = options
113        .plugin
114        .map(|s| s.parse::<webui::Plugin>())
115        .transpose()
116        .map_err(NapiError::from_reason)?;
117
118    let legal_comments = options
119        .legal_comments
120        .map(|s| s.parse::<webui::LegalComments>())
121        .transpose()
122        .map_err(NapiError::from_reason)?
123        .unwrap_or_default();
124
125    let build_options = webui::BuildOptions {
126        app_dir: std::path::PathBuf::from(&options.app_dir),
127        entry: options.entry.unwrap_or_else(|| "index.html".to_string()),
128        css,
129        dom,
130        plugin,
131        components: options.components.unwrap_or_default(),
132        css_file_name_template: options
133            .css_file_name_template
134            .unwrap_or_else(|| webui::DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string()),
135        css_public_base: options.css_public_base,
136        legal_comments,
137    };
138
139    let result = webui::build(build_options)
140        .map_err(|e| NapiError::from_reason(format!("Build error: {}", e.chain_message())))?;
141
142    // Flatten css_files into alternating [filename, content, ...] for JS interop
143    let css_files: Vec<String> = result
144        .css_files
145        .into_iter()
146        .flat_map(|(name, content)| [name, content])
147        .collect();
148
149    Ok(JsBuildResult {
150        protocol: Buffer::from(result.protocol_bytes),
151        css_files,
152        stats: JsBuildStats {
153            duration_ms: result.stats.duration.as_secs_f64() * 1000.0,
154            fragment_count: result.stats.fragment_count as u32,
155            component_count: result.stats.component_count as u32,
156            css_file_count: result.stats.css_file_count as u32,
157            protocol_size_bytes: result.stats.protocol_size_bytes as u32,
158            token_count: result.stats.token_count as u32,
159        },
160    })
161}
162
163/// Inspect protocol bytes and return a JSON representation.
164#[napi]
165pub fn inspect(protocol_data: Buffer) -> napi::Result<String> {
166    webui::inspect_bytes(&protocol_data)
167        .map_err(|e| NapiError::from_reason(format!("Inspect error: {e}")))
168}
169
170/// Produce a complete JSON partial response for client-side navigation.
171///
172/// Combines application state, route templates, inventory, request path, and
173/// matched route chain into a single JSON string:
174/// `{"state":{...},"templates":[...],"inventory":"...","path":"...","chain":[...]}`.
175///
176/// Host servers return this directly — no assembly required.
177#[napi]
178pub fn render_partial(
179    protocol_data: Buffer,
180    state_json: String,
181    entry_id: String,
182    request_path: String,
183    inventory_hex: String,
184) -> napi::Result<String> {
185    let protocol = WebUIProtocol::from_protobuf(&protocol_data)
186        .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
187
188    let state: serde_json::Value = serde_json::from_str(&state_json)
189        .map_err(|e| NapiError::from_reason(format!("invalid state JSON: {e}")))?;
190
191    // TODO: ProtocolIndex is created per-request here because the Node binding
192    // receives the protocol fresh each call. Ideally the host should cache it
193    // alongside the protocol for the lifetime of the server — it's deterministic
194    // per protocol and avoids rebuilding the component index + route cache.
195    let mut index = webui_handler::route_handler::ProtocolIndex::new(&protocol);
196
197    let mut result = webui_handler::route_handler::render_partial(
198        &protocol,
199        &entry_id,
200        &request_path,
201        &inventory_hex,
202        &mut index,
203    )
204    .map_err(|e| NapiError::from_reason(format!("render_partial failed: {e}")))?;
205    if let Some(obj) = result.as_object_mut() {
206        obj.insert("state".into(), state);
207    }
208
209    serde_json::to_string(&result)
210        .map_err(|e| NapiError::from_reason(format!("JSON serialize error: {e}")))
211}
212
213#[napi]
214pub fn render_component_templates(
215    protocol_data: Buffer,
216    component_tags_json: 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 tags: Vec<String> = serde_json::from_str(&component_tags_json)
223        .map_err(|e| NapiError::from_reason(format!("invalid tags JSON: {e}")))?;
224    let tag_refs: Vec<&str> = tags.iter().map(|s| s.as_str()).collect();
225
226    // Per-request index — see ProtocolIndex doc for caching guidance.
227    let mut index = webui_handler::route_handler::ProtocolIndex::new(&protocol);
228
229    let result = webui_handler::route_handler::render_component_templates(
230        &protocol,
231        &tag_refs,
232        &inventory_hex,
233        &mut index,
234    )
235    .map_err(|e| NapiError::from_reason(format!("render_component_templates failed: {e}")))?;
236
237    serde_json::to_string(&result)
238        .map_err(|e| NapiError::from_reason(format!("JSON serialize error: {e}")))
239}
240
241/// Extract the CSS token name list from a compiled protocol.
242///
243/// Returns the tokens as a JavaScript string array, preserving the original
244/// order from the build step.
245#[napi]
246pub fn protocol_tokens(protocol_data: Buffer) -> napi::Result<Vec<String>> {
247    let protocol = WebUIProtocol::from_protobuf(&protocol_data)
248        .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
249
250    Ok(protocol.tokens)
251}
252
253/// A writer that streams each rendered fragment to a JS callback.
254struct CallbackWriter<'a, 'env> {
255    callback: &'a Function<'env, String, ()>,
256    error: Option<String>,
257}
258
259impl<'a, 'env> CallbackWriter<'a, 'env> {
260    fn new(callback: &'a Function<'env, String, ()>) -> Self {
261        Self {
262            callback,
263            error: None,
264        }
265    }
266}
267
268impl ResponseWriter for CallbackWriter<'_, '_> {
269    fn write(&mut self, content: &str) -> webui_handler::Result<()> {
270        if self.error.is_some() {
271            return Ok(());
272        }
273        if let Err(e) = self.callback.call(content.to_owned()) {
274            // Ignore "Value is not undefined" errors from callbacks that
275            // return non-void (e.g. res.write() returns a boolean).
276            let msg = format!("{e}");
277            if !msg.contains("Value is not undefined") {
278                self.error = Some(msg);
279            }
280        }
281        Ok(())
282    }
283
284    fn end(&mut self) -> webui_handler::Result<()> {
285        Ok(())
286    }
287}
288
289/// Render a pre-compiled WebUI protocol with JSON state, streaming each
290/// fragment to the provided callback.
291///
292/// # Arguments
293///
294/// * `protocol_data` — Protobuf binary from `webui build` (zero-copy Buffer).
295/// * `state_json` — JSON string with the render state.
296/// * `on_chunk` — Called with each rendered HTML fragment as it is produced.
297/// * `plugin` — Optional plugin identifier (see crate documentation for available identifiers).
298#[napi]
299#[allow(clippy::too_many_arguments)]
300pub fn render(
301    protocol_data: Buffer,
302    state_json: String,
303    entry: String,
304    request_path: String,
305    on_chunk: Function<String, ()>,
306    plugin: Option<String>,
307) -> napi::Result<()> {
308    let protocol = WebUIProtocol::from_protobuf(&protocol_data)
309        .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
310
311    let state: Value = serde_json::from_str(&state_json)
312        .map_err(|e| NapiError::from_reason(format!("State JSON error: {e}")))?;
313
314    let mut writer = CallbackWriter::new(&on_chunk);
315    let plugin = plugin
316        .map(|s| s.parse::<webui::Plugin>())
317        .transpose()
318        .map_err(NapiError::from_reason)?;
319    let handler = match plugin {
320        Some(webui::Plugin::Fast | webui::Plugin::FastV2) => {
321            WebUIHandler::with_plugin(|| Box::new(FastV2HydrationPlugin::new()))
322        }
323        Some(webui::Plugin::FastV3) => {
324            WebUIHandler::with_plugin(|| Box::new(FastV3HydrationPlugin::new()))
325        }
326        Some(webui::Plugin::WebUI) => {
327            WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()))
328        }
329        None => WebUIHandler::new(),
330    };
331    handler
332        .render(
333            &protocol,
334            &state,
335            &RenderOptions::new(&entry, &request_path),
336            &mut writer,
337        )
338        .map_err(|e| NapiError::from_reason(format!("Render error: {e}")))?;
339
340    if let Some(err) = writer.error {
341        return Err(NapiError::from_reason(format!("Callback error: {err}")));
342    }
343
344    Ok(())
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use webui_parser::HtmlParser;
351
352    /// Helper: parse HTML into protobuf bytes for testing.
353    fn build_protocol(html: &str) -> Vec<u8> {
354        let mut parser = HtmlParser::new();
355        parser.parse("index.html", html).expect("parse failed");
356        let tokens = parser.take_tokens();
357        let protocol = WebUIProtocol::with_tokens(parser.into_fragment_records(), tokens);
358        protocol.to_protobuf().expect("protobuf encode failed")
359    }
360
361    /// Helper: render protocol bytes + state, collecting output into a String.
362    fn render_to_string(protocol_bytes: &[u8], state_json: &str) -> Result<String, String> {
363        let protocol = WebUIProtocol::from_protobuf(protocol_bytes).map_err(|e| e.to_string())?;
364        let state: Value = serde_json::from_str(state_json).map_err(|e| e.to_string())?;
365
366        let mut output = String::with_capacity(1024);
367        let handler = WebUIHandler::new();
368
369        struct StringWriter<'a> {
370            output: &'a mut String,
371        }
372        impl ResponseWriter for StringWriter<'_> {
373            fn write(&mut self, content: &str) -> webui_handler::Result<()> {
374                self.output.push_str(content);
375                Ok(())
376            }
377            fn end(&mut self) -> webui_handler::Result<()> {
378                Ok(())
379            }
380        }
381
382        let mut writer = StringWriter {
383            output: &mut output,
384        };
385        handler
386            .render(
387                &protocol,
388                &state,
389                &RenderOptions::new("index.html", "/"),
390                &mut writer,
391            )
392            .map_err(|e| e.to_string())?;
393        Ok(output)
394    }
395
396    #[test]
397    fn test_simple_passthrough() {
398        let proto = build_protocol("<p>Hello</p>");
399        let result = render_to_string(&proto, "{}");
400        assert_eq!(result.as_deref(), Ok("<p>Hello</p>"));
401    }
402
403    #[test]
404    fn test_signal_substitution() {
405        let proto = build_protocol("Hello, {{name}}!");
406        let result = render_to_string(&proto, r#"{"name": "WebUI"}"#);
407        assert_eq!(result.as_deref(), Ok("Hello, WebUI!"));
408    }
409
410    #[test]
411    fn test_for_loop() {
412        let proto = build_protocol("<ul><for each=\"item in items\"><li>{{item}}</li></for></ul>");
413        let result = render_to_string(&proto, r#"{"items": ["a", "b", "c"]}"#);
414        assert_eq!(
415            result.as_deref(),
416            Ok("<ul><li>a</li><li>b</li><li>c</li></ul>")
417        );
418    }
419
420    #[test]
421    fn test_if_condition_true() {
422        let proto = build_protocol("<if condition=\"show\"><p>Visible</p></if>");
423        let result = render_to_string(&proto, r#"{"show": true}"#);
424        assert_eq!(result.as_deref(), Ok("<p>Visible</p>"));
425    }
426
427    #[test]
428    fn test_if_condition_false() {
429        let proto = build_protocol("<if condition=\"show\"><p>Hidden</p></if>");
430        let result = render_to_string(&proto, r#"{"show": false}"#);
431        assert_eq!(result.as_deref(), Ok(""));
432    }
433
434    #[test]
435    fn test_html_escaping() {
436        let proto = build_protocol("<div>{{content}}</div>");
437        let state = r#"{"content": "<script>alert('xss')</script>"}"#;
438        let result = render_to_string(&proto, state).expect("render should succeed");
439        assert!(!result.contains("<script>"));
440        assert!(result.contains("&lt;script&gt;"));
441    }
442
443    #[test]
444    fn test_raw_signal() {
445        let proto = build_protocol("<div>{{{content}}}</div>");
446        let result = render_to_string(&proto, r#"{"content": "<b>bold</b>"}"#);
447        assert_eq!(result.as_deref(), Ok("<div><b>bold</b></div>"));
448    }
449
450    #[test]
451    fn test_invalid_json() {
452        let proto = build_protocol("<p>hi</p>");
453        let result = render_to_string(&proto, "NOT JSON");
454        assert!(result.is_err());
455    }
456
457    #[test]
458    fn test_empty_state() {
459        let proto = build_protocol("<p>static</p>");
460        let result = render_to_string(&proto, "{}");
461        assert_eq!(result.as_deref(), Ok("<p>static</p>"));
462    }
463
464    #[test]
465    fn test_nested_object_signal() {
466        let proto = build_protocol("{{user.name}}");
467        let result = render_to_string(&proto, r#"{"user": {"name": "Alice"}}"#);
468        assert_eq!(result.as_deref(), Ok("Alice"));
469    }
470
471    #[test]
472    fn test_invalid_protobuf() {
473        let result = render_to_string(&[0xFF, 0xFF, 0xFF], "{}");
474        assert!(result.is_err());
475    }
476
477    // ── Tests for build() and inspect() napi exports ─────────────────
478
479    #[test]
480    fn test_build_simple_app() {
481        let dir = tempfile::TempDir::new().unwrap();
482        std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
483
484        let options = JsBuildOptions {
485            app_dir: dir.path().to_string_lossy().to_string(),
486            entry: None,
487            css: None,
488            dom: None,
489            plugin: None,
490            components: None,
491            css_file_name_template: None,
492            css_public_base: None,
493            legal_comments: None,
494        };
495
496        let result = build(options).unwrap();
497        assert!(!result.protocol.is_empty());
498        assert!(result.stats.fragment_count > 0);
499        assert!(result.stats.protocol_size_bytes > 0);
500        assert!(result.stats.duration_ms >= 0.0);
501    }
502
503    #[test]
504    fn test_build_with_custom_entry() {
505        let dir = tempfile::TempDir::new().unwrap();
506        std::fs::write(dir.path().join("page.html"), "<p>Custom</p>").unwrap();
507
508        let options = JsBuildOptions {
509            app_dir: dir.path().to_string_lossy().to_string(),
510            entry: Some("page.html".to_string()),
511            css: None,
512            dom: None,
513            plugin: None,
514            components: None,
515            css_file_name_template: None,
516            css_public_base: None,
517            legal_comments: None,
518        };
519
520        let result = build(options).unwrap();
521        assert!(!result.protocol.is_empty());
522    }
523
524    #[test]
525    fn test_build_missing_app_dir() {
526        let options = JsBuildOptions {
527            app_dir: "/nonexistent/path".to_string(),
528            entry: None,
529            css: None,
530            dom: None,
531            plugin: None,
532            components: None,
533            css_file_name_template: None,
534            css_public_base: None,
535            legal_comments: None,
536        };
537
538        let result = build(options);
539        assert!(result.is_err());
540    }
541
542    #[test]
543    fn test_build_invalid_css() {
544        let dir = tempfile::TempDir::new().unwrap();
545        std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
546
547        let options = JsBuildOptions {
548            app_dir: dir.path().to_string_lossy().to_string(),
549            entry: None,
550            css: Some("bogus".to_string()),
551            dom: None,
552            plugin: None,
553            components: None,
554            css_file_name_template: None,
555            css_public_base: None,
556            legal_comments: None,
557        };
558
559        let result = build(options);
560        assert!(result.is_err());
561    }
562
563    #[test]
564    fn test_build_with_components_css() {
565        let dir = tempfile::TempDir::new().unwrap();
566        std::fs::write(dir.path().join("index.html"), "<my-card>Hello</my-card>").unwrap();
567        std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
568        std::fs::write(dir.path().join("my-card.css"), ".card { color: red; }").unwrap();
569
570        let options = JsBuildOptions {
571            app_dir: dir.path().to_string_lossy().to_string(),
572            entry: None,
573            css: Some("link".to_string()),
574            dom: None,
575            plugin: None,
576            components: None,
577            css_file_name_template: None,
578            css_public_base: None,
579            legal_comments: None,
580        };
581
582        let result = build(options).unwrap();
583        // css_files is flattened: [filename, content, filename, content, ...]
584        assert_eq!(result.css_files.len(), 2);
585        assert_eq!(result.css_files[0], "my-card.css");
586        assert!(result.css_files[1].contains("color: red"));
587        assert_eq!(result.stats.css_file_count, 1);
588    }
589
590    #[test]
591    fn test_build_legal_comments_none_strips_legal_css() {
592        let dir = tempfile::TempDir::new().unwrap();
593        std::fs::write(dir.path().join("index.html"), "<my-card>Hello</my-card>").unwrap();
594        std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
595        std::fs::write(
596            dir.path().join("my-card.css"),
597            "/*! @license MIT */ .card { color: red; }",
598        )
599        .unwrap();
600
601        let options = JsBuildOptions {
602            app_dir: dir.path().to_string_lossy().to_string(),
603            entry: None,
604            css: Some("link".to_string()),
605            dom: None,
606            plugin: None,
607            components: None,
608            css_file_name_template: None,
609            css_public_base: None,
610            legal_comments: Some("none".to_string()),
611        };
612
613        let result = build(options).unwrap();
614        assert_eq!(result.css_files[1], " .card { color: red; }");
615    }
616
617    #[test]
618    fn test_build_invalid_legal_comments() {
619        let dir = tempfile::TempDir::new().unwrap();
620        std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
621
622        let options = JsBuildOptions {
623            app_dir: dir.path().to_string_lossy().to_string(),
624            entry: None,
625            css: None,
626            dom: None,
627            plugin: None,
628            components: None,
629            css_file_name_template: None,
630            css_public_base: None,
631            legal_comments: Some("linked".to_string()),
632        };
633
634        let result = build(options);
635        assert!(result.is_err());
636    }
637
638    #[test]
639    fn test_build_with_light_dom_omits_shadow_root_template() {
640        let dir = tempfile::TempDir::new().unwrap();
641        std::fs::write(dir.path().join("index.html"), "<my-card>Hi</my-card>").unwrap();
642        std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
643
644        let options = JsBuildOptions {
645            app_dir: dir.path().to_string_lossy().to_string(),
646            entry: None,
647            css: None,
648            dom: Some("light".to_string()),
649            plugin: None,
650            components: None,
651            css_file_name_template: None,
652            css_public_base: None,
653            legal_comments: None,
654        };
655
656        let result = build(options).unwrap();
657        let json = inspect(result.protocol).unwrap();
658        assert!(
659            !json.contains("shadowrootmode"),
660            "light DOM build should not emit shadowrootmode wrappers, got: {json}"
661        );
662    }
663
664    #[test]
665    fn test_build_with_shadow_dom_emits_shadow_root_template() {
666        let dir = tempfile::TempDir::new().unwrap();
667        std::fs::write(dir.path().join("index.html"), "<my-card>Hi</my-card>").unwrap();
668        std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
669
670        let options = JsBuildOptions {
671            app_dir: dir.path().to_string_lossy().to_string(),
672            entry: None,
673            css: None,
674            dom: Some("shadow".to_string()),
675            plugin: None,
676            components: None,
677            css_file_name_template: None,
678            css_public_base: None,
679            legal_comments: None,
680        };
681
682        let result = build(options).unwrap();
683        let json = inspect(result.protocol).unwrap();
684        assert!(
685            json.contains("shadowrootmode"),
686            "shadow DOM build should emit shadowrootmode wrappers, got: {json}"
687        );
688    }
689
690    #[test]
691    fn test_build_invalid_dom() {
692        let dir = tempfile::TempDir::new().unwrap();
693        std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
694
695        let options = JsBuildOptions {
696            app_dir: dir.path().to_string_lossy().to_string(),
697            entry: None,
698            css: None,
699            dom: Some("bogus".to_string()),
700            plugin: None,
701            components: None,
702            css_file_name_template: None,
703            css_public_base: None,
704            legal_comments: None,
705        };
706
707        let result = build(options);
708        assert!(result.is_err());
709    }
710
711    #[test]
712    fn test_inspect_valid_protocol() {
713        let proto = build_protocol("<h1>Hello {{name}}</h1>");
714        let json = inspect(napi::bindgen_prelude::Buffer::from(proto)).unwrap();
715        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
716        assert!(parsed.get("fragments").is_some());
717    }
718
719    #[test]
720    fn test_inspect_invalid_protocol() {
721        let result = inspect(napi::bindgen_prelude::Buffer::from(vec![0xFF, 0xFF]));
722        assert!(result.is_err());
723    }
724
725    // ── Tests for protocol_tokens napi export ────────────────────────
726
727    #[test]
728    fn test_protocol_tokens_empty() {
729        let proto = build_protocol("<p>Hello</p>");
730        let tokens = protocol_tokens(napi::bindgen_prelude::Buffer::from(proto)).unwrap();
731        assert!(tokens.is_empty());
732    }
733
734    #[test]
735    fn test_protocol_tokens_returns_parsed_tokens() {
736        // Build from a protocol that has CSS tokens via with_tokens constructor.
737        let mut parser = HtmlParser::new();
738        parser.parse("index.html", "<p>Hi</p>").expect("parse");
739        let protocol = WebUIProtocol::with_tokens(
740            parser.into_fragment_records(),
741            vec![
742                "colorBrandBackground".to_string(),
743                "fontSizeBase300".to_string(),
744            ],
745        );
746        let proto = protocol.to_protobuf().expect("encode");
747        let tokens = protocol_tokens(napi::bindgen_prelude::Buffer::from(proto)).unwrap();
748        assert_eq!(tokens, vec!["colorBrandBackground", "fontSizeBase300"]);
749    }
750
751    #[test]
752    fn test_protocol_tokens_invalid_protobuf() {
753        let result = protocol_tokens(napi::bindgen_prelude::Buffer::from(vec![0xFF, 0xFF]));
754        assert!(result.is_err());
755    }
756}