vrl/test/
mod.rs

1#![allow(clippy::print_stdout)] // tests
2#![allow(clippy::print_stderr)] // tests
3
4use std::path::{MAIN_SEPARATOR, PathBuf};
5use std::{collections::BTreeMap, env, str::FromStr, time::Instant};
6
7use ansi_term::Colour;
8use chrono::{DateTime, SecondsFormat, Utc};
9
10pub use test::Test;
11
12use crate::compiler::{
13    CompilationResult, CompileConfig, Function, Program, SecretTarget, TargetValueRef, TimeZone,
14    VrlRuntime, compile_with_external,
15    runtime::{Runtime, Terminate},
16    state::{ExternalEnv, RuntimeState},
17    value::VrlValueConvert,
18};
19use crate::diagnostic::{DiagnosticList, Formatter};
20use crate::value::Secrets;
21use crate::value::Value;
22
23#[allow(clippy::module_inception)]
24mod test;
25
26fn measure_time<F, R>(f: F) -> (R, std::time::Duration)
27where
28    F: FnOnce() -> R, // F is a closure that takes no argument and returns a value of type R
29{
30    let start = Instant::now();
31    let result = f(); // Execute the closure
32    let duration = start.elapsed();
33    (result, duration) // Return the result of the closure and the elapsed time
34}
35
36pub struct TestConfig {
37    pub fail_early: bool,
38    pub verbose: bool,
39    pub no_diff: bool,
40    pub timings: bool,
41    pub runtime: VrlRuntime,
42    pub timezone: TimeZone,
43}
44
45#[derive(Clone)]
46struct FailedTest {
47    name: String,
48    category: String,
49    source_file: String,
50    source_line: u32,
51}
52
53pub fn test_dir() -> PathBuf {
54    PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap())
55}
56
57pub fn test_prefix() -> String {
58    let mut prefix = test_dir().join("tests").to_string_lossy().to_string();
59    prefix.push(MAIN_SEPARATOR);
60    prefix
61}
62
63pub fn example_vrl_path() -> PathBuf {
64    test_dir().join("tests").join("example.vrl")
65}
66
67pub fn get_tests_from_functions(functions: Vec<Box<dyn Function>>) -> Vec<Test> {
68    let mut tests = vec![];
69    functions.into_iter().for_each(|function| {
70        if let Some(closure) = function.closure() {
71            closure.inputs.iter().for_each(|input| {
72                let test = Test::from_example(
73                    format!("{} (closure)", function.identifier()),
74                    &input.example,
75                );
76                tests.push(test);
77            });
78        }
79
80        function.examples().iter().for_each(|example| {
81            let test = Test::from_example(function.identifier(), example);
82            tests.push(test)
83        })
84    });
85
86    tests
87}
88
89pub fn run_tests<T>(
90    tests: Vec<Test>,
91    cfg: &TestConfig,
92    functions: &[Box<dyn Function>],
93    compile_config_provider: impl Fn() -> (CompileConfig, T),
94    finalize_config: impl Fn(T),
95) {
96    let total_count = tests.len();
97    let mut failed_count = 0;
98    let mut warnings_count = 0;
99    let mut category = "".to_owned();
100    let mut failed_tests: Vec<FailedTest> = Vec::new();
101
102    for mut test in tests {
103        if category != test.category {
104            category.clone_from(&test.category);
105            println!("{}", Colour::Fixed(3).bold().paint(category.to_string()));
106        }
107
108        if let Some(err) = test.error {
109            println!("{}", Colour::Purple.bold().paint("INVALID"));
110            println!("{}", Colour::Red.paint(err));
111            failed_count += 1;
112            continue;
113        }
114
115        let mut name = test.name.clone();
116        name.truncate(58);
117
118        let dots = if name.len() >= 60 { 0 } else { 60 - name.len() };
119        print!("  {}{}", name, Colour::Fixed(240).paint(".".repeat(dots)));
120
121        if test.skip {
122            println!("{}", Colour::Yellow.bold().paint("SKIPPED"));
123            continue;
124        }
125
126        let (mut config, config_metadata) = (compile_config_provider)();
127        // Set some read-only paths that can be tested
128        for (path, recursive) in &test.read_only_paths {
129            config.set_read_only_path(path.clone(), *recursive);
130        }
131
132        let (result, compile_duration) = measure_time(|| {
133            compile_with_external(&test.source, functions, &ExternalEnv::default(), config)
134        });
135        let compile_timing_fmt = if cfg.timings {
136            format!("comp: {compile_duration:>9.3?}")
137        } else {
138            String::new()
139        };
140
141        let failed = match result {
142            Ok(CompilationResult {
143                program,
144                warnings,
145                config: _,
146            }) => {
147                warnings_count += warnings.len();
148
149                if test.check_diagnostics {
150                    process_compilation_diagnostics(&test, cfg, warnings, compile_timing_fmt)
151                } else if warnings.is_empty() {
152                    let run_start = Instant::now();
153
154                    finalize_config(config_metadata);
155                    let result = run_vrl(program, &mut test.object, cfg.timezone, cfg.runtime);
156                    let run_end = run_start.elapsed();
157
158                    let timings = {
159                        let timings_color = if run_end.as_millis() > 10 { 1 } else { 245 };
160                        let timings_fmt = if cfg.timings {
161                            format!(" ({compile_timing_fmt}, run: {run_end:>9.3?})")
162                        } else {
163                            String::new()
164                        };
165                        Colour::Fixed(timings_color).paint(timings_fmt).to_string()
166                    };
167
168                    process_result(result, &mut test, cfg, timings)
169                } else {
170                    println!("{} (diagnostics)", Colour::Red.bold().paint("FAILED"));
171                    let formatter = Formatter::new(&test.source, warnings);
172                    println!("{formatter}");
173                    // mark as failure, did not expect any warnings
174                    true
175                }
176            }
177            Err(diagnostics) => {
178                warnings_count += diagnostics.warnings().len();
179                process_compilation_diagnostics(&test, cfg, diagnostics, compile_timing_fmt)
180            }
181        };
182        if failed {
183            failed_count += 1;
184            failed_tests.push(FailedTest {
185                name: test.name.clone(),
186                category: test.category.clone(),
187                source_file: test.source_file.clone(),
188                source_line: test.source_line,
189            });
190        }
191    }
192
193    print_result(total_count, failed_count, warnings_count, failed_tests);
194}
195
196fn process_result(
197    result: Result<Value, Terminate>,
198    test: &mut Test,
199    config: &TestConfig,
200    timings: String,
201) -> bool {
202    if test.skip {
203        return false;
204    }
205
206    match result {
207        Ok(got) => {
208            let got_value = vrl_value_to_json_value(got);
209            let mut failed = false;
210
211            let match_mode = if test.check_type_only {
212                MatchMode::TypeOnly
213            } else {
214                MatchMode::Exact
215            };
216
217            let want = test.result.clone();
218            let want_value = if want.starts_with("r'") && want.ends_with('\'') {
219                match regex::Regex::new(&want[2..want.len() - 1].replace("\\'", "'")) {
220                    Ok(regex) => regex.to_string().into(),
221                    Err(_) => want.into(),
222                }
223            } else if want.starts_with("t'") && want.ends_with('\'') {
224                match DateTime::<Utc>::from_str(&want[2..want.len() - 1]) {
225                    Ok(dt) => dt.to_rfc3339_opts(SecondsFormat::AutoSi, true).into(),
226                    Err(_) => want.into(),
227                }
228            } else if want.starts_with("s'") && want.ends_with('\'') {
229                want[2..want.len() - 1].into()
230            } else {
231                serde_json::from_str::<'_, serde_json::Value>(want.trim()).unwrap_or_else(|err| {
232                    eprintln!("{err}");
233                    want.into()
234                })
235            };
236
237            if match_mode.matches(&got_value, &want_value) {
238                print!(
239                    "{timings}{}",
240                    Colour::Green.bold().paint(match_mode.ok_label())
241                );
242            } else {
243                print!("{}", Colour::Red.bold().paint(match_mode.fail_label()));
244
245                if !config.no_diff {
246                    let want = serde_json::to_string_pretty(&want_value).unwrap();
247                    let got = serde_json::to_string_pretty(&got_value).unwrap();
248
249                    let diff = prettydiff::diff_lines(&want, &got);
250                    println!("  {diff}");
251                }
252
253                failed = true;
254            }
255            println!();
256
257            if config.verbose {
258                println!("{got_value:#}");
259            }
260
261            if failed && config.fail_early {
262                std::process::exit(1)
263            }
264            failed
265        }
266        Err(err) => {
267            let mut failed = false;
268            let got = err.to_string().trim().to_owned();
269            let want = test.result.clone().trim().to_owned();
270
271            if (test.result_approx && compare_partial_diagnostic(&got, &want)) || got == want {
272                println!("{}{}", Colour::Green.bold().paint("OK"), timings);
273            } else if matches!(err, Terminate::Abort { .. }) {
274                let want =
275                    serde_json::from_str::<'_, serde_json::Value>(&want).unwrap_or_else(|err| {
276                        eprintln!("{err}");
277                        want.into()
278                    });
279
280                let got = vrl_value_to_json_value(test.object.clone());
281                if got == want {
282                    println!("{}{}", Colour::Green.bold().paint("OK"), timings);
283                } else {
284                    println!("{} (abort)", Colour::Red.bold().paint("FAILED"));
285
286                    if !config.no_diff {
287                        let want = serde_json::to_string_pretty(&want).unwrap();
288                        let got = serde_json::to_string_pretty(&got).unwrap();
289                        let diff = prettydiff::diff_lines(&want, &got);
290                        println!("{diff}");
291                    }
292
293                    failed = true;
294                }
295            } else {
296                println!("{} (runtime)", Colour::Red.bold().paint("FAILED"));
297
298                if !config.no_diff {
299                    let diff = prettydiff::diff_lines(&want, &got);
300                    println!("{diff}");
301                }
302
303                failed = true;
304            }
305
306            if config.verbose {
307                println!("{err:#}");
308            }
309
310            if failed && config.fail_early {
311                std::process::exit(1)
312            }
313            failed
314        }
315    }
316}
317
318fn process_compilation_diagnostics(
319    test: &Test,
320    cfg: &TestConfig,
321    diagnostics: DiagnosticList,
322    compile_timing_fmt: String,
323) -> bool {
324    let mut failed = false;
325
326    let mut formatter = Formatter::new(&test.source, diagnostics);
327
328    let got = formatter.to_string();
329    let got = got.trim();
330
331    let want = test.result.clone();
332    let want = want.trim();
333
334    if (test.result_approx && compare_partial_diagnostic(got, want)) || got == want {
335        let timings = {
336            let timings_fmt = if cfg.timings {
337                format!(" ({compile_timing_fmt})")
338            } else {
339                String::new()
340            };
341            Colour::Fixed(245).paint(timings_fmt).to_string()
342        };
343        println!("{}{timings}", Colour::Green.bold().paint("OK"));
344    } else {
345        println!("{} (compilation)", Colour::Red.bold().paint("FAILED"));
346
347        if !cfg.no_diff {
348            let diff = prettydiff::diff_lines(want, got);
349            println!("{diff}");
350        }
351
352        // Always print diagnostics when test fails
353        formatter.enable_colors(true);
354        println!("{formatter:#}");
355
356        failed = true;
357    }
358
359    if cfg.verbose && !failed {
360        // In verbose mode, print diagnostics even for passing tests
361        formatter.enable_colors(true);
362        println!("{formatter:#}");
363    }
364
365    if failed && cfg.fail_early {
366        std::process::exit(1)
367    }
368    failed
369}
370
371fn print_result(
372    total_count: usize,
373    failed_count: usize,
374    warnings_count: usize,
375    failed_tests: Vec<FailedTest>,
376) {
377    let code = i32::from(failed_count > 0);
378
379    println!("\n");
380
381    let passed_count = total_count - failed_count;
382    if failed_count > 0 {
383        println!(
384            "Overall result: {}\n\n  Number failed: {}\n  Number passed: {}",
385            Colour::Red.bold().paint("FAILED"),
386            Colour::Red.bold().paint(failed_count.to_string()),
387            Colour::Green.bold().paint(passed_count.to_string())
388        );
389    } else {
390        println!(
391            "Overall result: {}\n  Number passed: {}",
392            Colour::Green.bold().paint("SUCCESS"),
393            Colour::Green.bold().paint(passed_count.to_string())
394        );
395    }
396    println!(
397        "  Number warnings: {}",
398        Colour::Yellow.bold().paint(warnings_count.to_string())
399    );
400
401    if !failed_tests.is_empty() {
402        println!("\n{}", Colour::Red.bold().paint("Failed tests:"));
403        for test in failed_tests {
404            println!(
405                "  {} - {}:{}",
406                Colour::Yellow.paint(format!("{}/{}", test.category, test.name)),
407                test.source_file,
408                test.source_line
409            );
410        }
411    }
412
413    std::process::exit(code)
414}
415
416fn compare_partial_diagnostic(got: &str, want: &str) -> bool {
417    got.lines()
418        .filter(|line| line.trim().starts_with("error[E"))
419        .zip(want.trim().lines())
420        .all(|(got, want)| got.contains(want))
421}
422
423fn vrl_value_to_json_value(value: Value) -> serde_json::Value {
424    use serde_json::Value::*;
425
426    match value {
427        v @ Value::Bytes(_) => String(v.try_bytes_utf8_lossy().unwrap().into_owned()),
428        Value::Integer(v) => v.into(),
429        Value::Float(v) => v.into_inner().into(),
430        Value::Boolean(v) => v.into(),
431        Value::Object(v) => v
432            .into_iter()
433            .map(|(k, v)| (k, vrl_value_to_json_value(v)))
434            .collect::<serde_json::Value>(),
435        Value::Array(v) => v
436            .into_iter()
437            .map(vrl_value_to_json_value)
438            .collect::<serde_json::Value>(),
439        Value::Timestamp(v) => v.to_rfc3339_opts(SecondsFormat::AutoSi, true).into(),
440        Value::Regex(v) => v.to_string().into(),
441        Value::Null => Null,
442    }
443}
444
445enum MatchMode {
446    Exact,
447    TypeOnly,
448}
449
450impl MatchMode {
451    fn matches(&self, got: &serde_json::Value, want: &serde_json::Value) -> bool {
452        match self {
453            MatchMode::Exact => got == want,
454            MatchMode::TypeOnly => std::mem::discriminant(got) == std::mem::discriminant(want),
455        }
456    }
457
458    fn ok_label(&self) -> &'static str {
459        match self {
460            MatchMode::Exact => "OK",
461            MatchMode::TypeOnly => "OK (type match)",
462        }
463    }
464
465    fn fail_label(&self) -> &'static str {
466        match self {
467            MatchMode::Exact => "FAILED (expectation)",
468            MatchMode::TypeOnly => "FAILED (type mismatch)",
469        }
470    }
471}
472
473fn run_vrl(
474    program: Program,
475    test_object: &mut Value,
476    timezone: TimeZone,
477    vrl_runtime: VrlRuntime,
478) -> Result<Value, Terminate> {
479    let mut metadata = Value::from(BTreeMap::new());
480    let mut target = TargetValueRef {
481        value: test_object,
482        metadata: &mut metadata,
483        secrets: &mut Secrets::new(),
484    };
485
486    // Insert a dummy secret for examples to use
487    target.insert_secret("my_secret", "secret value");
488    target.insert_secret("datadog_api_key", "secret value");
489
490    match vrl_runtime {
491        VrlRuntime::Ast => {
492            // test_enrichment.finish_load();
493            let mut runtime = Runtime::new(RuntimeState::default());
494            runtime.resolve(&mut target, &program, &timezone)
495        }
496    }
497}