Skip to main content

webui_test_utils/
lib.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Test utilities for WebUI framework.
5//!
6//! This crate provides testing helpers and should only be used in test code.
7
8use std::fs;
9use std::{collections::HashMap, path::PathBuf};
10use tempfile::TempDir;
11pub use webui_protocol;
12
13/// A macro that wraps `serde_json::json!` but allows bypassing clippy::disallowed_methods.
14///
15/// This macro should only be used in test code.
16#[macro_export]
17macro_rules! test_json {
18    ($($json:tt)+) => {{
19        #[allow(clippy::disallowed_methods)]
20        let value = serde_json::json!($($json)+);
21        value
22    }};
23}
24
25/// Assert that a fragment list matches the expected pattern.
26///
27/// Each matcher is one of:
28/// - `raw("text")` — Raw fragment with exact value
29/// - `signal("name")` — Escaped signal
30/// - `signal("name", raw)` — Raw (unescaped) signal
31/// - `attr("name", value: "v")` — Simple dynamic attribute
32/// - `attr("name", template: "id")` — Template attribute
33/// - `attr("name", complex: "v")` — Complex (:-prefixed) attribute
34/// - `bool_attr("name", "signal")` — Boolean attribute with identifier condition
35/// - `attr_raw("name", "v")` — Static attribute with rawValue
36/// - `attr_raw("name", "v", attr_start)` — Static rawValue with attrStart
37/// - `attr_skip("name", "v")` — Skipped attribute
38/// - `component("id")` — Component fragment
39/// - `for_loop("item", "collection", "template")` — For loop
40/// - `if_cond("template")` — If condition
41#[macro_export]
42macro_rules! assert_fragments {
43    ($fragments:expr, [ $($matcher:expr),* $(,)? ]) => {{
44        let matchers: Vec<$crate::FragmentMatcher> = vec![$($matcher),*];
45        $crate::assert_fragment_list(&$fragments, &matchers);
46    }};
47}
48
49/// Assert that a named stream in the records matches the expected pattern.
50#[macro_export]
51macro_rules! assert_stream {
52    ($records:expr, $stream_id:expr, [ $($matcher:expr),* $(,)? ]) => {{
53        let stream = $records.get($stream_id)
54            .unwrap_or_else(|| panic!("Missing stream: {}", $stream_id));
55        let matchers: Vec<$crate::FragmentMatcher> = vec![$($matcher),*];
56        $crate::assert_fragment_list(&stream.fragments, &matchers);
57    }};
58}
59
60// ── Fragment matcher types ──────────────────────────────────────────
61
62/// Describes an expected fragment for assertion matching.
63#[derive(Debug)]
64pub enum FragmentMatcher {
65    Raw(String),
66    Signal {
67        value: String,
68        raw: bool,
69    },
70    Attribute(AttrMatcher),
71    Component(String),
72    ForLoop {
73        item: String,
74        collection: String,
75        template: String,
76    },
77    IfCond {
78        template: String,
79    },
80}
81
82/// Describes expected attribute properties.
83#[derive(Debug, Default)]
84pub struct AttrMatcher {
85    pub name: String,
86    pub value: Option<String>,
87    pub template: Option<String>,
88    pub complex: bool,
89    pub attr_start: bool,
90    pub attr_skip: bool,
91    pub raw_value: bool,
92    pub bool_signal: Option<String>,
93    /// Match a boolean attribute with a predicate condition (left, op, right).
94    pub bool_predicate: Option<(String, i32, String)>,
95    /// Match a boolean attribute with a negation condition (inner identifier).
96    pub bool_not: Option<String>,
97}
98
99// ── Matcher constructors ────────────────────────────────────────────
100
101/// Match a raw fragment.
102pub fn raw(value: &str) -> FragmentMatcher {
103    FragmentMatcher::Raw(value.to_string())
104}
105
106/// Match an escaped signal fragment.
107pub fn signal(value: &str) -> FragmentMatcher {
108    FragmentMatcher::Signal {
109        value: value.to_string(),
110        raw: false,
111    }
112}
113
114/// Match a raw (unescaped) signal fragment.
115pub fn signal_raw(value: &str) -> FragmentMatcher {
116    FragmentMatcher::Signal {
117        value: value.to_string(),
118        raw: true,
119    }
120}
121
122/// Match a simple dynamic attribute.
123pub fn attr(name: &str, value: &str) -> FragmentMatcher {
124    FragmentMatcher::Attribute(AttrMatcher {
125        name: name.to_string(),
126        value: Some(value.to_string()),
127        ..Default::default()
128    })
129}
130
131/// Match a template (mixed) attribute.
132pub fn attr_template(name: &str, template: &str) -> FragmentMatcher {
133    FragmentMatcher::Attribute(AttrMatcher {
134        name: name.to_string(),
135        template: Some(template.to_string()),
136        ..Default::default()
137    })
138}
139
140/// Match a complex (:-prefixed) attribute.
141pub fn attr_complex(name: &str, value: &str) -> FragmentMatcher {
142    FragmentMatcher::Attribute(AttrMatcher {
143        name: name.to_string(),
144        value: Some(value.to_string()),
145        complex: true,
146        ..Default::default()
147    })
148}
149
150/// Match a complex attribute with attrStart.
151pub fn attr_complex_start(name: &str, value: &str) -> FragmentMatcher {
152    FragmentMatcher::Attribute(AttrMatcher {
153        name: name.to_string(),
154        value: Some(value.to_string()),
155        complex: true,
156        attr_start: true,
157        ..Default::default()
158    })
159}
160
161/// Match a boolean attribute with an identifier condition.
162pub fn bool_attr(name: &str, signal: &str) -> FragmentMatcher {
163    FragmentMatcher::Attribute(AttrMatcher {
164        name: name.to_string(),
165        bool_signal: Some(signal.to_string()),
166        ..Default::default()
167    })
168}
169
170/// Match a boolean attribute with attrStart.
171pub fn bool_attr_start(name: &str, signal: &str) -> FragmentMatcher {
172    FragmentMatcher::Attribute(AttrMatcher {
173        name: name.to_string(),
174        bool_signal: Some(signal.to_string()),
175        attr_start: true,
176        ..Default::default()
177    })
178}
179
180/// Match a boolean attribute with a predicate condition (e.g., `?disabled={{count > 5}}`).
181pub fn bool_attr_predicate(name: &str, left: &str, op: i32, right: &str) -> FragmentMatcher {
182    FragmentMatcher::Attribute(AttrMatcher {
183        name: name.to_string(),
184        bool_predicate: Some((left.to_string(), op, right.to_string())),
185        ..Default::default()
186    })
187}
188
189/// Match a boolean attribute with a negated condition (e.g., `?disabled={{!isReady}}`).
190pub fn bool_attr_not(name: &str, inner: &str) -> FragmentMatcher {
191    FragmentMatcher::Attribute(AttrMatcher {
192        name: name.to_string(),
193        bool_not: Some(inner.to_string()),
194        ..Default::default()
195    })
196}
197
198/// Match a static rawValue attribute.
199pub fn attr_raw(name: &str, value: &str) -> FragmentMatcher {
200    FragmentMatcher::Attribute(AttrMatcher {
201        name: name.to_string(),
202        value: Some(value.to_string()),
203        raw_value: true,
204        ..Default::default()
205    })
206}
207
208/// Match a static rawValue attribute with attrStart.
209pub fn attr_raw_start(name: &str, value: &str) -> FragmentMatcher {
210    FragmentMatcher::Attribute(AttrMatcher {
211        name: name.to_string(),
212        value: Some(value.to_string()),
213        raw_value: true,
214        attr_start: true,
215        ..Default::default()
216    })
217}
218
219/// Match a skipped attribute.
220pub fn attr_skip(name: &str, value: &str) -> FragmentMatcher {
221    FragmentMatcher::Attribute(AttrMatcher {
222        name: name.to_string(),
223        value: Some(value.to_string()),
224        attr_skip: true,
225        ..Default::default()
226    })
227}
228
229/// Match a simple dynamic attribute with attrStart.
230pub fn attr_start(name: &str, value: &str) -> FragmentMatcher {
231    FragmentMatcher::Attribute(AttrMatcher {
232        name: name.to_string(),
233        value: Some(value.to_string()),
234        attr_start: true,
235        ..Default::default()
236    })
237}
238
239/// Match a component fragment.
240pub fn component(id: &str) -> FragmentMatcher {
241    FragmentMatcher::Component(id.to_string())
242}
243
244/// Match a for-loop fragment.
245pub fn for_loop(item: &str, collection: &str, template: &str) -> FragmentMatcher {
246    FragmentMatcher::ForLoop {
247        item: item.to_string(),
248        collection: collection.to_string(),
249        template: template.to_string(),
250    }
251}
252
253/// Match an if-condition fragment.
254pub fn if_cond(template: &str) -> FragmentMatcher {
255    FragmentMatcher::IfCond {
256        template: template.to_string(),
257    }
258}
259
260// ── Assertion implementation ────────────────────────────────────────
261
262/// Assert that a fragment list matches the expected matchers.
263pub fn assert_fragment_list(
264    fragments: &[webui_protocol::WebUIFragment],
265    matchers: &[FragmentMatcher],
266) {
267    use webui_protocol::web_ui_fragment::Fragment;
268
269    assert_eq!(
270        fragments.len(),
271        matchers.len(),
272        "Fragment count mismatch: got {} fragments, expected {}\nFragments: {:#?}",
273        fragments.len(),
274        matchers.len(),
275        fragments.iter().map(format_fragment).collect::<Vec<_>>()
276    );
277
278    for (i, (frag, matcher)) in fragments.iter().zip(matchers.iter()).enumerate() {
279        match (frag.fragment.as_ref(), matcher) {
280            (Some(Fragment::Raw(r)), FragmentMatcher::Raw(expected)) => {
281                assert_eq!(r.value, *expected, "Fragment[{}]: raw value mismatch", i);
282            }
283            (Some(Fragment::Signal(s)), FragmentMatcher::Signal { value, raw }) => {
284                assert_eq!(s.value, *value, "Fragment[{}]: signal value mismatch", i);
285                assert_eq!(s.raw, *raw, "Fragment[{}]: signal raw flag mismatch", i);
286            }
287            (Some(Fragment::Attribute(a)), FragmentMatcher::Attribute(m)) => {
288                assert_eq!(a.name, m.name, "Fragment[{}]: attr name mismatch", i);
289                if let Some(ref v) = m.value {
290                    assert_eq!(a.value, *v, "Fragment[{}]: attr value mismatch", i);
291                }
292                if let Some(ref t) = m.template {
293                    assert_eq!(a.template, *t, "Fragment[{}]: attr template mismatch", i);
294                }
295                assert_eq!(
296                    a.complex, m.complex,
297                    "Fragment[{}]: attr complex mismatch",
298                    i
299                );
300                assert_eq!(
301                    a.attr_start, m.attr_start,
302                    "Fragment[{}]: attr_start mismatch",
303                    i
304                );
305                assert_eq!(
306                    a.attr_skip, m.attr_skip,
307                    "Fragment[{}]: attr_skip mismatch",
308                    i
309                );
310                assert_eq!(
311                    a.raw_value, m.raw_value,
312                    "Fragment[{}]: raw_value mismatch",
313                    i
314                );
315                if let Some(ref sig) = m.bool_signal {
316                    let cond = a
317                        .condition_tree
318                        .as_ref()
319                        .unwrap_or_else(|| panic!("Fragment[{}]: expected condition_tree", i));
320                    match cond.expr.as_ref() {
321                        Some(webui_protocol::condition_expr::Expr::Identifier(id)) => {
322                            assert_eq!(
323                                id.value, *sig,
324                                "Fragment[{}]: bool attr signal mismatch",
325                                i
326                            );
327                        }
328                        other => panic!(
329                            "Fragment[{}]: expected identifier condition, got {:?}",
330                            i, other
331                        ),
332                    }
333                }
334                if let Some((ref left, op, ref right)) = m.bool_predicate {
335                    let cond = a
336                        .condition_tree
337                        .as_ref()
338                        .unwrap_or_else(|| panic!("Fragment[{}]: expected condition_tree", i));
339                    match cond.expr.as_ref() {
340                        Some(webui_protocol::condition_expr::Expr::Predicate(pred)) => {
341                            assert_eq!(
342                                pred.left, *left,
343                                "Fragment[{}]: predicate left mismatch",
344                                i
345                            );
346                            assert_eq!(
347                                pred.operator, op,
348                                "Fragment[{}]: predicate operator mismatch",
349                                i
350                            );
351                            assert_eq!(
352                                pred.right, *right,
353                                "Fragment[{}]: predicate right mismatch",
354                                i
355                            );
356                        }
357                        other => panic!(
358                            "Fragment[{}]: expected predicate condition, got {:?}",
359                            i, other
360                        ),
361                    }
362                }
363                if let Some(ref inner) = m.bool_not {
364                    let cond = a
365                        .condition_tree
366                        .as_ref()
367                        .unwrap_or_else(|| panic!("Fragment[{}]: expected condition_tree", i));
368                    match cond.expr.as_ref() {
369                        Some(webui_protocol::condition_expr::Expr::Not(not_cond)) => {
370                            let inner_cond = not_cond.condition.as_ref().unwrap_or_else(|| {
371                                panic!("Fragment[{}]: not condition missing inner", i)
372                            });
373                            match inner_cond.expr.as_ref() {
374                                Some(webui_protocol::condition_expr::Expr::Identifier(id)) => {
375                                    assert_eq!(
376                                        id.value, *inner,
377                                        "Fragment[{}]: not inner identifier mismatch",
378                                        i
379                                    );
380                                }
381                                other => panic!(
382                                    "Fragment[{}]: expected identifier inside not, got {:?}",
383                                    i, other
384                                ),
385                            }
386                        }
387                        other => panic!("Fragment[{}]: expected not condition, got {:?}", i, other),
388                    }
389                }
390            }
391            (Some(Fragment::Component(c)), FragmentMatcher::Component(id)) => {
392                assert_eq!(c.fragment_id, *id, "Fragment[{}]: component id mismatch", i);
393            }
394            (
395                Some(Fragment::ForLoop(fl)),
396                FragmentMatcher::ForLoop {
397                    item,
398                    collection,
399                    template,
400                },
401            ) => {
402                assert_eq!(fl.item, *item, "Fragment[{}]: for item mismatch", i);
403                assert_eq!(
404                    fl.collection, *collection,
405                    "Fragment[{}]: for collection mismatch",
406                    i
407                );
408                assert_eq!(
409                    fl.fragment_id, *template,
410                    "Fragment[{}]: for template mismatch",
411                    i
412                );
413            }
414            (Some(Fragment::IfCond(ic)), FragmentMatcher::IfCond { template }) => {
415                assert_eq!(
416                    ic.fragment_id, *template,
417                    "Fragment[{}]: if template mismatch",
418                    i
419                );
420            }
421            (_actual, expected) => {
422                panic!(
423                    "Fragment[{}]: type mismatch\n  expected: {:?}\n  actual: {}",
424                    i,
425                    expected,
426                    format_fragment(frag)
427                );
428            }
429        }
430    }
431}
432
433fn format_fragment(frag: &webui_protocol::WebUIFragment) -> String {
434    use webui_protocol::web_ui_fragment::Fragment;
435    match frag.fragment.as_ref() {
436        Some(Fragment::Raw(r)) => format!("raw({:?})", r.value),
437        Some(Fragment::Signal(s)) => format!("signal({:?}, raw={})", s.value, s.raw),
438        Some(Fragment::Attribute(a)) => format!(
439            "attr({:?}, value={:?}, template={:?}, complex={}, start={}, skip={}, raw_value={})",
440            a.name, a.value, a.template, a.complex, a.attr_start, a.attr_skip, a.raw_value
441        ),
442        Some(Fragment::Component(c)) => format!("component({:?})", c.fragment_id),
443        Some(Fragment::ForLoop(f)) => format!(
444            "for({:?} in {:?}, template={:?})",
445            f.item, f.collection, f.fragment_id
446        ),
447        Some(Fragment::IfCond(i)) => format!("if(template={:?})", i.fragment_id),
448        Some(Fragment::Plugin(p)) => format!("plugin(data={:?})", p.data),
449        Some(Fragment::Route(r)) => {
450            format!("route(path={:?}, fragment={:?})", r.path, r.fragment_id)
451        }
452        Some(Fragment::Outlet(_)) => "outlet".to_string(),
453        None => "None".to_string(),
454    }
455}
456
457/// A test file system that manages temporary files and directories
458pub struct TestFileSystem {
459    files: HashMap<String, PathBuf>,
460
461    // Keep directories alive for the lifetime of this struct
462    _temp_dirs: Vec<TempDir>,
463}
464
465impl Default for TestFileSystem {
466    fn default() -> Self {
467        Self::new()
468    }
469}
470
471impl TestFileSystem {
472    /// Create a new empty test file system
473    pub fn new() -> Self {
474        Self {
475            files: HashMap::new(),
476            _temp_dirs: Vec::new(),
477        }
478    }
479
480    /// Add a file to the test file system at the specified path
481    pub fn add_file(&mut self, path: &str, content: &str) -> PathBuf {
482        // Create a new temporary directory for this file
483        let temp_dir = tempfile::tempdir().expect("Failed to create temporary directory");
484
485        // Parse the path to separate directories and filename
486        let path_parts: Vec<&str> = path.split('/').collect();
487        let filename = path_parts.last().expect("Path must contain a filename");
488
489        // Create the file within the temporary directory
490        let file_path = temp_dir.path().join(filename);
491        fs::write(&file_path, content).expect("Failed to write content to file");
492
493        // Store the path and keep the directory alive
494        self.files.insert(path.to_string(), file_path.clone());
495        self._temp_dirs.push(temp_dir);
496
497        // Return a reference to the stored path
498        self.files
499            .get(path)
500            .expect("File path not found in the test file system");
501
502        // Return the path by value (clone it)
503        file_path
504    }
505}