tendermint_testgen/
tester.rs

1use std::{
2    fs::{self, DirEntry},
3    io::Write,
4    panic::{self, RefUnwindSafe, UnwindSafe},
5    path::{Path, PathBuf},
6    sync::{Arc, Mutex},
7};
8
9use serde::de::DeserializeOwned;
10use simple_error::SimpleError;
11use tempfile::TempDir;
12
13use crate::{
14    helpers::*,
15    tester::TestResult::{Failure, ParseError, ReadError, Success},
16};
17
18/// A test environment, which is essentially a wrapper around some directory,
19/// with some utility functions operating relative to that directory.
20#[derive(Debug, Clone)]
21pub struct TestEnv {
22    /// Directory where the test is being executed
23    current_dir: String,
24}
25
26impl TestEnv {
27    pub fn new(current_dir: &str) -> Option<Self> {
28        fs::create_dir_all(current_dir).ok().map(|_| TestEnv {
29            current_dir: current_dir.to_string(),
30        })
31    }
32
33    pub fn push(&self, child: &str) -> Option<Self> {
34        let mut path = PathBuf::from(&self.current_dir);
35        path.push(child);
36        path.to_str().and_then(TestEnv::new)
37    }
38
39    pub fn current_dir(&self) -> &str {
40        &self.current_dir
41    }
42
43    pub fn clear_log(&self) -> Option<()> {
44        fs::remove_file(self.full_path("log")).ok()
45    }
46
47    pub fn logln(&self, msg: &str) -> Option<()> {
48        println!("{msg}");
49        fs::OpenOptions::new()
50            .create(true)
51            .append(true)
52            .open(self.full_path("log"))
53            .ok()
54            .and_then(|mut file| writeln!(file, "{msg}").ok())
55    }
56
57    pub fn logln_to(&self, msg: &str, rel_path: impl AsRef<Path>) -> Option<()> {
58        println!("{msg}");
59        fs::OpenOptions::new()
60            .create(true)
61            .append(true)
62            .open(self.full_path(rel_path))
63            .ok()
64            .and_then(|mut file| writeln!(file, "{msg}").ok())
65    }
66
67    /// Read a file from a path relative to the environment current dir into a string
68    pub fn read_file(&self, rel_path: impl AsRef<Path>) -> Option<String> {
69        fs::read_to_string(self.full_path(rel_path)).ok()
70    }
71
72    /// Write a file to a path relative to the environment current dir
73    pub fn write_file(&self, rel_path: impl AsRef<Path>, contents: &str) -> Option<()> {
74        fs::write(self.full_path(rel_path), contents).ok()
75    }
76
77    /// Parse a file from a path relative to the environment current dir as the given type
78    pub fn parse_file<T: DeserializeOwned>(&self, rel_path: impl AsRef<Path>) -> Option<T> {
79        self.read_file(rel_path)
80            .and_then(|input| serde_json::from_str(&input).ok())
81    }
82
83    /// Copy a file from the path outside environment into the environment current dir
84    /// Returns None if copying was not successful
85    pub fn copy_file_from(&self, path: impl AsRef<Path>) -> Option<()> {
86        let path = path.as_ref();
87        let new_name = path.file_name()?.to_str()?;
88        self.copy_file_from_as(path, new_name)
89    }
90
91    /// Copy a file from the path outside environment into the environment current dir
92    /// Assigns the file a new_name in the current environment
93    /// Returns None if copying was not successful
94    pub fn copy_file_from_as(&self, path: impl AsRef<Path>, new_name: &str) -> Option<()> {
95        let path = path.as_ref();
96        if !path.is_file() {
97            return None;
98        }
99        fs::copy(path, self.full_path(new_name)).ok().map(|_| ())
100    }
101
102    /// Copy a file from the path relative to the other environment into the environment current dir
103    /// Returns None if copying was not successful
104    pub fn copy_file_from_env(&self, other: &TestEnv, path: impl AsRef<Path>) -> Option<()> {
105        self.copy_file_from(other.full_path(path))
106    }
107
108    /// Copy a file from the path relative to the other environment into the environment current dir
109    /// Assigns the file a new_name in the current environment
110    /// Returns None if copying was not successful
111    pub fn copy_file_from_env_as(
112        &self,
113        other: &TestEnv,
114        path: impl AsRef<Path>,
115        new_name: &str,
116    ) -> Option<()> {
117        self.copy_file_from_as(other.full_path(path), new_name)
118    }
119
120    /// Remove a file from a path relative to the environment current dir
121    pub fn remove_file(&self, rel_path: impl AsRef<Path>) -> Option<()> {
122        fs::remove_file(self.full_path(rel_path)).ok()
123    }
124
125    /// Convert a relative path to the full path from the test root
126    /// Return None if the full path can't be formed
127    pub fn full_path(&self, rel_path: impl AsRef<Path>) -> PathBuf {
128        PathBuf::from(&self.current_dir).join(rel_path)
129    }
130
131    /// Convert a full path to the path relative to the test root
132    /// Returns None if the full path doesn't contain test root as prefix
133    pub fn rel_path(&self, full_path: impl AsRef<Path>) -> Option<String> {
134        match PathBuf::from(full_path.as_ref()).strip_prefix(&self.current_dir) {
135            Err(_) => None,
136            Ok(rel_path) => rel_path.to_str().map(|rp| rp.to_string()),
137        }
138    }
139
140    /// Convert a relative path to the full path from the test root, canonicalized
141    /// Returns None the full path can't be formed
142    pub fn full_canonical_path(&self, rel_path: impl AsRef<Path>) -> Option<String> {
143        let full_path = PathBuf::from(&self.current_dir).join(rel_path);
144        full_path
145            .canonicalize()
146            .ok()
147            .and_then(|p| p.to_str().map(|x| x.to_string()))
148    }
149}
150
151#[derive(Debug, Clone)]
152pub enum TestResult {
153    ReadError,
154    ParseError(SimpleError),
155    Success,
156    Failure { message: String, location: String },
157}
158
159/// A function that takes as input the test file path and its content,
160/// and returns the result of running the test on it
161type TestFn = Box<dyn Fn(&str, &str) -> TestResult>;
162
163/// A function that takes as input the batch file path and its content,
164/// and returns the vector of test names/contents for tests in the batch,
165/// or None if the batch could not be parsed
166type BatchFn = Box<dyn Fn(&str, &str) -> Option<Vec<(String, String)>>>;
167
168pub struct Test {
169    /// test name
170    pub name: String,
171    /// test function
172    pub test: TestFn,
173}
174
175/// Tester allows you to easily run some test functions over a set of test files.
176/// You create a Tester instance with the reference to some specific directory, containing your test
177/// files. After a creation, you can add several types of tests there:
178///  * add_test() adds a simple test function, which can run on some test, deserilizable from a
179///    file.
180///  * add_test_with_env() allows your test function to receive several test environments, so that
181///    it can easily perform some operations on files when necessary
182///  * add_test_batch() adds a batch of test: a function that accepts a ceserializable batch
183///    description, and produces a set of test from it
184///
185///  After you have added all your test functions, you run Tester either on individual files
186///  using run_for_file(), or for whole directories, using run_foreach_in_dir();
187///  the directories will be traversed recursively top-down.
188///
189///  The last step involves calling the finalize() function, which will produce the test report
190///  and panic in case there was at least one failing test.
191///  When there are files in the directories you run Tester on, that could not be read/parsed,
192///  it is also considered an error, and leads to panic.
193pub struct Tester {
194    name: String,
195    root_dir: String,
196    tests: Vec<Test>,
197    batches: Vec<BatchFn>,
198    results: std::collections::BTreeMap<String, Vec<(String, TestResult)>>,
199}
200
201impl TestResult {
202    pub fn is_success(&self) -> bool {
203        matches!(self, TestResult::Success)
204    }
205
206    pub fn is_failure(&self) -> bool {
207        matches!(self, TestResult::Failure { .. })
208    }
209
210    pub fn is_readerror(&self) -> bool {
211        matches!(self, TestResult::ReadError)
212    }
213
214    pub fn is_parseerror(&self) -> bool {
215        matches!(self, TestResult::ParseError(_))
216    }
217}
218
219impl Tester {
220    pub fn new(name: &str, root_dir: &str) -> Tester {
221        Tester {
222            name: name.to_string(),
223            root_dir: root_dir.to_string(),
224            tests: vec![],
225            batches: vec![],
226            results: Default::default(),
227        }
228    }
229
230    pub fn env(&self) -> Option<TestEnv> {
231        TestEnv::new(&self.root_dir)
232    }
233
234    pub fn output_env(&self) -> Option<TestEnv> {
235        let output_dir = self.root_dir.clone() + "/_" + &self.name;
236        TestEnv::new(&output_dir)
237    }
238
239    fn capture_test<F>(test: F) -> TestResult
240    where
241        F: FnOnce() + UnwindSafe,
242    {
243        let test_result = Arc::new(Mutex::new(ParseError(SimpleError::new("no error"))));
244        let old_hook = panic::take_hook();
245        panic::set_hook({
246            let result = test_result.clone();
247            Box::new(move |info| {
248                let mut result = result.lock().unwrap();
249                let message = match info.payload().downcast_ref::<&'static str>() {
250                    Some(s) => s.to_string(),
251                    None => match info.payload().downcast_ref::<String>() {
252                        Some(s) => s.clone(),
253                        None => "Unknown error".to_string(),
254                    },
255                };
256                let location = match info.location() {
257                    Some(l) => l.to_string(),
258                    None => "".to_string(),
259                };
260                *result = Failure { message, location };
261            })
262        });
263        let result = panic::catch_unwind(test);
264        panic::set_hook(old_hook);
265        match result {
266            Ok(_) => Success,
267            Err(_) => (*test_result.lock().unwrap()).clone(),
268        }
269    }
270
271    pub fn add_test<T, F>(&mut self, name: &str, test: F)
272    where
273        T: 'static + DeserializeOwned + UnwindSafe,
274        F: Fn(T) + UnwindSafe + RefUnwindSafe + 'static,
275    {
276        let test_fn = move |_path: &str, input: &str| match parse_as::<T>(input) {
277            Ok(test_case) => Tester::capture_test(|| {
278                test(test_case);
279            }),
280            Err(e) => ParseError(e),
281        };
282        self.tests.push(Test {
283            name: name.to_string(),
284            test: Box::new(test_fn),
285        });
286    }
287
288    pub fn add_test_with_env<T, F>(&mut self, name: &str, test: F)
289    where
290        T: 'static + DeserializeOwned + UnwindSafe,
291        F: Fn(T, &TestEnv, &TestEnv, &TestEnv) + UnwindSafe + RefUnwindSafe + 'static,
292    {
293        let test_env = self.env().unwrap();
294        let output_env = self.output_env().unwrap();
295        let test_fn = move |path: &str, input: &str| match parse_as::<T>(input) {
296            Ok(test_case) => Tester::capture_test(|| {
297                // It is OK to unwrap() here: in case of unwrapping failure, the test will fail.
298                let dir = TempDir::new().unwrap();
299                let env = TestEnv::new(dir.path().to_str().unwrap()).unwrap();
300                let output_dir = output_env.full_path(path);
301                let output_env = TestEnv::new(output_dir.to_str().unwrap()).unwrap();
302                test(test_case, &env, &test_env, &output_env);
303                fs::remove_dir_all(env.current_dir()).unwrap();
304            }),
305            Err(e) => ParseError(e),
306        };
307        self.tests.push(Test {
308            name: name.to_string(),
309            test: Box::new(test_fn),
310        });
311    }
312
313    pub fn add_test_batch<T, F>(&mut self, batch: F)
314    where
315        T: 'static + DeserializeOwned,
316        F: Fn(T) -> Vec<(String, String)> + 'static,
317    {
318        let batch_fn = move |_path: &str, input: &str| match parse_as::<T>(input) {
319            Ok(test_batch) => Some(batch(test_batch)),
320            Err(_) => None,
321        };
322        self.batches.push(Box::new(batch_fn));
323    }
324
325    fn results_for(&mut self, name: &str) -> &mut Vec<(String, TestResult)> {
326        self.results.entry(name.to_string()).or_default()
327    }
328
329    fn add_result(&mut self, name: &str, path: &str, result: TestResult) {
330        self.results_for(name).push((path.to_string(), result));
331    }
332
333    fn read_error(&mut self, path: &str) {
334        self.results_for("")
335            .push((path.to_string(), TestResult::ReadError))
336    }
337
338    fn parse_error(&mut self, path: &str) {
339        self.results_for("").push((
340            path.to_string(),
341            TestResult::ParseError(SimpleError::new("no error")),
342        ))
343    }
344
345    pub fn successful_tests(&self, test: &str) -> Vec<String> {
346        let mut tests = Vec::new();
347        if let Some(results) = self.results.get(test) {
348            for (path, res) in results {
349                if let Success = res {
350                    tests.push(path.clone())
351                }
352            }
353        }
354        tests
355    }
356
357    pub fn failed_tests(&self, test: &str) -> Vec<(String, String, String)> {
358        let mut tests = Vec::new();
359        if let Some(results) = self.results.get(test) {
360            for (path, res) in results {
361                if let Failure { message, location } = res {
362                    tests.push((path.clone(), message.clone(), location.clone()))
363                }
364            }
365        }
366        tests
367    }
368
369    pub fn unreadable_tests(&self) -> Vec<String> {
370        let mut tests = Vec::new();
371        if let Some(results) = self.results.get("") {
372            for (path, res) in results {
373                if let ReadError = res {
374                    tests.push(path.clone())
375                }
376            }
377        }
378        tests
379    }
380
381    pub fn unparseable_tests(&self) -> Vec<String> {
382        let mut tests = Vec::new();
383        if let Some(results) = self.results.get("") {
384            for (path, res) in results {
385                if let ParseError(_) = res {
386                    tests.push(path.clone())
387                }
388            }
389        }
390        tests
391    }
392
393    fn run_for_input(&mut self, path: &str, input: &str) {
394        let mut results = Vec::new();
395        for Test { name, test } in &self.tests {
396            match test(path, input) {
397                TestResult::ParseError(_) => {
398                    continue;
399                },
400                res => results.push((name.to_string(), path, res)),
401            }
402        }
403        if !results.is_empty() {
404            for (name, path, res) in results {
405                self.add_result(&name, path, res)
406            }
407        } else {
408            // parsing as a test failed; try parse as a batch
409            let mut res_tests = Vec::new();
410            for batch in &self.batches {
411                match batch(path, input) {
412                    None => continue,
413                    Some(tests) => {
414                        for (name, input) in tests {
415                            let test_path = path.to_string() + "/" + &name;
416                            res_tests.push((test_path, input));
417                        }
418                    },
419                }
420            }
421            if !res_tests.is_empty() {
422                for (path, input) in res_tests {
423                    self.run_for_input(&path, &input);
424                }
425            } else {
426                // parsing both as a test and as a batch failed
427                self.parse_error(path);
428            }
429        }
430    }
431
432    pub fn run_for_file(&mut self, path: &str) {
433        match self.env().unwrap().read_file(path) {
434            None => self.read_error(path),
435            Some(input) => self.run_for_input(path, &input),
436        }
437    }
438
439    pub fn run_foreach_in_dir(&mut self, dir: &str) {
440        let full_dir = PathBuf::from(&self.root_dir).join(dir);
441        let starts_with_underscore = |entry: &DirEntry| {
442            if let Some(last) = entry.path().iter().next_back() {
443                if let Some(last) = last.to_str() {
444                    if last.starts_with('_') {
445                        return true;
446                    }
447                }
448            }
449            false
450        };
451        match full_dir.to_str() {
452            None => self.read_error(dir),
453            Some(full_dir) => match fs::read_dir(full_dir) {
454                Err(_) => self.read_error(full_dir),
455                Ok(paths) => {
456                    paths.flatten().for_each(|entry| {
457                        // ignore path components starting with '_'
458                        if starts_with_underscore(&entry) {
459                            return;
460                        }
461                        if let Ok(kind) = entry.file_type() {
462                            let path = format!("{}", entry.path().display());
463                            let rel_path = self.env().unwrap().rel_path(path).unwrap();
464                            if kind.is_file() || kind.is_symlink() {
465                                if rel_path.ends_with(".json") {
466                                    self.run_for_file(&rel_path);
467                                }
468                            } else if kind.is_dir() {
469                                self.run_foreach_in_dir(&rel_path);
470                            }
471                        }
472                    });
473                },
474            },
475        }
476    }
477
478    pub fn finalize(&mut self) {
479        let env = self.output_env().unwrap();
480        env.write_file("report", "");
481        let print = |msg: &str| {
482            env.logln_to(msg, "report");
483        };
484        let mut do_panic = false;
485
486        print(&format!(
487            "\n====== Report for '{}' tester run ======",
488            &self.name
489        ));
490        for name in self.results.keys() {
491            if name.is_empty() {
492                continue;
493            }
494            print(&format!("\nResults for '{name}'"));
495            let tests = self.successful_tests(name);
496            if !tests.is_empty() {
497                print("  Successful tests:  ");
498                for path in tests {
499                    print(&format!("    {path}"));
500                    if let Some(logs) = env.read_file(path + "/log") {
501                        print(&logs)
502                    }
503                }
504            }
505            let tests = self.failed_tests(name);
506            if !tests.is_empty() {
507                do_panic = true;
508                print("  Failed tests:  ");
509                for (path, message, location) in tests {
510                    print(&format!("    {path}, '{message}', {location}"));
511                    if let Some(logs) = env.read_file(path + "/log") {
512                        print(&logs)
513                    }
514                }
515            }
516        }
517        let tests = self.unreadable_tests();
518        if !tests.is_empty() {
519            do_panic = true;
520            print("\nUnreadable tests:  ");
521            for path in tests {
522                print(&format!("  {path}"))
523            }
524        }
525        let tests = self.unparseable_tests();
526        if !tests.is_empty() {
527            do_panic = true;
528            print("\nUnparseable tests:  ");
529            for path in tests {
530                print(&format!("  {path}"))
531            }
532        }
533        print(&format!(
534            "\n====== End of report for '{}' tester run ======\n",
535            &self.name
536        ));
537        if do_panic {
538            panic!("Some tests failed or could not be read/parsed");
539        }
540    }
541}