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 skipped attribute with a static raw value.
230pub fn attr_skip_raw(name: &str, value: &str) -> FragmentMatcher {
231    FragmentMatcher::Attribute(AttrMatcher {
232        name: name.to_string(),
233        raw_value: true,
234        value: Some(value.to_string()),
235        attr_skip: true,
236        ..Default::default()
237    })
238}
239
240/// Match a skipped attribute with an embedded binding template.
241pub fn attr_skip_template(name: &str, template: &str) -> FragmentMatcher {
242    FragmentMatcher::Attribute(AttrMatcher {
243        name: name.to_string(),
244        template: Some(template.to_string()),
245        attr_skip: true,
246        ..Default::default()
247    })
248}
249
250/// Match a simple dynamic attribute with attrStart.
251pub fn attr_start(name: &str, value: &str) -> FragmentMatcher {
252    FragmentMatcher::Attribute(AttrMatcher {
253        name: name.to_string(),
254        value: Some(value.to_string()),
255        attr_start: true,
256        ..Default::default()
257    })
258}
259
260/// Match a component fragment.
261pub fn component(id: &str) -> FragmentMatcher {
262    FragmentMatcher::Component(id.to_string())
263}
264
265/// Match a for-loop fragment.
266pub fn for_loop(item: &str, collection: &str, template: &str) -> FragmentMatcher {
267    FragmentMatcher::ForLoop {
268        item: item.to_string(),
269        collection: collection.to_string(),
270        template: template.to_string(),
271    }
272}
273
274/// Match an if-condition fragment.
275pub fn if_cond(template: &str) -> FragmentMatcher {
276    FragmentMatcher::IfCond {
277        template: template.to_string(),
278    }
279}
280
281// ── Assertion implementation ────────────────────────────────────────
282
283/// Assert that a fragment list matches the expected matchers.
284pub fn assert_fragment_list(
285    fragments: &[webui_protocol::WebUIFragment],
286    matchers: &[FragmentMatcher],
287) {
288    use webui_protocol::web_ui_fragment::Fragment;
289
290    assert_eq!(
291        fragments.len(),
292        matchers.len(),
293        "Fragment count mismatch: got {} fragments, expected {}\nFragments: {:#?}",
294        fragments.len(),
295        matchers.len(),
296        fragments.iter().map(format_fragment).collect::<Vec<_>>()
297    );
298
299    for (i, (frag, matcher)) in fragments.iter().zip(matchers.iter()).enumerate() {
300        match (frag.fragment.as_ref(), matcher) {
301            (Some(Fragment::Raw(r)), FragmentMatcher::Raw(expected)) => {
302                assert_eq!(r.value, *expected, "Fragment[{}]: raw value mismatch", i);
303            }
304            (Some(Fragment::Signal(s)), FragmentMatcher::Signal { value, raw }) => {
305                assert_eq!(s.value, *value, "Fragment[{}]: signal value mismatch", i);
306                assert_eq!(s.raw, *raw, "Fragment[{}]: signal raw flag mismatch", i);
307            }
308            (Some(Fragment::Attribute(a)), FragmentMatcher::Attribute(m)) => {
309                assert_eq!(a.name, m.name, "Fragment[{}]: attr name mismatch", i);
310                if let Some(ref v) = m.value {
311                    assert_eq!(a.value, *v, "Fragment[{}]: attr value mismatch", i);
312                }
313                if let Some(ref t) = m.template {
314                    assert_eq!(a.template, *t, "Fragment[{}]: attr template mismatch", i);
315                }
316                assert_eq!(
317                    a.complex, m.complex,
318                    "Fragment[{}]: attr complex mismatch",
319                    i
320                );
321                assert_eq!(
322                    a.attr_start, m.attr_start,
323                    "Fragment[{}]: attr_start mismatch",
324                    i
325                );
326                assert_eq!(
327                    a.attr_skip, m.attr_skip,
328                    "Fragment[{}]: attr_skip mismatch",
329                    i
330                );
331                assert_eq!(
332                    a.raw_value, m.raw_value,
333                    "Fragment[{}]: raw_value mismatch",
334                    i
335                );
336                if let Some(ref sig) = m.bool_signal {
337                    let cond = a
338                        .condition_tree
339                        .as_ref()
340                        .unwrap_or_else(|| panic!("Fragment[{}]: expected condition_tree", i));
341                    match cond.expr.as_ref() {
342                        Some(webui_protocol::condition_expr::Expr::Identifier(id)) => {
343                            assert_eq!(
344                                id.value, *sig,
345                                "Fragment[{}]: bool attr signal mismatch",
346                                i
347                            );
348                        }
349                        other => panic!(
350                            "Fragment[{}]: expected identifier condition, got {:?}",
351                            i, other
352                        ),
353                    }
354                }
355                if let Some((ref left, op, ref right)) = m.bool_predicate {
356                    let cond = a
357                        .condition_tree
358                        .as_ref()
359                        .unwrap_or_else(|| panic!("Fragment[{}]: expected condition_tree", i));
360                    match cond.expr.as_ref() {
361                        Some(webui_protocol::condition_expr::Expr::Predicate(pred)) => {
362                            assert_eq!(
363                                pred.left, *left,
364                                "Fragment[{}]: predicate left mismatch",
365                                i
366                            );
367                            assert_eq!(
368                                pred.operator, op,
369                                "Fragment[{}]: predicate operator mismatch",
370                                i
371                            );
372                            assert_eq!(
373                                pred.right, *right,
374                                "Fragment[{}]: predicate right mismatch",
375                                i
376                            );
377                        }
378                        other => panic!(
379                            "Fragment[{}]: expected predicate condition, got {:?}",
380                            i, other
381                        ),
382                    }
383                }
384                if let Some(ref inner) = m.bool_not {
385                    let cond = a
386                        .condition_tree
387                        .as_ref()
388                        .unwrap_or_else(|| panic!("Fragment[{}]: expected condition_tree", i));
389                    match cond.expr.as_ref() {
390                        Some(webui_protocol::condition_expr::Expr::Not(not_cond)) => {
391                            let inner_cond = not_cond.condition.as_ref().unwrap_or_else(|| {
392                                panic!("Fragment[{}]: not condition missing inner", i)
393                            });
394                            match inner_cond.expr.as_ref() {
395                                Some(webui_protocol::condition_expr::Expr::Identifier(id)) => {
396                                    assert_eq!(
397                                        id.value, *inner,
398                                        "Fragment[{}]: not inner identifier mismatch",
399                                        i
400                                    );
401                                }
402                                other => panic!(
403                                    "Fragment[{}]: expected identifier inside not, got {:?}",
404                                    i, other
405                                ),
406                            }
407                        }
408                        other => panic!("Fragment[{}]: expected not condition, got {:?}", i, other),
409                    }
410                }
411            }
412            (Some(Fragment::Component(c)), FragmentMatcher::Component(id)) => {
413                assert_eq!(c.fragment_id, *id, "Fragment[{}]: component id mismatch", i);
414            }
415            (
416                Some(Fragment::ForLoop(fl)),
417                FragmentMatcher::ForLoop {
418                    item,
419                    collection,
420                    template,
421                },
422            ) => {
423                assert_eq!(fl.item, *item, "Fragment[{}]: for item mismatch", i);
424                assert_eq!(
425                    fl.collection, *collection,
426                    "Fragment[{}]: for collection mismatch",
427                    i
428                );
429                assert_eq!(
430                    fl.fragment_id, *template,
431                    "Fragment[{}]: for template mismatch",
432                    i
433                );
434            }
435            (Some(Fragment::IfCond(ic)), FragmentMatcher::IfCond { template }) => {
436                assert_eq!(
437                    ic.fragment_id, *template,
438                    "Fragment[{}]: if template mismatch",
439                    i
440                );
441            }
442            (_actual, expected) => {
443                panic!(
444                    "Fragment[{}]: type mismatch\n  expected: {:?}\n  actual: {}",
445                    i,
446                    expected,
447                    format_fragment(frag)
448                );
449            }
450        }
451    }
452}
453
454fn format_fragment(frag: &webui_protocol::WebUIFragment) -> String {
455    use webui_protocol::web_ui_fragment::Fragment;
456    match frag.fragment.as_ref() {
457        Some(Fragment::Raw(r)) => format!("raw({:?})", r.value),
458        Some(Fragment::Signal(s)) => format!("signal({:?}, raw={})", s.value, s.raw),
459        Some(Fragment::Attribute(a)) => format!(
460            "attr({:?}, value={:?}, template={:?}, complex={}, start={}, skip={}, raw_value={})",
461            a.name, a.value, a.template, a.complex, a.attr_start, a.attr_skip, a.raw_value
462        ),
463        Some(Fragment::Component(c)) => format!("component({:?})", c.fragment_id),
464        Some(Fragment::ForLoop(f)) => format!(
465            "for({:?} in {:?}, template={:?})",
466            f.item, f.collection, f.fragment_id
467        ),
468        Some(Fragment::IfCond(i)) => format!("if(template={:?})", i.fragment_id),
469        Some(Fragment::Plugin(p)) => format!("plugin(data={:?})", p.data),
470        Some(Fragment::Route(r)) => {
471            format!("route(path={:?}, fragment={:?})", r.path, r.fragment_id)
472        }
473        Some(Fragment::Outlet(_)) => "outlet".to_string(),
474        None => "None".to_string(),
475    }
476}
477
478/// A test file system that manages temporary files and directories
479pub struct TestFileSystem {
480    files: HashMap<String, PathBuf>,
481
482    // Keep directories alive for the lifetime of this struct
483    _temp_dirs: Vec<TempDir>,
484}
485
486impl Default for TestFileSystem {
487    fn default() -> Self {
488        Self::new()
489    }
490}
491
492impl TestFileSystem {
493    /// Create a new empty test file system
494    pub fn new() -> Self {
495        Self {
496            files: HashMap::new(),
497            _temp_dirs: Vec::new(),
498        }
499    }
500
501    /// Add a file to the test file system at the specified path
502    pub fn add_file(&mut self, path: &str, content: &str) -> PathBuf {
503        // Create a new temporary directory for this file
504        let temp_dir = tempfile::tempdir().expect("Failed to create temporary directory");
505
506        // Parse the path to separate directories and filename
507        let path_parts: Vec<&str> = path.split('/').collect();
508        let filename = path_parts.last().expect("Path must contain a filename");
509
510        // Create the file within the temporary directory
511        let file_path = temp_dir.path().join(filename);
512        fs::write(&file_path, content).expect("Failed to write content to file");
513
514        // Store the path and keep the directory alive
515        self.files.insert(path.to_string(), file_path.clone());
516        self._temp_dirs.push(temp_dir);
517
518        // Return a reference to the stored path
519        self.files
520            .get(path)
521            .expect("File path not found in the test file system");
522
523        // Return the path by value (clone it)
524        file_path
525    }
526}