Skip to main content

webui_wasm/
lib.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! WebAssembly bindings for the WebUI framework.
5//!
6//! This crate can be built as three WASM variants:
7//! - **handler** - render pre-built protocol bytes with state.
8//! - **parser** - build protocol bytes from virtual files.
9//! - **all** - parser plus handler exports for playground-style live preview.
10
11mod error;
12
13#[cfg(all(not(feature = "handler"), not(feature = "parser")))]
14compile_error!("microsoft-webui-wasm requires at least one of the `handler` or `parser` features");
15
16#[cfg(feature = "handler")]
17mod handler;
18#[cfg(feature = "parser")]
19mod parser;
20
21#[cfg(feature = "handler")]
22pub use handler::Protocol;
23#[cfg(feature = "parser")]
24pub use parser::build_protocol;
25
26#[cfg(all(test, feature = "handler", feature = "parser"))]
27mod tests {
28    use super::*;
29    use crate::error::WasmError;
30    use std::collections::HashMap;
31    use webui_protocol::WebUIProtocol;
32
33    fn render_files_for_test(
34        files: &HashMap<String, String>,
35        state_json: &str,
36        entry: &str,
37        request_path: &str,
38    ) -> Result<String, WasmError> {
39        let protocol = parser::parse_to_protocol(files, entry, &[])?;
40        handler::render_protocol_to_string(&protocol, state_json, entry, request_path, None)
41    }
42
43    #[test]
44    fn test_simple_render() {
45        let mut files = HashMap::new();
46        files.insert(
47            "index.html".to_string(),
48            "<h1>Hello, {{name}}!</h1>".to_string(),
49        );
50
51        let result = render_files_for_test(&files, r#"{"name": "WebUI"}"#, "index.html", "/");
52        assert_eq!(result.unwrap(), "<h1>Hello, WebUI!</h1>");
53    }
54
55    #[test]
56    fn test_missing_entry_file() {
57        let files = HashMap::new();
58        let result = render_files_for_test(&files, "{}", "index.html", "/");
59        assert!(result.is_err());
60        let err = result.unwrap_err().to_string();
61        assert!(err.contains("not found"), "Unexpected error: {}", err);
62    }
63
64    #[test]
65    fn test_build_protocol_surfaces_invalid_w_ref() {
66        let mut files = HashMap::new();
67        files.insert(
68            "index.html".to_string(),
69            "<my-card>Hi</my-card>".to_string(),
70        );
71        files.insert(
72            "my-card.html".to_string(),
73            r#"<div><input w-ref="myInput" /></div>"#.to_string(),
74        );
75
76        let result = parser::build_protocol_inner(&files, "index.html", &[]);
77        assert!(result.is_err());
78        let err = result.unwrap_err().to_string();
79        assert!(
80            err.contains("invalid w-ref binding"),
81            "Unexpected error: {err}"
82        );
83        assert!(
84            err.contains("component <my-card> · element <input>"),
85            "Unexpected error: {err}"
86        );
87    }
88
89    #[test]
90    fn test_render_files_surfaces_invalid_event_handler() {
91        let mut files = HashMap::new();
92        files.insert("index.html".to_string(), "<my-btn>x</my-btn>".to_string());
93        files.insert(
94            "my-btn.html".to_string(),
95            r#"<div><button @click="e.preventDefault()">x</button></div>"#.to_string(),
96        );
97
98        let result = render_files_for_test(&files, "{}", "index.html", "/");
99        assert!(result.is_err());
100        let err = result.unwrap_err().to_string();
101        assert!(
102            err.contains("invalid @click handler"),
103            "Unexpected error: {err}"
104        );
105        assert!(
106            err.contains("component <my-btn> · element <button>"),
107            "Unexpected error: {err}"
108        );
109    }
110
111    #[test]
112    fn test_with_component() {
113        let mut files = HashMap::new();
114        files.insert(
115            "index.html".to_string(),
116            "<my-card>World</my-card>".to_string(),
117        );
118        files.insert(
119            "my-card.html".to_string(),
120            "<template shadowrootmode=\"open\"><div class=\"card\"><slot></slot></div></template>"
121                .to_string(),
122        );
123
124        let result = render_files_for_test(&files, "{}", "index.html", "/");
125        assert!(result.is_ok(), "Render failed: {:?}", result);
126        let html = result.as_deref().unwrap_or("");
127        assert!(html.contains("card"), "Expected card class in: {}", html);
128    }
129
130    #[test]
131    fn test_with_for_loop() {
132        let mut files = HashMap::new();
133        files.insert(
134            "index.html".to_string(),
135            "<for each=\"item in items\">{{item.name}}, </for>".to_string(),
136        );
137
138        let state = r#"{"items": [{"name": "A"}, {"name": "B"}]}"#;
139        let result = render_files_for_test(&files, state, "index.html", "/");
140        assert!(result.is_ok(), "Render failed: {:?}", result);
141        let html = result.as_deref().unwrap_or("");
142        assert!(html.contains("A"), "Expected 'A' in: {}", html);
143        assert!(html.contains("B"), "Expected 'B' in: {}", html);
144    }
145
146    #[test]
147    fn test_with_if_condition() {
148        let mut files = HashMap::new();
149        files.insert(
150            "index.html".to_string(),
151            "<if condition=\"show\">Visible</if>".to_string(),
152        );
153
154        let result_true = render_files_for_test(&files, r#"{"show": true}"#, "index.html", "/");
155        assert_eq!(result_true.unwrap(), "Visible");
156
157        let result_false = render_files_for_test(&files, r#"{"show": false}"#, "index.html", "/");
158        assert_eq!(result_false.unwrap(), "");
159    }
160
161    #[test]
162    fn test_invalid_state_json() {
163        let mut files = HashMap::new();
164        files.insert("index.html".to_string(), "<p>Hi</p>".to_string());
165
166        let result = render_files_for_test(&files, "not json", "index.html", "/");
167        assert!(result.is_err());
168        let err = result.unwrap_err().to_string();
169        assert!(
170            err.contains("State JSON error"),
171            "Unexpected error: {}",
172            err
173        );
174    }
175
176    #[test]
177    fn test_component_with_css() {
178        let mut files = HashMap::new();
179        files.insert(
180            "index.html".to_string(),
181            "<my-card>Content</my-card>".to_string(),
182        );
183        files.insert(
184            "my-card.html".to_string(),
185            "<template shadowrootmode=\"open\"><p><slot></slot></p></template>".to_string(),
186        );
187        files.insert("my-card.css".to_string(), "p { color: red; }".to_string());
188
189        let result = render_files_for_test(&files, "{}", "index.html", "/");
190        assert!(result.is_ok(), "Render failed: {:?}", result);
191        let html = result.as_deref().unwrap_or("");
192        assert!(
193            html.contains(
194                r#"<style data-webui-resource="my-card" data-webui-strategy="style">p { color: red; }</style>"#,
195            ),
196            "Expected inline <style> tag in: {}",
197            html
198        );
199        assert!(
200            !html.contains("<link"),
201            "Should not have external <link> tag in: {}",
202            html
203        );
204    }
205
206    #[test]
207    fn test_raw_signal() {
208        let mut files = HashMap::new();
209        files.insert(
210            "index.html".to_string(),
211            "<div>{{{raw_html}}}</div>".to_string(),
212        );
213
214        let result =
215            render_files_for_test(&files, r#"{"raw_html": "<b>bold</b>"}"#, "index.html", "/");
216        assert!(result.is_ok(), "Render failed: {:?}", result);
217        let html = result.as_deref().unwrap_or("");
218        assert!(
219            html.contains("<b>bold</b>"),
220            "Expected raw HTML in: {}",
221            html
222        );
223    }
224
225    #[test]
226    fn test_static_html_passthrough() {
227        let mut files = HashMap::new();
228        files.insert(
229            "index.html".to_string(),
230            "<h1>Static</h1><p>Content</p>".to_string(),
231        );
232
233        let result = render_files_for_test(&files, "{}", "index.html", "/");
234        assert_eq!(result.unwrap(), "<h1>Static</h1><p>Content</p>");
235    }
236
237    #[test]
238    fn test_protocol_tokens_empty() {
239        let protocol = WebUIProtocol::new(HashMap::new());
240        let bytes = protocol.to_protobuf().unwrap();
241        let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
242        assert!(decoded.tokens.is_empty());
243    }
244
245    #[test]
246    fn test_protocol_tokens_roundtrip() {
247        let tokens = vec![
248            "colorBrandBackground".to_string(),
249            "fontSizeBase300".to_string(),
250        ];
251        let protocol = WebUIProtocol::with_tokens(HashMap::new(), tokens.clone());
252        let bytes = protocol.to_protobuf().unwrap();
253        let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
254        assert_eq!(decoded.tokens, tokens);
255    }
256
257    #[test]
258    fn test_protocol_tokens_preserves_order() {
259        let tokens = vec!["zeta".to_string(), "alpha".to_string(), "zeta".to_string()];
260        let protocol = WebUIProtocol::with_tokens(HashMap::new(), tokens.clone());
261        let bytes = protocol.to_protobuf().unwrap();
262        let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
263        assert_eq!(decoded.tokens, tokens);
264    }
265}