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/// - `boundary("name", "template")` — Streaming boundary declaration
42#[macro_export]
43macro_rules! assert_fragments {
44    ($fragments:expr, [ $($matcher:expr),* $(,)? ]) => {{
45        let matchers: Vec<$crate::FragmentMatcher> = vec![$($matcher),*];
46        $crate::assert_fragment_list(&$fragments, &matchers);
47    }};
48}
49
50/// Assert that a named stream in the records matches the expected pattern.
51#[macro_export]
52macro_rules! assert_stream {
53    ($records:expr, $stream_id:expr, [ $($matcher:expr),* $(,)? ]) => {{
54        let stream = $records.get($stream_id)
55            .unwrap_or_else(|| panic!("Missing stream: {}", $stream_id));
56        let matchers: Vec<$crate::FragmentMatcher> = vec![$($matcher),*];
57        $crate::assert_fragment_list(&stream.fragments, &matchers);
58    }};
59}
60
61// ── Fragment matcher types ──────────────────────────────────────────
62
63/// Describes an expected fragment for assertion matching.
64#[derive(Debug)]
65pub enum FragmentMatcher {
66    Raw(String),
67    Signal {
68        value: String,
69        raw: bool,
70        raw_text_context: bool,
71    },
72    Attribute(AttrMatcher),
73    Component(String),
74    ForLoop {
75        item: String,
76        collection: String,
77        template: String,
78    },
79    IfCond {
80        template: String,
81    },
82    Boundary {
83        name: String,
84        declaration: u32,
85    },
86    BoundaryEnd {
87        declaration: u32,
88    },
89}
90
91/// Describes expected attribute properties.
92#[derive(Debug, Default)]
93pub struct AttrMatcher {
94    pub name: String,
95    pub value: Option<String>,
96    pub template: Option<String>,
97    pub complex: bool,
98    pub attr_start: bool,
99    pub attr_skip: bool,
100    pub raw_value: bool,
101    pub bool_signal: Option<String>,
102    /// Match a boolean attribute with a predicate condition (left, op, right).
103    pub bool_predicate: Option<(String, i32, String)>,
104    /// Match a boolean attribute with a negation condition (inner identifier).
105    pub bool_not: Option<String>,
106}
107
108// ── Matcher constructors ────────────────────────────────────────────
109
110/// Match a raw fragment.
111pub fn raw(value: &str) -> FragmentMatcher {
112    FragmentMatcher::Raw(value.to_string())
113}
114
115/// Match an escaped signal fragment.
116pub fn signal(value: &str) -> FragmentMatcher {
117    FragmentMatcher::Signal {
118        value: value.to_string(),
119        raw: false,
120        raw_text_context: false,
121    }
122}
123
124/// Match a raw (unescaped) signal fragment.
125pub fn signal_raw(value: &str) -> FragmentMatcher {
126    FragmentMatcher::Signal {
127        value: value.to_string(),
128        raw: true,
129        raw_text_context: false,
130    }
131}
132
133/// Match a signal rendered in an HTML raw-text context.
134pub fn raw_text_signal(value: &str, raw: bool) -> FragmentMatcher {
135    FragmentMatcher::Signal {
136        value: value.to_string(),
137        raw,
138        raw_text_context: true,
139    }
140}
141
142/// Match a simple dynamic attribute.
143pub fn attr(name: &str, value: &str) -> FragmentMatcher {
144    FragmentMatcher::Attribute(AttrMatcher {
145        name: name.to_string(),
146        value: Some(value.to_string()),
147        ..Default::default()
148    })
149}
150
151/// Match a template (mixed) attribute.
152pub fn attr_template(name: &str, template: &str) -> FragmentMatcher {
153    FragmentMatcher::Attribute(AttrMatcher {
154        name: name.to_string(),
155        template: Some(template.to_string()),
156        ..Default::default()
157    })
158}
159
160/// Match a complex (:-prefixed) attribute.
161pub fn attr_complex(name: &str, value: &str) -> FragmentMatcher {
162    FragmentMatcher::Attribute(AttrMatcher {
163        name: name.to_string(),
164        value: Some(value.to_string()),
165        complex: true,
166        ..Default::default()
167    })
168}
169
170/// Match a complex attribute with attrStart.
171pub fn attr_complex_start(name: &str, value: &str) -> FragmentMatcher {
172    FragmentMatcher::Attribute(AttrMatcher {
173        name: name.to_string(),
174        value: Some(value.to_string()),
175        complex: true,
176        attr_start: true,
177        ..Default::default()
178    })
179}
180
181/// Match a boolean attribute with an identifier condition.
182pub fn bool_attr(name: &str, signal: &str) -> FragmentMatcher {
183    FragmentMatcher::Attribute(AttrMatcher {
184        name: name.to_string(),
185        bool_signal: Some(signal.to_string()),
186        ..Default::default()
187    })
188}
189
190/// Match a boolean attribute with attrStart.
191pub fn bool_attr_start(name: &str, signal: &str) -> FragmentMatcher {
192    FragmentMatcher::Attribute(AttrMatcher {
193        name: name.to_string(),
194        bool_signal: Some(signal.to_string()),
195        attr_start: true,
196        ..Default::default()
197    })
198}
199
200/// Match a boolean attribute with a predicate condition (e.g., `?disabled={{count > 5}}`).
201pub fn bool_attr_predicate(name: &str, left: &str, op: i32, right: &str) -> FragmentMatcher {
202    FragmentMatcher::Attribute(AttrMatcher {
203        name: name.to_string(),
204        bool_predicate: Some((left.to_string(), op, right.to_string())),
205        ..Default::default()
206    })
207}
208
209/// Match a boolean attribute with a negated condition (e.g., `?disabled={{!isReady}}`).
210pub fn bool_attr_not(name: &str, inner: &str) -> FragmentMatcher {
211    FragmentMatcher::Attribute(AttrMatcher {
212        name: name.to_string(),
213        bool_not: Some(inner.to_string()),
214        ..Default::default()
215    })
216}
217
218/// Match a static rawValue attribute.
219pub fn attr_raw(name: &str, value: &str) -> FragmentMatcher {
220    FragmentMatcher::Attribute(AttrMatcher {
221        name: name.to_string(),
222        value: Some(value.to_string()),
223        raw_value: true,
224        ..Default::default()
225    })
226}
227
228/// Match a static rawValue attribute with attrStart.
229pub fn attr_raw_start(name: &str, value: &str) -> FragmentMatcher {
230    FragmentMatcher::Attribute(AttrMatcher {
231        name: name.to_string(),
232        value: Some(value.to_string()),
233        raw_value: true,
234        attr_start: true,
235        ..Default::default()
236    })
237}
238
239/// Match a skipped attribute.
240pub fn attr_skip(name: &str, value: &str) -> FragmentMatcher {
241    FragmentMatcher::Attribute(AttrMatcher {
242        name: name.to_string(),
243        value: Some(value.to_string()),
244        attr_skip: true,
245        ..Default::default()
246    })
247}
248
249/// Match a skipped attribute with a static raw value.
250pub fn attr_skip_raw(name: &str, value: &str) -> FragmentMatcher {
251    FragmentMatcher::Attribute(AttrMatcher {
252        name: name.to_string(),
253        raw_value: true,
254        value: Some(value.to_string()),
255        attr_skip: true,
256        ..Default::default()
257    })
258}
259
260/// Match a skipped attribute with an embedded binding template.
261pub fn attr_skip_template(name: &str, template: &str) -> FragmentMatcher {
262    FragmentMatcher::Attribute(AttrMatcher {
263        name: name.to_string(),
264        template: Some(template.to_string()),
265        attr_skip: true,
266        ..Default::default()
267    })
268}
269
270/// Match a simple dynamic attribute with attrStart.
271pub fn attr_start(name: &str, value: &str) -> FragmentMatcher {
272    FragmentMatcher::Attribute(AttrMatcher {
273        name: name.to_string(),
274        value: Some(value.to_string()),
275        attr_start: true,
276        ..Default::default()
277    })
278}
279
280/// Match a component fragment.
281pub fn component(id: &str) -> FragmentMatcher {
282    FragmentMatcher::Component(id.to_string())
283}
284
285/// Match a for-loop fragment.
286pub fn for_loop(item: &str, collection: &str, template: &str) -> FragmentMatcher {
287    FragmentMatcher::ForLoop {
288        item: item.to_string(),
289        collection: collection.to_string(),
290        template: template.to_string(),
291    }
292}
293
294/// Match an if-condition fragment.
295pub fn if_cond(template: &str) -> FragmentMatcher {
296    FragmentMatcher::IfCond {
297        template: template.to_string(),
298    }
299}
300
301/// Match the start marker of an inline streaming boundary tape.
302pub fn boundary(name: &str, declaration: u32) -> FragmentMatcher {
303    FragmentMatcher::Boundary {
304        name: name.to_string(),
305        declaration,
306    }
307}
308
309/// Match the end marker that closes an inline streaming boundary tape.
310#[must_use]
311pub fn boundary_end(declaration: u32) -> FragmentMatcher {
312    FragmentMatcher::BoundaryEnd { declaration }
313}
314
315// ── Assertion implementation ────────────────────────────────────────
316
317/// Assert that a fragment list matches the expected matchers.
318pub fn assert_fragment_list(
319    fragments: &[webui_protocol::WebUIFragment],
320    matchers: &[FragmentMatcher],
321) {
322    use webui_protocol::web_ui_fragment::Fragment;
323
324    assert_eq!(
325        fragments.len(),
326        matchers.len(),
327        "Fragment count mismatch: got {} fragments, expected {}\nFragments: {:#?}",
328        fragments.len(),
329        matchers.len(),
330        fragments.iter().map(format_fragment).collect::<Vec<_>>()
331    );
332
333    for (i, (frag, matcher)) in fragments.iter().zip(matchers.iter()).enumerate() {
334        match (frag.fragment.as_ref(), matcher) {
335            (Some(Fragment::Raw(r)), FragmentMatcher::Raw(expected)) => {
336                assert_eq!(r.value, *expected, "Fragment[{}]: raw value mismatch", i);
337            }
338            (
339                Some(Fragment::Signal(s)),
340                FragmentMatcher::Signal {
341                    value,
342                    raw,
343                    raw_text_context,
344                },
345            ) => {
346                assert_eq!(s.value, *value, "Fragment[{}]: signal value mismatch", i);
347                assert_eq!(s.raw, *raw, "Fragment[{}]: signal raw flag mismatch", i);
348                assert_eq!(
349                    s.raw_text_context, *raw_text_context,
350                    "Fragment[{i}]: signal raw-text context mismatch"
351                );
352            }
353            (Some(Fragment::Attribute(a)), FragmentMatcher::Attribute(m)) => {
354                assert_eq!(a.name, m.name, "Fragment[{}]: attr name mismatch", i);
355                if let Some(ref v) = m.value {
356                    assert_eq!(a.value, *v, "Fragment[{}]: attr value mismatch", i);
357                }
358                if let Some(ref t) = m.template {
359                    assert_eq!(a.template, *t, "Fragment[{}]: attr template mismatch", i);
360                }
361                assert_eq!(
362                    a.complex, m.complex,
363                    "Fragment[{}]: attr complex mismatch",
364                    i
365                );
366                assert_eq!(
367                    a.attr_start, m.attr_start,
368                    "Fragment[{}]: attr_start mismatch",
369                    i
370                );
371                assert_eq!(
372                    a.attr_skip, m.attr_skip,
373                    "Fragment[{}]: attr_skip mismatch",
374                    i
375                );
376                assert_eq!(
377                    a.raw_value, m.raw_value,
378                    "Fragment[{}]: raw_value mismatch",
379                    i
380                );
381                if let Some(ref sig) = m.bool_signal {
382                    let cond = a
383                        .condition_tree
384                        .as_ref()
385                        .unwrap_or_else(|| panic!("Fragment[{}]: expected condition_tree", i));
386                    match cond.expr.as_ref() {
387                        Some(webui_protocol::condition_expr::Expr::Identifier(id)) => {
388                            assert_eq!(
389                                id.value, *sig,
390                                "Fragment[{}]: bool attr signal mismatch",
391                                i
392                            );
393                        }
394                        other => panic!(
395                            "Fragment[{}]: expected identifier condition, got {:?}",
396                            i, other
397                        ),
398                    }
399                }
400                if let Some((ref left, op, ref right)) = m.bool_predicate {
401                    let cond = a
402                        .condition_tree
403                        .as_ref()
404                        .unwrap_or_else(|| panic!("Fragment[{}]: expected condition_tree", i));
405                    match cond.expr.as_ref() {
406                        Some(webui_protocol::condition_expr::Expr::Predicate(pred)) => {
407                            assert_eq!(
408                                pred.left, *left,
409                                "Fragment[{}]: predicate left mismatch",
410                                i
411                            );
412                            assert_eq!(
413                                pred.operator, op,
414                                "Fragment[{}]: predicate operator mismatch",
415                                i
416                            );
417                            assert_eq!(
418                                pred.right, *right,
419                                "Fragment[{}]: predicate right mismatch",
420                                i
421                            );
422                        }
423                        other => panic!(
424                            "Fragment[{}]: expected predicate condition, got {:?}",
425                            i, other
426                        ),
427                    }
428                }
429                if let Some(ref inner) = m.bool_not {
430                    let cond = a
431                        .condition_tree
432                        .as_ref()
433                        .unwrap_or_else(|| panic!("Fragment[{}]: expected condition_tree", i));
434                    match cond.expr.as_ref() {
435                        Some(webui_protocol::condition_expr::Expr::Not(not_cond)) => {
436                            let inner_cond = not_cond.condition.as_ref().unwrap_or_else(|| {
437                                panic!("Fragment[{}]: not condition missing inner", i)
438                            });
439                            match inner_cond.expr.as_ref() {
440                                Some(webui_protocol::condition_expr::Expr::Identifier(id)) => {
441                                    assert_eq!(
442                                        id.value, *inner,
443                                        "Fragment[{}]: not inner identifier mismatch",
444                                        i
445                                    );
446                                }
447                                other => panic!(
448                                    "Fragment[{}]: expected identifier inside not, got {:?}",
449                                    i, other
450                                ),
451                            }
452                        }
453                        other => panic!("Fragment[{}]: expected not condition, got {:?}", i, other),
454                    }
455                }
456            }
457            (Some(Fragment::Component(c)), FragmentMatcher::Component(id)) => {
458                assert_eq!(c.fragment_id, *id, "Fragment[{}]: component id mismatch", i);
459            }
460            (
461                Some(Fragment::ForLoop(fl)),
462                FragmentMatcher::ForLoop {
463                    item,
464                    collection,
465                    template,
466                },
467            ) => {
468                assert_eq!(fl.item, *item, "Fragment[{}]: for item mismatch", i);
469                assert_eq!(
470                    fl.collection, *collection,
471                    "Fragment[{}]: for collection mismatch",
472                    i
473                );
474                assert_eq!(
475                    fl.fragment_id, *template,
476                    "Fragment[{}]: for template mismatch",
477                    i
478                );
479            }
480            (Some(Fragment::IfCond(ic)), FragmentMatcher::IfCond { template }) => {
481                assert_eq!(
482                    ic.fragment_id, *template,
483                    "Fragment[{}]: if template mismatch",
484                    i
485                );
486            }
487            (
488                Some(Fragment::Boundary(boundary)),
489                FragmentMatcher::Boundary { name, declaration },
490            ) => {
491                assert_eq!(
492                    boundary.phase(),
493                    webui_protocol::BoundaryPhase::Start,
494                    "Fragment[{}]: expected a boundary start marker",
495                    i
496                );
497                assert_eq!(
498                    boundary.name, *name,
499                    "Fragment[{}]: boundary name mismatch",
500                    i
501                );
502                assert_eq!(
503                    boundary.declaration_id, *declaration,
504                    "Fragment[{}]: boundary declaration mismatch",
505                    i
506                );
507            }
508            (Some(Fragment::Boundary(boundary)), FragmentMatcher::BoundaryEnd { declaration }) => {
509                assert_eq!(
510                    boundary.phase(),
511                    webui_protocol::BoundaryPhase::End,
512                    "Fragment[{}]: expected a boundary end marker",
513                    i
514                );
515                assert_eq!(
516                    boundary.declaration_id, *declaration,
517                    "Fragment[{}]: boundary declaration mismatch",
518                    i
519                );
520            }
521            (_actual, expected) => {
522                panic!(
523                    "Fragment[{}]: type mismatch\n  expected: {:?}\n  actual: {}",
524                    i,
525                    expected,
526                    format_fragment(frag)
527                );
528            }
529        }
530    }
531}
532
533fn format_fragment(frag: &webui_protocol::WebUIFragment) -> String {
534    use webui_protocol::web_ui_fragment::Fragment;
535    match frag.fragment.as_ref() {
536        Some(Fragment::Raw(r)) => format!("raw({:?})", r.value),
537        Some(Fragment::Signal(s)) if s.raw_text_context => {
538            format!("raw_text_signal({:?}, raw={})", s.value, s.raw)
539        }
540        Some(Fragment::Signal(s)) => format!("signal({:?}, raw={})", s.value, s.raw),
541        Some(Fragment::Attribute(a)) => format!(
542            "attr({:?}, value={:?}, template={:?}, complex={}, start={}, skip={}, raw_value={})",
543            a.name, a.value, a.template, a.complex, a.attr_start, a.attr_skip, a.raw_value
544        ),
545        Some(Fragment::Component(c)) => format!("component({:?})", c.fragment_id),
546        Some(Fragment::ForLoop(f)) => format!(
547            "for({:?} in {:?}, template={:?})",
548            f.item, f.collection, f.fragment_id
549        ),
550        Some(Fragment::IfCond(i)) => format!("if(template={:?})", i.fragment_id),
551        Some(Fragment::Plugin(p)) => format!("plugin(data={:?})", p.data),
552        Some(Fragment::Route(r)) => {
553            format!("route(path={:?}, fragment={:?})", r.path, r.fragment_id)
554        }
555        Some(Fragment::Outlet(_)) => "outlet".to_string(),
556        Some(Fragment::Boundary(boundary)) => format!(
557            "boundary(name={:?}, phase={:?}, declaration={})",
558            boundary.name,
559            boundary.phase(),
560            boundary.declaration_id
561        ),
562        None => "None".to_string(),
563    }
564}
565
566/// A test file system that manages temporary files and directories
567pub struct TestFileSystem {
568    files: HashMap<String, PathBuf>,
569
570    // Keep directories alive for the lifetime of this struct
571    _temp_dirs: Vec<TempDir>,
572}
573
574impl Default for TestFileSystem {
575    fn default() -> Self {
576        Self::new()
577    }
578}
579
580impl TestFileSystem {
581    /// Create a new empty test file system
582    pub fn new() -> Self {
583        Self {
584            files: HashMap::new(),
585            _temp_dirs: Vec::new(),
586        }
587    }
588
589    /// Add a file to the test file system at the specified path
590    #[allow(clippy::disallowed_methods)] // Test helper — panicking on failure is intentional
591    pub fn add_file(&mut self, path: &str, content: &str) -> PathBuf {
592        // Create a new temporary directory for this file
593        let temp_dir = tempfile::tempdir().expect("Failed to create temporary directory");
594
595        // Parse the path to separate directories and filename
596        let path_parts: Vec<&str> = path.split('/').collect();
597        let filename = path_parts.last().expect("Path must contain a filename");
598
599        // Create the file within the temporary directory
600        let file_path = temp_dir.path().join(filename);
601        fs::write(&file_path, content).expect("Failed to write content to file");
602
603        // Store the path and keep the directory alive
604        self.files.insert(path.to_string(), file_path.clone());
605        self._temp_dirs.push(temp_dir);
606
607        // Return a reference to the stored path
608        self.files
609            .get(path)
610            .expect("File path not found in the test file system");
611
612        // Return the path by value (clone it)
613        file_path
614    }
615}