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 `Protocol` class decodes pre-compiled protobuf data from `webui build`
10//! once and provides buffered and streaming render methods.
11//!
12//! ## Usage (from Node.js)
13//!
14//! ```js
15//! import fs from 'node:fs';
16//!
17//! // Load the native addon
18//! const mod = { exports: {} };
19//! process.dlopen(mod, './target/release/libwebui_node.dylib');
20//! const { Protocol } = mod.exports;
21//!
22//! // Read pre-compiled protocol (from `webui build`)
23//! const protocol = new Protocol(fs.readFileSync('./dist/protocol.bin'), 'webui');
24//! const state = '{"name": "WebUI"}';
25//!
26//! // Stream rendered fragments
27//! protocol.renderStream(state, 'index.html', '/', (chunk) => process.stdout.write(chunk));
28//! ```
29
30use std::sync::Arc;
31
32use napi::bindgen_prelude::{Buffer, 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    BoundaryId, BoundaryMode, HandlerError, Protocol as HandlerProtocol, RenderOptions,
41    ResponseWriter, SessionOptions, StreamingSession as HandlerStreamingSession, WebUIHandler,
42};
43#[cfg(test)]
44use webui_protocol::WebUIProtocol;
45
46const STREAM_CHUNK_SIZE: usize = 16 * 1024;
47
48/// Build statistics returned from the build function.
49#[napi(object)]
50pub struct JsBuildStats {
51    /// Build duration in milliseconds.
52    pub duration_ms: f64,
53    /// Total number of protocol fragments.
54    pub fragment_count: u32,
55    /// Number of registered components.
56    pub component_count: u32,
57    /// Number of CSS files produced.
58    pub css_file_count: u32,
59    /// Size of the serialized protocol in bytes.
60    pub protocol_size_bytes: u32,
61    /// Number of unique CSS tokens discovered.
62    pub token_count: u32,
63}
64
65/// Result of a successful build operation.
66#[napi(object)]
67pub struct JsBuildResult {
68    /// Serialized protocol (protobuf binary).
69    pub protocol: Buffer,
70    /// CSS files as alternating [filename, content, filename, content, ...].
71    pub css_files: Vec<String>,
72    /// Static component asset files as alternating [filename, content, filename, content, ...].
73    pub component_asset_files: Vec<String>,
74    /// Esbuild-compatible component asset metafile JSON when requested.
75    pub metafile: Option<String>,
76    /// Non-fatal build advisories (plain text), e.g. CSS tokens used only with a
77    /// literal `var()` fallback and absent from every theme.
78    pub warnings: Vec<String>,
79    /// Build statistics.
80    pub stats: JsBuildStats,
81}
82
83/// Inline projection manifest transported through N-API.
84#[napi(object)]
85pub struct JsProjectionManifest {
86    /// Logical manifest path used to resolve `root` and stale file checks.
87    pub path: String,
88    /// Canonical manifest JSON.
89    pub json: String,
90}
91
92/// Build options for the webui build API.
93#[napi(object)]
94pub struct JsBuildOptions {
95    /// Path to the application folder containing templates.
96    pub app_dir: String,
97    /// Entry HTML file name (defaults to "index.html").
98    pub entry: Option<String>,
99    /// CSS mode: "link" (default) or "style".
100    pub css: Option<String>,
101    /// DOM strategy for component rendering: "shadow" (default) or "light".
102    pub dom: Option<String>,
103    /// Plugin identifier (see crate documentation for available identifiers).
104    pub plugin: Option<String>,
105    /// Additional component sources (npm packages or local paths).
106    pub components: Option<Vec<String>>,
107    /// Root component tags emitted as static `.webui.js` ESM assets.
108    pub component_asset_roots: Option<Vec<String>>,
109    /// Generate and return an esbuild-compatible component asset metafile.
110    pub metafile: Option<bool>,
111    /// Link-mode CSS filename template using [name], [hash], [ext].
112    pub css_file_name_template: Option<String>,
113    /// Optional base URL/path prefix for Link-mode css hrefs.
114    pub css_public_base: Option<String>,
115    /// Legal comment handling: "inline" (default) or "none".
116    pub legal_comments: Option<String>,
117    /// Design token theme: a JSON file path or npm package name.
118    pub theme: Option<String>,
119    /// Projection manifest paths.
120    pub projection_manifests: Option<Vec<String>>,
121    /// Inline manifest objects with their logical paths.
122    pub projection_manifest_objects: Option<Vec<JsProjectionManifest>>,
123}
124
125/// Build a WebUI application from an app directory.
126///
127/// Returns the compiled protocol bytes, CSS files, and build statistics.
128#[napi]
129#[allow(clippy::cast_possible_truncation)] // stats are bounded by component/file counts
130pub fn build(options: JsBuildOptions) -> napi::Result<JsBuildResult> {
131    let css = options
132        .css
133        .map(|s| s.parse::<webui::CssStrategy>())
134        .transpose()
135        .map_err(NapiError::from_reason)?
136        .unwrap_or_default();
137
138    let dom = options
139        .dom
140        .map(|s| s.parse::<webui::DomStrategy>())
141        .transpose()
142        .map_err(NapiError::from_reason)?
143        .unwrap_or_default();
144
145    let plugin = options
146        .plugin
147        .map(|s| s.parse::<webui::Plugin>())
148        .transpose()
149        .map_err(NapiError::from_reason)?;
150
151    let legal_comments = options
152        .legal_comments
153        .map(|s| s.parse::<webui::LegalComments>())
154        .transpose()
155        .map_err(NapiError::from_reason)?
156        .unwrap_or_default();
157
158    let app_dir = std::path::PathBuf::from(&options.app_dir);
159    let theme = options
160        .theme
161        .as_deref()
162        .map(|theme| load_theme(theme, &app_dir))
163        .transpose()?;
164    let mut projection_manifests: Vec<webui::ProjectionManifestSource> = options
165        .projection_manifests
166        .unwrap_or_default()
167        .into_iter()
168        .map(std::path::PathBuf::from)
169        .map(Into::into)
170        .collect();
171    projection_manifests.extend(
172        options
173            .projection_manifest_objects
174            .unwrap_or_default()
175            .into_iter()
176            .map(|manifest| webui::ProjectionManifestSource::Inline {
177                manifest_path: std::path::PathBuf::from(manifest.path),
178                json: manifest.json,
179            }),
180    );
181
182    let build_options = webui::BuildOptions {
183        app_dir,
184        entry: options.entry.unwrap_or_else(|| "index.html".to_string()),
185        css,
186        dom,
187        plugin,
188        components: options.components.unwrap_or_default(),
189        component_asset_roots: options.component_asset_roots.unwrap_or_default(),
190        metafile: options.metafile.unwrap_or(false),
191        css_file_name_template: options
192            .css_file_name_template
193            .unwrap_or_else(|| webui::DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string()),
194        css_public_base: options.css_public_base,
195        legal_comments,
196        theme,
197        projection_manifests,
198    };
199
200    let result = webui::build(build_options)
201        .map_err(|e| NapiError::from_reason(format!("Build error: {}", e.chain_message())))?;
202
203    // Flatten css_files into alternating [filename, content, ...] for JS interop
204    let css_files: Vec<String> = result
205        .css_files
206        .into_iter()
207        .flat_map(|(name, content)| [name, content])
208        .collect();
209    let component_asset_files: Vec<String> = result
210        .component_asset_files
211        .into_iter()
212        .flat_map(|file| [file.name, file.content])
213        .collect();
214    let warnings: Vec<String> = result.warnings.iter().map(|d| d.to_string()).collect();
215
216    Ok(JsBuildResult {
217        protocol: Buffer::from(result.protocol_bytes),
218        css_files,
219        component_asset_files,
220        metafile: result.metafile,
221        warnings,
222        stats: JsBuildStats {
223            duration_ms: result.stats.duration.as_secs_f64() * 1000.0,
224            fragment_count: result.stats.fragment_count as u32,
225            component_count: result.stats.component_count as u32,
226            css_file_count: result.stats.css_file_count as u32,
227            protocol_size_bytes: result.stats.protocol_size_bytes as u32,
228            token_count: result.stats.token_count as u32,
229        },
230    })
231}
232
233fn load_theme(theme: &str, search_root: &std::path::Path) -> napi::Result<webui::TokenFile> {
234    let resolved = webui::resolve_theme_path(theme, search_root)
235        .map_err(|e| NapiError::from_reason(format!("Theme resolution error: {e}")))?;
236    webui::load_token_file(&resolved).map_err(|e| {
237        NapiError::from_reason(format!("Theme load error for {}: {e}", resolved.display()))
238    })
239}
240
241/// Inspect protocol bytes and return a JSON representation.
242#[napi]
243pub fn inspect(protocol_data: Buffer) -> napi::Result<String> {
244    webui::inspect_bytes(&protocol_data)
245        .map_err(|e| NapiError::from_reason(format!("Inspect error: {e}")))
246}
247
248/// A decoded protocol and its reusable deterministic indices.
249///
250/// Create this once when a Node server loads `protocol.bin`, then reuse it for
251/// full renders, partial navigation, component loading, and token queries. The
252/// selected hydration plugin is bound once at construction.
253#[napi]
254pub struct Protocol {
255    inner: Arc<HandlerProtocol>,
256    handler: Arc<WebUIHandler>,
257}
258
259#[napi]
260impl Protocol {
261    /// Decode a protocol and bind its render plugin for repeated rendering.
262    #[napi(constructor)]
263    pub fn new(protocol_data: Buffer, plugin: Option<String>) -> napi::Result<Self> {
264        let inner = Arc::new(decode_protocol(&protocol_data)?);
265        let handler = Arc::new(create_handler(plugin)?);
266        Ok(Self { inner, handler })
267    }
268
269    /// Render from an existing JSON string into a UTF-8 Node.js buffer.
270    #[napi]
271    pub fn render(
272        &self,
273        state_json: String,
274        entry: String,
275        request_path: String,
276    ) -> napi::Result<Buffer> {
277        let state = parse_state_json(&state_json)?;
278        let options = RenderOptions::new(&entry, &request_path);
279        let html = render_to_string(&self.handler, &self.inner, &state, &options)?;
280        // napi-rs can expose the existing Vec as an external Buffer on Node.
281        Ok(Buffer::from(html.into_bytes()))
282    }
283
284    /// Stream an existing JSON string in bounded chunks.
285    #[napi]
286    pub fn render_stream(
287        &self,
288        state_json: String,
289        entry: String,
290        request_path: String,
291        on_chunk: Function<String>,
292    ) -> napi::Result<()> {
293        let state = parse_state_json(&state_json)?;
294        let options = RenderOptions::new(&entry, &request_path);
295        render_to_callback(&self.handler, &self.inner, &state, &options, &on_chunk)
296    }
297
298    /// Produce a complete partial-navigation response.
299    #[napi]
300    pub fn render_partial(
301        &self,
302        state_json: String,
303        entry_id: String,
304        request_path: String,
305        inventory_hex: String,
306    ) -> napi::Result<String> {
307        self.inner
308            .render_partial(&state_json, &entry_id, &request_path, &inventory_hex)
309            .map_err(|e| NapiError::from_reason(format!("render_partial failed: {e}")))
310    }
311
312    /// Render component templates and styles for on-demand loading.
313    #[napi]
314    pub fn render_component_templates(
315        &self,
316        component_tags: Vec<String>,
317        inventory_hex: String,
318    ) -> napi::Result<String> {
319        let tag_refs: Vec<&str> = component_tags.iter().map(String::as_str).collect();
320        let result = self
321            .inner
322            .render_component_templates(&tag_refs, &inventory_hex)
323            .map_err(|e| {
324                NapiError::from_reason(format!("render_component_templates failed: {e}"))
325            })?;
326        serde_json::to_string(&result)
327            .map_err(|e| NapiError::from_reason(format!("JSON serialize error: {e}")))
328    }
329
330    /// Return CSS token names in build order.
331    #[napi]
332    pub fn tokens(&self) -> Vec<String> {
333        self.inner.tokens().to_vec()
334    }
335
336    /// Open a host-driven progressive response for a streaming entry.
337    ///
338    /// Unlike `renderStream`, which pushes every chunk during one synchronous
339    /// call, the returned session hands each chunk back so the Node server
340    /// owns the socket, the write order, and backpressure.
341    #[napi]
342    pub fn stream_response(
343        &self,
344        entry: String,
345        request_path: String,
346        options: Option<JsStreamOptions>,
347    ) -> napi::Result<StreamingSession> {
348        let options = options.unwrap_or_default();
349        let mut session_options = SessionOptions::new(entry, request_path);
350        session_options.nonce = options.nonce;
351        session_options.head_inject = options.head_inject;
352        session_options.body_inject = options.body_inject;
353        let inner = HandlerStreamingSession::new(
354            Arc::clone(&self.handler),
355            Arc::clone(&self.inner),
356            session_options,
357        )
358        .map_err(streaming_error)?;
359        Ok(StreamingSession { inner })
360    }
361}
362
363/// Optional per-response streaming settings.
364#[napi(object)]
365#[derive(Default)]
366pub struct JsStreamOptions {
367    /// CSP nonce applied to generated inline `<script>` tags.
368    pub nonce: Option<String>,
369    /// HTML injected at the structural `head_end` boundary.
370    pub head_inject: Option<String>,
371    /// HTML injected at the structural `body_end` boundary.
372    pub body_inject: Option<String>,
373}
374
375/// A progressive HTML response driven one chunk at a time from Node.
376///
377/// Every method returns the bytes it produced. Write them to the response and
378/// await `drain` whenever `write()` returns `false`; the session holds no
379/// transport and never blocks on one.
380///
381/// ```js
382/// const session = protocol.streamResponse('index.html', '/');
383/// const weather = session.boundary('weather-shell');
384/// res.write(session.writeShell(shellState));
385/// res.write(session.writeBoundary(weather, weatherState, 'updatable'));
386/// res.write(session.update(weather, forecast));
387/// res.end(session.finish(tailState));
388/// ```
389#[napi]
390pub struct StreamingSession {
391    inner: HandlerStreamingSession,
392}
393
394#[napi]
395impl StreamingSession {
396    /// Resolve an authored boundary name to a stable integer handle.
397    ///
398    /// Resolve once outside the write loop; the handle costs nothing to reuse.
399    #[napi]
400    pub fn boundary(&self, name: String) -> napi::Result<u32> {
401        self.inner
402            .boundary(&name)
403            .map(BoundaryId::raw)
404            .map_err(streaming_error)
405    }
406
407    /// Number of compile-time boundaries declared by this entry.
408    #[napi(getter)]
409    pub fn boundary_count(&self) -> u32 {
410        // Boundary counts are bounded by the compiled entry, so this cannot
411        // exceed u32 in any protocol the build can produce.
412        u32::try_from(self.inner.boundary_count()).unwrap_or(u32::MAX)
413    }
414
415    /// Whether the terminal record has been written.
416    #[napi(getter)]
417    pub fn finished(&self) -> bool {
418        self.inner.is_finished()
419    }
420
421    /// Render everything before the first boundary.
422    #[napi]
423    pub fn write_shell(&mut self, state_json: String) -> napi::Result<Buffer> {
424        let state = parse_state_json(&state_json)?;
425        self.inner
426            .write_shell(&state)
427            .map(Buffer::from)
428            .map_err(streaming_error)
429    }
430
431    /// Render and commit the next boundary in declaration order.
432    ///
433    /// `mode` is `"final"` (default) or `"updatable"`. Only updatable
434    /// boundaries accept later `update()` calls.
435    #[napi]
436    pub fn write_boundary(
437        &mut self,
438        boundary: u32,
439        state_json: String,
440        mode: Option<String>,
441    ) -> napi::Result<Buffer> {
442        let state = parse_state_json(&state_json)?;
443        let mode = parse_boundary_mode(mode.as_deref())?;
444        self.inner
445            .write_boundary(BoundaryId::from_raw(boundary), &state, mode)
446            .map(Buffer::from)
447            .map_err(streaming_error)
448    }
449
450    /// Push a projected state patch to a committed updatable boundary.
451    #[napi]
452    pub fn update(&mut self, boundary: u32, state_json: String) -> napi::Result<Buffer> {
453        let state = parse_state_json(&state_json)?;
454        self.inner
455            .update(BoundaryId::from_raw(boundary), &state)
456            .map(Buffer::from)
457            .map_err(streaming_error)
458    }
459
460    /// Render the document tail and emit the terminal record.
461    #[napi]
462    pub fn finish(&mut self, state_json: String) -> napi::Result<Buffer> {
463        let state = parse_state_json(&state_json)?;
464        self.inner
465            .finish(&state)
466            .map(Buffer::from)
467            .map_err(streaming_error)
468    }
469}
470
471fn parse_boundary_mode(mode: Option<&str>) -> napi::Result<BoundaryMode> {
472    match mode {
473        None | Some("final") => Ok(BoundaryMode::Final),
474        Some("updatable") => Ok(BoundaryMode::Updatable),
475        Some(other) => Err(NapiError::from_reason(format!(
476            "unknown boundary mode '{other}'; expected 'final' or 'updatable'"
477        ))),
478    }
479}
480
481fn streaming_error(error: HandlerError) -> NapiError {
482    NapiError::from_reason(error.to_string())
483}
484
485fn decode_protocol(protocol_data: &[u8]) -> napi::Result<HandlerProtocol> {
486    HandlerProtocol::from_protobuf(protocol_data)
487        .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))
488}
489
490fn parse_state_json(state_json: &str) -> napi::Result<Value> {
491    serde_json::from_str(state_json)
492        .map_err(|e| NapiError::from_reason(format!("State JSON error: {e}")))
493}
494
495fn create_handler(plugin: Option<String>) -> napi::Result<WebUIHandler> {
496    let plugin = plugin
497        .map(|value| value.parse::<webui::Plugin>())
498        .transpose()
499        .map_err(NapiError::from_reason)?;
500    Ok(match plugin {
501        Some(webui::Plugin::Fast | webui::Plugin::FastV2) => {
502            WebUIHandler::with_plugin(|| Box::new(FastV2HydrationPlugin::new()))
503        }
504        Some(webui::Plugin::FastV3) => {
505            WebUIHandler::with_plugin(|| Box::new(FastV3HydrationPlugin::new()))
506        }
507        Some(webui::Plugin::WebUI) => {
508            WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()))
509        }
510        None => WebUIHandler::new(),
511    })
512}
513
514struct BufferedWriter {
515    output: String,
516}
517
518impl BufferedWriter {
519    fn new() -> Self {
520        Self {
521            output: String::with_capacity(4096),
522        }
523    }
524}
525
526impl ResponseWriter for BufferedWriter {
527    fn write(&mut self, content: &str) -> webui_handler::Result<()> {
528        self.output.push_str(content);
529        Ok(())
530    }
531
532    fn end(&mut self) -> webui_handler::Result<()> {
533        Ok(())
534    }
535}
536
537fn render_to_string(
538    handler: &WebUIHandler,
539    protocol: &HandlerProtocol,
540    state: &Value,
541    options: &RenderOptions<'_>,
542) -> napi::Result<String> {
543    let mut writer = BufferedWriter::new();
544    handler
545        .render(protocol, state, options, &mut writer)
546        .map_err(|e| NapiError::from_reason(format!("Render error: {e}")))?;
547    Ok(writer.output)
548}
549
550/// A writer that batches rendered fragments before crossing into JavaScript.
551struct CallbackWriter<F> {
552    callback: F,
553    buffer: String,
554    error: Option<NapiError>,
555}
556
557impl<F> CallbackWriter<F>
558where
559    F: FnMut(String) -> napi::Result<()>,
560{
561    fn new(callback: F) -> Self {
562        Self {
563            callback,
564            buffer: String::with_capacity(STREAM_CHUNK_SIZE),
565            error: None,
566        }
567    }
568
569    fn flush(&mut self) -> webui_handler::Result<()> {
570        if self.error.is_some() {
571            return Err(callback_writer_error());
572        }
573        if self.buffer.is_empty() {
574            return Ok(());
575        }
576
577        let chunk = std::mem::replace(&mut self.buffer, String::with_capacity(STREAM_CHUNK_SIZE));
578        if let Err(error) = (self.callback)(chunk) {
579            self.error = Some(error);
580            return Err(callback_writer_error());
581        }
582        Ok(())
583    }
584}
585
586impl<F> ResponseWriter for CallbackWriter<F>
587where
588    F: FnMut(String) -> napi::Result<()>,
589{
590    fn write(&mut self, content: &str) -> webui_handler::Result<()> {
591        if self.error.is_some() {
592            return Err(callback_writer_error());
593        }
594        self.buffer.push_str(content);
595        if self.buffer.len() >= STREAM_CHUNK_SIZE {
596            self.flush()?;
597        }
598        Ok(())
599    }
600
601    fn end(&mut self) -> webui_handler::Result<()> {
602        self.flush()
603    }
604}
605
606#[cold]
607#[inline(never)]
608fn callback_writer_error() -> HandlerError {
609    HandlerError::Writer("Node chunk callback failed".to_owned())
610}
611
612fn render_to_callback(
613    handler: &WebUIHandler,
614    protocol: &HandlerProtocol,
615    state: &Value,
616    options: &RenderOptions<'_>,
617    on_chunk: &Function<String>,
618) -> napi::Result<()> {
619    let mut writer = CallbackWriter::new(|chunk| on_chunk.call(chunk).map(drop));
620    let render_result = handler.render(protocol, state, options, &mut writer);
621    if let Some(error) = writer.error {
622        return Err(error);
623    }
624    render_result.map_err(|e| NapiError::from_reason(format!("Render error: {e}")))
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630    use std::collections::BTreeMap;
631    use webui_parser::HtmlParser;
632    use webui_protocol::projection_manifest::{
633        ProjectionAdapter, ProjectionComponent, ProjectionManifest, ProjectionProducer,
634        PRODUCER_NAME, SCHEMA_ID,
635    };
636
637    /// Helper: parse HTML into protobuf bytes for testing.
638    fn build_protocol(html: &str) -> Vec<u8> {
639        let mut parser = HtmlParser::new();
640        parser.parse("index.html", html).expect("parse failed");
641        let tokens = parser.take_tokens();
642        let protocol = WebUIProtocol::with_tokens(parser.into_fragment_records(), tokens);
643        protocol.to_protobuf().expect("protobuf encode failed")
644    }
645
646    /// Helper: render protocol bytes + state, collecting output into a String.
647    fn render_to_string(protocol_bytes: &[u8], state_json: &str) -> Result<String, String> {
648        let protocol = HandlerProtocol::from_protobuf(protocol_bytes).map_err(|e| e.to_string())?;
649        let state: Value = serde_json::from_str(state_json).map_err(|e| e.to_string())?;
650
651        let mut output = String::with_capacity(1024);
652        let handler = WebUIHandler::new();
653
654        struct StringWriter<'a> {
655            output: &'a mut String,
656        }
657        impl ResponseWriter for StringWriter<'_> {
658            fn write(&mut self, content: &str) -> webui_handler::Result<()> {
659                self.output.push_str(content);
660                Ok(())
661            }
662            fn end(&mut self) -> webui_handler::Result<()> {
663                Ok(())
664            }
665        }
666
667        let mut writer = StringWriter {
668            output: &mut output,
669        };
670        handler
671            .render(
672                &protocol,
673                &state,
674                &RenderOptions::new("index.html", "/"),
675                &mut writer,
676            )
677            .map_err(|e| e.to_string())?;
678        Ok(output)
679    }
680
681    #[test]
682    fn test_simple_passthrough() {
683        let proto = build_protocol("<p>Hello</p>");
684        let result = render_to_string(&proto, "{}");
685        assert_eq!(result.as_deref(), Ok("<p>Hello</p>"));
686    }
687
688    #[test]
689    fn test_signal_substitution() {
690        let proto = build_protocol("Hello, {{name}}!");
691        let result = render_to_string(&proto, r#"{"name": "WebUI"}"#);
692        assert_eq!(result.as_deref(), Ok("Hello, WebUI!"));
693    }
694
695    #[test]
696    fn protocol_reuses_decoded_protocol_for_json_state() {
697        let proto = build_protocol("Hello, {{name}}!");
698        let protocol = Protocol::new(Buffer::from(proto), None).expect("protocol should load");
699
700        let first = protocol
701            .render(
702                r#"{"name":"First"}"#.to_string(),
703                "index.html".to_string(),
704                "/".to_string(),
705            )
706            .expect("first render should succeed");
707        let second = protocol
708            .render(
709                r#"{"name":"世界"}"#.to_string(),
710                "index.html".to_string(),
711                "/".to_string(),
712            )
713            .expect("second render should succeed");
714
715        assert_eq!(first.as_ref(), b"Hello, First!");
716        assert_eq!(second.as_ref(), "Hello, 世界!".as_bytes());
717    }
718
719    #[test]
720    fn test_for_loop() {
721        let proto = build_protocol("<ul><for each=\"item in items\"><li>{{item}}</li></for></ul>");
722        let result = render_to_string(&proto, r#"{"items": ["a", "b", "c"]}"#);
723        assert_eq!(
724            result.as_deref(),
725            Ok("<ul><li>a</li><li>b</li><li>c</li></ul>")
726        );
727    }
728
729    #[test]
730    fn test_if_condition_true() {
731        let proto = build_protocol("<if condition=\"show\"><p>Visible</p></if>");
732        let result = render_to_string(&proto, r#"{"show": true}"#);
733        assert_eq!(result.as_deref(), Ok("<p>Visible</p>"));
734    }
735
736    #[test]
737    fn test_if_condition_false() {
738        let proto = build_protocol("<if condition=\"show\"><p>Hidden</p></if>");
739        let result = render_to_string(&proto, r#"{"show": false}"#);
740        assert_eq!(result.as_deref(), Ok(""));
741    }
742
743    #[test]
744    fn test_html_escaping() {
745        let proto = build_protocol("<div>{{content}}</div>");
746        let state = r#"{"content": "<script>alert('xss')</script>"}"#;
747        let result = render_to_string(&proto, state).expect("render should succeed");
748        assert!(!result.contains("<script>"));
749        assert!(result.contains("&lt;script&gt;"));
750    }
751
752    #[test]
753    fn test_raw_signal() {
754        let proto = build_protocol("<div>{{{content}}}</div>");
755        let result = render_to_string(&proto, r#"{"content": "<b>bold</b>"}"#);
756        assert_eq!(result.as_deref(), Ok("<div><b>bold</b></div>"));
757    }
758
759    #[test]
760    fn test_invalid_json() {
761        let proto = build_protocol("<p>hi</p>");
762        let result = render_to_string(&proto, "NOT JSON");
763        assert!(result.is_err());
764    }
765
766    #[test]
767    fn test_empty_state() {
768        let proto = build_protocol("<p>static</p>");
769        let result = render_to_string(&proto, "{}");
770        assert_eq!(result.as_deref(), Ok("<p>static</p>"));
771    }
772
773    #[test]
774    fn test_nested_object_signal() {
775        let proto = build_protocol("{{user.name}}");
776        let result = render_to_string(&proto, r#"{"user": {"name": "Alice"}}"#);
777        assert_eq!(result.as_deref(), Ok("Alice"));
778    }
779
780    #[test]
781    fn test_invalid_protobuf() {
782        let result = render_to_string(&[0xFF, 0xFF, 0xFF], "{}");
783        assert!(result.is_err());
784    }
785
786    /// Parse `html`, attach a sorted hydration `schema`, and encode to protobuf.
787    fn build_projected_protocol(html: &str, schema: &[&str]) -> Vec<u8> {
788        let mut parser = HtmlParser::new();
789        parser.parse("index.html", html).expect("parse failed");
790        let tokens = parser.take_tokens();
791        let mut protocol = WebUIProtocol::with_tokens(parser.into_fragment_records(), tokens);
792        protocol.fragments.insert(
793            "client-card".to_string(),
794            webui_protocol::FragmentList {
795                fragments: vec![webui_protocol::WebUIFragment::raw("<p>client</p>")],
796            },
797        );
798        protocol
799            .fragments
800            .get_mut("index.html")
801            .expect("index fragment should exist")
802            .fragments
803            .insert(1, webui_protocol::WebUIFragment::component("client-card"));
804        protocol.initial_state_strategy = webui_protocol::InitialStateStrategy::Components as i32;
805        protocol.components.insert(
806            "client-card".to_string(),
807            webui_protocol::ComponentData {
808                hydration_mode: webui_protocol::StateProjectionMode::Keys as i32,
809                hydration_keys: schema.iter().map(|key| (*key).to_string()).collect(),
810                ..Default::default()
811            },
812        );
813        protocol.to_protobuf().expect("protobuf encode failed")
814    }
815
816    /// Render protocol bytes with the WebUI hydration plugin so the `#webui-data`
817    /// bootstrap block (and its projected state) is emitted — this mirrors the
818    /// production `render(..., plugin = "webui")` path.
819    fn render_with_webui_plugin(protocol_bytes: &[u8], state_json: &str) -> Result<String, String> {
820        let protocol = HandlerProtocol::from_protobuf(protocol_bytes).map_err(|e| e.to_string())?;
821        let state: Value = serde_json::from_str(state_json).map_err(|e| e.to_string())?;
822
823        let mut output = String::with_capacity(1024);
824        struct StringWriter<'a> {
825            output: &'a mut String,
826        }
827        impl ResponseWriter for StringWriter<'_> {
828            fn write(&mut self, content: &str) -> webui_handler::Result<()> {
829                self.output.push_str(content);
830                Ok(())
831            }
832            fn end(&mut self) -> webui_handler::Result<()> {
833                Ok(())
834            }
835        }
836        let mut writer = StringWriter {
837            output: &mut output,
838        };
839        let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
840        handler
841            .render(
842                &protocol,
843                &state,
844                &RenderOptions::new("index.html", "/"),
845                &mut writer,
846            )
847            .map_err(|e| e.to_string())?;
848        Ok(output)
849    }
850
851    #[test]
852    fn render_projects_state_to_component_hydration_keys() {
853        // Full document so the parser emits a `body_end` signal, which makes the
854        // WebUI plugin emit the #webui-data bootstrap block.
855        let bytes =
856            build_projected_protocol("<html><body><p>{{kept}}</p></body></html>", &["kept"]);
857        let out =
858            render_with_webui_plugin(&bytes, r#"{"kept":"KEPT_VALUE","dropped":"DROPPED_VALUE"}"#)
859                .expect("render should succeed");
860
861        // Only the hydratable key reaches the bootstrap state block...
862        assert!(
863            out.contains(r#""kept":"KEPT_VALUE""#),
864            "hydratable key missing from bootstrap state: {out}"
865        );
866        // ...the non-hydratable key is projected out entirely.
867        assert!(
868            !out.contains("DROPPED_VALUE"),
869            "server-only value leaked: {out}"
870        );
871        assert!(
872            !out.contains("dropped"),
873            "server-only key name leaked: {out}"
874        );
875    }
876
877    // ── Tests for build() and inspect() napi exports ─────────────────
878
879    fn projection_manifest_json() -> String {
880        const EMPTY_SHA256: &str =
881            "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
882        let mut manifest = ProjectionManifest {
883            schema: SCHEMA_ID.to_string(),
884            producer: ProjectionProducer {
885                name: PRODUCER_NAME.to_string(),
886                version: "0.0.18".to_string(),
887            },
888            adapter: ProjectionAdapter {
889                name: "test".to_string(),
890                bundler: "test@1.0.0".to_string(),
891            },
892            root: ".".to_string(),
893            analysis_hash: format!("sha256:{}", "1".repeat(64)),
894            build_id: String::new(),
895            inputs: BTreeMap::from([("demo-card.ts".to_string(), EMPTY_SHA256.to_string())]),
896            outputs: BTreeMap::from([("bundle.js".to_string(), EMPTY_SHA256.to_string())]),
897            components: BTreeMap::from([(
898                "demo-card".to_string(),
899                ProjectionComponent {
900                    module: "demo-card.ts".to_string(),
901                    outputs: vec!["bundle.js".to_string()],
902                    hydration_keys: vec!["name".to_string()],
903                    navigation_keys: vec!["label".to_string(), "name".to_string()],
904                },
905            )]),
906            entry_closures: BTreeMap::new(),
907        };
908        manifest.build_id = manifest.compute_build_id();
909        serde_json::to_string(&manifest).unwrap()
910    }
911
912    fn projection_build_options(app_dir: &std::path::Path) -> JsBuildOptions {
913        JsBuildOptions {
914            app_dir: app_dir.to_string_lossy().to_string(),
915            entry: None,
916            css: None,
917            dom: None,
918            plugin: Some("webui".to_string()),
919            components: None,
920            component_asset_roots: None,
921            metafile: None,
922            css_file_name_template: None,
923            css_public_base: None,
924            legal_comments: None,
925            theme: None,
926            projection_manifests: None,
927            projection_manifest_objects: None,
928        }
929    }
930
931    #[test]
932    fn test_build_accepts_projection_paths_and_inline_objects() {
933        let dir = tempfile::TempDir::new().unwrap();
934        std::fs::write(dir.path().join("index.html"), "<demo-card></demo-card>").unwrap();
935        std::fs::write(dir.path().join("demo-card.html"), "<p>{{name}}</p>").unwrap();
936        std::fs::write(dir.path().join("demo-card.ts"), "").unwrap();
937        std::fs::write(dir.path().join("bundle.js"), "").unwrap();
938        let manifest_path = dir.path().join("projection.json");
939        let json = projection_manifest_json();
940        std::fs::write(&manifest_path, &json).unwrap();
941
942        let mut path_options = projection_build_options(dir.path());
943        path_options.projection_manifests = Some(vec![manifest_path.to_string_lossy().to_string()]);
944        let path_result = build(path_options).unwrap();
945        let path_protocol = WebUIProtocol::from_protobuf(&path_result.protocol).unwrap();
946        assert_eq!(
947            path_protocol.components["demo-card"].hydration_keys,
948            ["name"]
949        );
950
951        std::fs::remove_file(&manifest_path).unwrap();
952        let mut inline_options = projection_build_options(dir.path());
953        inline_options.projection_manifest_objects = Some(vec![JsProjectionManifest {
954            path: manifest_path.to_string_lossy().to_string(),
955            json,
956        }]);
957        let inline_result = build(inline_options).unwrap();
958        let inline_protocol = WebUIProtocol::from_protobuf(&inline_result.protocol).unwrap();
959        assert_eq!(
960            inline_protocol.components["demo-card"].navigation_keys,
961            ["label", "name"]
962        );
963    }
964
965    #[test]
966    fn test_build_simple_app() {
967        let dir = tempfile::TempDir::new().unwrap();
968        std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
969
970        let options = JsBuildOptions {
971            app_dir: dir.path().to_string_lossy().to_string(),
972            entry: None,
973            css: None,
974            dom: None,
975            plugin: None,
976            components: None,
977            component_asset_roots: None,
978            metafile: None,
979            css_file_name_template: None,
980            css_public_base: None,
981            legal_comments: None,
982            theme: None,
983            projection_manifests: None,
984            projection_manifest_objects: None,
985        };
986
987        let result = build(options).unwrap();
988        assert!(!result.protocol.is_empty());
989        assert!(result.stats.fragment_count > 0);
990        assert!(result.stats.protocol_size_bytes > 0);
991        assert!(result.stats.duration_ms >= 0.0);
992    }
993
994    #[test]
995    fn test_build_with_custom_entry() {
996        let dir = tempfile::TempDir::new().unwrap();
997        std::fs::write(dir.path().join("page.html"), "<p>Custom</p>").unwrap();
998
999        let options = JsBuildOptions {
1000            app_dir: dir.path().to_string_lossy().to_string(),
1001            entry: Some("page.html".to_string()),
1002            css: None,
1003            dom: None,
1004            plugin: None,
1005            components: None,
1006            component_asset_roots: None,
1007            metafile: None,
1008            css_file_name_template: None,
1009            css_public_base: None,
1010            legal_comments: None,
1011            theme: None,
1012            projection_manifests: None,
1013            projection_manifest_objects: None,
1014        };
1015
1016        let result = build(options).unwrap();
1017        assert!(!result.protocol.is_empty());
1018    }
1019
1020    #[test]
1021    fn test_build_missing_app_dir() {
1022        let options = JsBuildOptions {
1023            app_dir: "/nonexistent/path".to_string(),
1024            entry: None,
1025            css: None,
1026            dom: None,
1027            plugin: None,
1028            components: None,
1029            component_asset_roots: None,
1030            metafile: None,
1031            css_file_name_template: None,
1032            css_public_base: None,
1033            legal_comments: None,
1034            theme: None,
1035            projection_manifests: None,
1036            projection_manifest_objects: None,
1037        };
1038
1039        let result = build(options);
1040        assert!(result.is_err());
1041    }
1042
1043    #[test]
1044    fn test_build_invalid_css() {
1045        let dir = tempfile::TempDir::new().unwrap();
1046        std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
1047
1048        let options = JsBuildOptions {
1049            app_dir: dir.path().to_string_lossy().to_string(),
1050            entry: None,
1051            css: Some("bogus".to_string()),
1052            dom: None,
1053            plugin: None,
1054            components: None,
1055            component_asset_roots: None,
1056            metafile: None,
1057            css_file_name_template: None,
1058            css_public_base: None,
1059            legal_comments: None,
1060            theme: None,
1061            projection_manifests: None,
1062            projection_manifest_objects: None,
1063        };
1064
1065        let result = build(options);
1066        assert!(result.is_err());
1067    }
1068
1069    #[test]
1070    fn test_build_with_components_css() {
1071        let dir = tempfile::TempDir::new().unwrap();
1072        std::fs::write(dir.path().join("index.html"), "<my-card>Hello</my-card>").unwrap();
1073        std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
1074        std::fs::write(dir.path().join("my-card.css"), ".card { color: red; }").unwrap();
1075
1076        let options = JsBuildOptions {
1077            app_dir: dir.path().to_string_lossy().to_string(),
1078            entry: None,
1079            css: Some("link".to_string()),
1080            dom: None,
1081            plugin: None,
1082            components: None,
1083            component_asset_roots: None,
1084            metafile: None,
1085            css_file_name_template: None,
1086            css_public_base: None,
1087            legal_comments: None,
1088            theme: None,
1089            projection_manifests: None,
1090            projection_manifest_objects: None,
1091        };
1092
1093        let result = build(options).unwrap();
1094        // css_files is flattened: [filename, content, filename, content, ...]
1095        assert_eq!(result.css_files.len(), 2);
1096        assert_eq!(result.css_files[0], "my-card.css");
1097        assert!(result.css_files[1].contains("color: red"));
1098        assert_eq!(result.stats.css_file_count, 1);
1099    }
1100
1101    #[test]
1102    fn test_build_with_theme_missing_token_errors() {
1103        let dir = tempfile::TempDir::new().unwrap();
1104        std::fs::write(dir.path().join("index.html"), "<my-card></my-card>").unwrap();
1105        std::fs::write(dir.path().join("my-card.html"), "<div>Card</div>").unwrap();
1106        std::fs::write(
1107            dir.path().join("my-card.css"),
1108            ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c))); }",
1109        )
1110        .unwrap();
1111        let theme_path = dir.path().join("theme.json");
1112        std::fs::write(&theme_path, r#"{"themes":{"light":{"token-b":"green"}}}"#).unwrap();
1113
1114        let options = JsBuildOptions {
1115            app_dir: dir.path().to_string_lossy().to_string(),
1116            entry: None,
1117            css: Some("link".to_string()),
1118            dom: None,
1119            plugin: None,
1120            components: None,
1121            component_asset_roots: None,
1122            metafile: None,
1123            css_file_name_template: None,
1124            css_public_base: None,
1125            legal_comments: None,
1126            theme: Some(theme_path.to_string_lossy().to_string()),
1127            projection_manifests: None,
1128            projection_manifest_objects: None,
1129        };
1130
1131        let Err(err) = build(options) else {
1132            panic!("missing theme token must fail");
1133        };
1134        let message = err.to_string();
1135        assert!(message.contains("missing-theme-token"), "msg: {message}");
1136        assert!(message.contains("--token-c"), "msg: {message}");
1137    }
1138
1139    #[test]
1140    fn test_build_returns_component_asset_files() {
1141        let dir = tempfile::TempDir::new().unwrap();
1142        std::fs::write(dir.path().join("index.html"), "<app-shell></app-shell>").unwrap();
1143        std::fs::write(dir.path().join("app-shell.html"), "<div></div>").unwrap();
1144        std::fs::write(dir.path().join("lazy-panel.html"), "<p>{{title}}</p>").unwrap();
1145        std::fs::write(dir.path().join("lazy-panel.ts"), "export {};").unwrap();
1146
1147        let options = JsBuildOptions {
1148            app_dir: dir.path().to_string_lossy().to_string(),
1149            entry: None,
1150            css: Some("link".to_string()),
1151            dom: None,
1152            plugin: Some("webui".to_string()),
1153            components: None,
1154            component_asset_roots: Some(vec!["lazy-panel".to_string()]),
1155            metafile: Some(true),
1156            css_file_name_template: None,
1157            css_public_base: None,
1158            legal_comments: None,
1159            theme: None,
1160            projection_manifests: None,
1161            projection_manifest_objects: None,
1162        };
1163
1164        let result = build(options).unwrap();
1165
1166        assert_eq!(result.component_asset_files.len(), 2);
1167        assert_eq!(result.component_asset_files[0], "lazy-panel.webui.js");
1168        assert!(result.component_asset_files[1].contains("webui-component-asset"));
1169        assert!(result.component_asset_files[1].contains("export default asset;"));
1170        let metafile = result
1171            .metafile
1172            .as_deref()
1173            .expect("requested metafile must be returned");
1174        let parsed: serde_json::Value = serde_json::from_str(metafile).unwrap();
1175        assert!(parsed["inputs"].get("webui:component/lazy-panel").is_some());
1176        assert!(parsed["outputs"].get("lazy-panel.webui.js").is_some());
1177    }
1178
1179    #[test]
1180    fn test_build_legal_comments_none_strips_legal_css() {
1181        let dir = tempfile::TempDir::new().unwrap();
1182        std::fs::write(dir.path().join("index.html"), "<my-card>Hello</my-card>").unwrap();
1183        std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
1184        std::fs::write(
1185            dir.path().join("my-card.css"),
1186            "/*! @license MIT */ .card { color: red; }",
1187        )
1188        .unwrap();
1189
1190        let options = JsBuildOptions {
1191            app_dir: dir.path().to_string_lossy().to_string(),
1192            entry: None,
1193            css: Some("link".to_string()),
1194            dom: None,
1195            plugin: None,
1196            components: None,
1197            component_asset_roots: None,
1198            metafile: None,
1199            css_file_name_template: None,
1200            css_public_base: None,
1201            legal_comments: Some("none".to_string()),
1202            theme: None,
1203            projection_manifests: None,
1204            projection_manifest_objects: None,
1205        };
1206
1207        let result = build(options).unwrap();
1208        assert_eq!(result.css_files[1], " .card { color: red; }");
1209    }
1210
1211    #[test]
1212    fn test_build_invalid_legal_comments() {
1213        let dir = tempfile::TempDir::new().unwrap();
1214        std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
1215
1216        let options = JsBuildOptions {
1217            app_dir: dir.path().to_string_lossy().to_string(),
1218            entry: None,
1219            css: None,
1220            dom: None,
1221            plugin: None,
1222            components: None,
1223            component_asset_roots: None,
1224            metafile: None,
1225            css_file_name_template: None,
1226            css_public_base: None,
1227            legal_comments: Some("linked".to_string()),
1228            theme: None,
1229            projection_manifests: None,
1230            projection_manifest_objects: None,
1231        };
1232
1233        let result = build(options);
1234        assert!(result.is_err());
1235    }
1236
1237    #[test]
1238    fn test_build_with_light_dom_omits_shadow_root_template() {
1239        let dir = tempfile::TempDir::new().unwrap();
1240        std::fs::write(dir.path().join("index.html"), "<my-card>Hi</my-card>").unwrap();
1241        std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
1242
1243        let options = JsBuildOptions {
1244            app_dir: dir.path().to_string_lossy().to_string(),
1245            entry: None,
1246            css: None,
1247            dom: Some("light".to_string()),
1248            plugin: None,
1249            components: None,
1250            component_asset_roots: None,
1251            metafile: None,
1252            css_file_name_template: None,
1253            css_public_base: None,
1254            legal_comments: None,
1255            theme: None,
1256            projection_manifests: None,
1257            projection_manifest_objects: None,
1258        };
1259
1260        let result = build(options).unwrap();
1261        let json = inspect(result.protocol).unwrap();
1262        assert!(
1263            !json.contains("shadowrootmode"),
1264            "light DOM build should not emit shadowrootmode wrappers, got: {json}"
1265        );
1266    }
1267
1268    #[test]
1269    fn test_build_with_shadow_dom_emits_shadow_root_template() {
1270        let dir = tempfile::TempDir::new().unwrap();
1271        std::fs::write(dir.path().join("index.html"), "<my-card>Hi</my-card>").unwrap();
1272        std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
1273
1274        let options = JsBuildOptions {
1275            app_dir: dir.path().to_string_lossy().to_string(),
1276            entry: None,
1277            css: None,
1278            dom: Some("shadow".to_string()),
1279            plugin: None,
1280            components: None,
1281            component_asset_roots: None,
1282            metafile: None,
1283            css_file_name_template: None,
1284            css_public_base: None,
1285            legal_comments: None,
1286            theme: None,
1287            projection_manifests: None,
1288            projection_manifest_objects: None,
1289        };
1290
1291        let result = build(options).unwrap();
1292        let json = inspect(result.protocol).unwrap();
1293        assert!(
1294            json.contains("shadowrootmode"),
1295            "shadow DOM build should emit shadowrootmode wrappers, got: {json}"
1296        );
1297    }
1298
1299    #[test]
1300    fn test_build_invalid_dom() {
1301        let dir = tempfile::TempDir::new().unwrap();
1302        std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
1303
1304        let options = JsBuildOptions {
1305            app_dir: dir.path().to_string_lossy().to_string(),
1306            entry: None,
1307            css: None,
1308            dom: Some("bogus".to_string()),
1309            plugin: None,
1310            components: None,
1311            component_asset_roots: None,
1312            metafile: None,
1313            css_file_name_template: None,
1314            css_public_base: None,
1315            legal_comments: None,
1316            theme: None,
1317            projection_manifests: None,
1318            projection_manifest_objects: None,
1319        };
1320
1321        let result = build(options);
1322        assert!(result.is_err());
1323    }
1324
1325    #[test]
1326    fn test_inspect_valid_protocol() {
1327        let proto = build_protocol("<h1>Hello {{name}}</h1>");
1328        let json = inspect(napi::bindgen_prelude::Buffer::from(proto)).unwrap();
1329        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1330        assert!(parsed.get("fragments").is_some());
1331    }
1332
1333    #[test]
1334    fn test_inspect_invalid_protocol() {
1335        let result = inspect(napi::bindgen_prelude::Buffer::from(vec![0xFF, 0xFF]));
1336        assert!(result.is_err());
1337    }
1338
1339    #[test]
1340    fn test_protocol_tokens_empty() {
1341        let proto = build_protocol("<p>Hello</p>");
1342        let protocol = Protocol::new(Buffer::from(proto), None).unwrap();
1343        let tokens = protocol.tokens();
1344        assert!(tokens.is_empty());
1345    }
1346
1347    #[test]
1348    fn test_protocol_tokens_returns_parsed_tokens() {
1349        // Build from a protocol that has CSS tokens via with_tokens constructor.
1350        let mut parser = HtmlParser::new();
1351        parser.parse("index.html", "<p>Hi</p>").expect("parse");
1352        let protocol = WebUIProtocol::with_tokens(
1353            parser.into_fragment_records(),
1354            vec![
1355                "colorBrandBackground".to_string(),
1356                "fontSizeBase300".to_string(),
1357            ],
1358        );
1359        let proto = protocol.to_protobuf().expect("encode");
1360        let protocol = Protocol::new(Buffer::from(proto), None).unwrap();
1361        let tokens = protocol.tokens();
1362        assert_eq!(tokens, vec!["colorBrandBackground", "fontSizeBase300"]);
1363    }
1364
1365    #[test]
1366    fn test_protocol_tokens_invalid_protobuf() {
1367        let result = Protocol::new(Buffer::from(vec![0xFF, 0xFF]), None);
1368        assert!(result.is_err());
1369    }
1370}