From d56ff94ce60a1a53ad631300962959c8af054b5e Mon Sep 17 00:00:00 2001 From: Michael Bryan Date: Thu, 16 Nov 2017 15:51:12 +0800 Subject: [PATCH] Regression tests (#422) * Created regression tests for the table of contents * Refactoring to make the test more readable * Fixed some bitrot and removed the (now redundant) tests/helper module * Removed the include_str!() stuff and use just the dummy book for testing * Regression tests now pass again! * Pinned a `*` dependency to use a particular version * Made sure test mocks return errors instead of panicking * Addressed the rest of @budziq's review * Replaced a file open/read with file_to_string --- Cargo.toml | 5 + tests/dummy/mod.rs | 87 ----------- tests/dummy_book/mod.rs | 116 +++++++++++++++ .../{dummy/book => dummy_book/src}/SUMMARY.md | 1 + .../book => dummy_book/src}/conclusion.md | 0 .../book => dummy_book/src}/first/index.md | 0 .../book => dummy_book/src}/first/nested.md | 0 tests/{dummy/book => dummy_book/src}/intro.md | 0 .../{dummy/book => dummy_book/src}/second.md | 0 tests/helpers/mod.rs | 27 ---- tests/rendered_output.rs | 138 ++++++++++++++++-- tests/testing.rs | 9 +- 12 files changed, 253 insertions(+), 130 deletions(-) delete mode 100644 tests/dummy/mod.rs create mode 100644 tests/dummy_book/mod.rs rename tests/{dummy/book => dummy_book/src}/SUMMARY.md (97%) rename tests/{dummy/book => dummy_book/src}/conclusion.md (100%) rename tests/{dummy/book => dummy_book/src}/first/index.md (100%) rename tests/{dummy/book => dummy_book/src}/first/nested.md (100%) rename tests/{dummy/book => dummy_book/src}/intro.md (100%) rename tests/{dummy/book => dummy_book/src}/second.md (100%) delete mode 100644 tests/helpers/mod.rs diff --git a/Cargo.toml b/Cargo.toml index 0526a4ca..d47d8fe4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,11 @@ ws = { version = "0.7", optional = true} [build-dependencies] error-chain = "0.11" +[dev-dependencies] +select = "0.4" +pretty_assertions = "0.4" +walkdir = "1.0" + [features] default = ["output", "watch", "serve"] debug = [] diff --git a/tests/dummy/mod.rs b/tests/dummy/mod.rs deleted file mode 100644 index 81a8896d..00000000 --- a/tests/dummy/mod.rs +++ /dev/null @@ -1,87 +0,0 @@ -//! This will create an entire book in a temporary directory using some -//! dummy contents from the `tests/dummy-book/` directory. - -// Not all features are used in all test crates, so... -#![allow(dead_code, unused_extern_crates)] - -extern crate tempdir; - -use std::fs::{create_dir_all, File}; -use std::io::Write; - -use tempdir::TempDir; - - -const SUMMARY_MD: &'static str = include_str!("book/SUMMARY.md"); -const INTRO: &'static str = include_str!("book/intro.md"); -const FIRST: &'static str = include_str!("book/first/index.md"); -const NESTED: &'static str = include_str!("book/first/nested.md"); -const SECOND: &'static str = include_str!("book/second.md"); -const CONCLUSION: &'static str = include_str!("book/conclusion.md"); - - -/// Create a dummy book in a temporary directory, using the contents of -/// `SUMMARY_MD` as a guide. -/// -/// The "Nested Chapter" file contains a code block with a single -/// `assert!($TEST_STATUS)`. If you want to check MDBook's testing -/// functionality, `$TEST_STATUS` can be substitute for either `true` or -/// `false`. This is done using the `passing_test` parameter. -#[derive(Clone, Debug, PartialEq)] -pub struct DummyBook { - passing_test: bool, -} - -impl DummyBook { - /// Create a new `DummyBook` with all the defaults. - pub fn new() -> DummyBook { - DummyBook::default() - } - - /// Whether the doc-test included in the "Nested Chapter" should pass or - /// fail (it passes by default). - pub fn with_passing_test(&mut self, test_passes: bool) -> &mut Self { - self.passing_test = test_passes; - self - } - - /// Write a book to a temporary directory using the provided settings. - /// - /// # Note - /// - /// If this fails for any reason it will `panic!()`. If we can't write to a - /// temporary directory then chances are you've got bigger problems... - pub fn build(&self) -> TempDir { - let temp = TempDir::new("dummy_book").unwrap(); - - let src = temp.path().join("src"); - create_dir_all(&src).unwrap(); - - let first = src.join("first"); - create_dir_all(&first).unwrap(); - - let to_substitute = if self.passing_test { "true" } else { "false" }; - let nested_text = NESTED.replace("$TEST_STATUS", to_substitute); - - let inputs = vec![(src.join("SUMMARY.md"), SUMMARY_MD), - (src.join("intro.md"), INTRO), - (first.join("index.md"), FIRST), - (first.join("nested.md"), &nested_text), - (src.join("second.md"), SECOND), - (src.join("conclusion.md"), CONCLUSION)]; - - for (path, content) in inputs { - File::create(path).unwrap() - .write_all(content.as_bytes()) - .unwrap(); - } - - temp - } -} - -impl Default for DummyBook { - fn default() -> DummyBook { - DummyBook { passing_test: true } - } -} diff --git a/tests/dummy_book/mod.rs b/tests/dummy_book/mod.rs new file mode 100644 index 00000000..fe3b547b --- /dev/null +++ b/tests/dummy_book/mod.rs @@ -0,0 +1,116 @@ +//! This will create an entire book in a temporary directory using some +//! dummy contents from the `tests/dummy-book/` directory. + +// Not all features are used in all test crates, so... +#![allow(dead_code, unused_variables, unused_imports, unused_extern_crates)] +extern crate mdbook; +extern crate tempdir; +extern crate walkdir; + +use std::path::Path; +use std::fs::{self, File}; +use std::io::{Read, Write}; +use mdbook::errors::*; +use mdbook::utils::fs::file_to_string; + +// The funny `self::` here is because we've got an `extern crate ...` and are +// in a submodule +use self::tempdir::TempDir; +use self::mdbook::MDBook; +use self::walkdir::WalkDir; + + +/// Create a dummy book in a temporary directory, using the contents of +/// `SUMMARY_MD` as a guide. +/// +/// The "Nested Chapter" file contains a code block with a single +/// `assert!($TEST_STATUS)`. If you want to check MDBook's testing +/// functionality, `$TEST_STATUS` can be substitute for either `true` or +/// `false`. This is done using the `passing_test` parameter. +#[derive(Clone, Debug, PartialEq)] +pub struct DummyBook { + passing_test: bool, +} + +impl DummyBook { + /// Create a new `DummyBook` with all the defaults. + pub fn new() -> DummyBook { + DummyBook { passing_test: true } + } + + /// Whether the doc-test included in the "Nested Chapter" should pass or + /// fail (it passes by default). + pub fn with_passing_test(&mut self, test_passes: bool) -> &mut DummyBook { + self.passing_test = test_passes; + self + } + + /// Write a book to a temporary directory using the provided settings. + pub fn build(&self) -> Result { + let temp = TempDir::new("dummy_book").chain_err(|| "Unable to create temp directory")?; + + let dummy_book_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/dummy_book"); + recursive_copy(&dummy_book_root, temp.path()).chain_err(|| { + "Couldn't copy files into a \ + temporary directory" + })?; + + let sub_pattern = if self.passing_test { "true" } else { "false" }; + let file_containing_test = temp.path().join("src/first/nested.md"); + replace_pattern_in_file(&file_containing_test, "$TEST_STATUS", sub_pattern)?; + + Ok(temp) + } +} + +fn replace_pattern_in_file(filename: &Path, from: &str, to: &str) -> Result<()> { + let contents = file_to_string(filename)?; + File::create(filename)?.write_all(contents.replace(from, to).as_bytes())?; + + Ok(()) +} + +/// Read the contents of the provided file into memory and then iterate through +/// the list of strings asserting that the file contains all of them. +pub fn assert_contains_strings>(filename: P, strings: &[&str]) { + let filename = filename.as_ref(); + let content = file_to_string(filename).expect("Couldn't read the file's contents"); + + for s in strings { + assert!(content.contains(s), + "Searching for {:?} in {}\n\n{}", + s, + filename.display(), + content); + } +} + + + +/// Recursively copy an entire directory tree to somewhere else (a la `cp -r`). +fn recursive_copy, B: AsRef>(from: A, to: B) -> Result<()> { + let from = from.as_ref(); + let to = to.as_ref(); + + for entry in WalkDir::new(&from) { + let entry = entry.chain_err(|| "Unable to inspect directory entry")?; + + let original_location = entry.path(); + let relative = original_location.strip_prefix(&from) + .expect("`original_location` is inside the `from` \ + directory"); + let new_location = to.join(relative); + + if original_location.is_file() { + if let Some(parent) = new_location.parent() { + fs::create_dir_all(parent).chain_err(|| "Couldn't create directory")?; + } + + fs::copy(&original_location, &new_location).chain_err(|| { + "Unable to copy file contents" + })?; + } + } + + Ok(()) +} diff --git a/tests/dummy/book/SUMMARY.md b/tests/dummy_book/src/SUMMARY.md similarity index 97% rename from tests/dummy/book/SUMMARY.md rename to tests/dummy_book/src/SUMMARY.md index f5f3a4c4..24471f53 100644 --- a/tests/dummy/book/SUMMARY.md +++ b/tests/dummy_book/src/SUMMARY.md @@ -4,6 +4,7 @@ - [First Chapter](./first/index.md) - [Nested Chapter](./first/nested.md) +--- - [Second Chapter](./second.md) [Conclusion](./conclusion.md) diff --git a/tests/dummy/book/conclusion.md b/tests/dummy_book/src/conclusion.md similarity index 100% rename from tests/dummy/book/conclusion.md rename to tests/dummy_book/src/conclusion.md diff --git a/tests/dummy/book/first/index.md b/tests/dummy_book/src/first/index.md similarity index 100% rename from tests/dummy/book/first/index.md rename to tests/dummy_book/src/first/index.md diff --git a/tests/dummy/book/first/nested.md b/tests/dummy_book/src/first/nested.md similarity index 100% rename from tests/dummy/book/first/nested.md rename to tests/dummy_book/src/first/nested.md diff --git a/tests/dummy/book/intro.md b/tests/dummy_book/src/intro.md similarity index 100% rename from tests/dummy/book/intro.md rename to tests/dummy_book/src/intro.md diff --git a/tests/dummy/book/second.md b/tests/dummy_book/src/second.md similarity index 100% rename from tests/dummy/book/second.md rename to tests/dummy_book/src/second.md diff --git a/tests/helpers/mod.rs b/tests/helpers/mod.rs deleted file mode 100644 index 3d1cdcc2..00000000 --- a/tests/helpers/mod.rs +++ /dev/null @@ -1,27 +0,0 @@ -//! Helpers for tests which exercise the overall application, in particular -//! the `MDBook` initialization and build/rendering process. - - -use std::path::Path; -use std::fs::File; -use std::io::Read; - - -/// Read the contents of the provided file into memory and then iterate through -/// the list of strings asserting that the file contains all of them. -pub fn assert_contains_strings>(filename: P, strings: &[&str]) { - let filename = filename.as_ref(); - - let mut content = String::new(); - File::open(&filename).expect("Couldn't open the provided file") - .read_to_string(&mut content) - .expect("Couldn't read the file's contents"); - - for s in strings { - assert!(content.contains(s), - "Searching for {:?} in {}\n\n{}", - s, - filename.display(), - content); - } -} diff --git a/tests/rendered_output.rs b/tests/rendered_output.rs index eb76d056..fa2b155b 100644 --- a/tests/rendered_output.rs +++ b/tests/rendered_output.rs @@ -1,18 +1,34 @@ extern crate mdbook; -extern crate tempdir; +#[macro_use] +extern crate pretty_assertions; +extern crate select; +extern crate walkdir; -mod dummy; -mod helpers; +mod dummy_book; -use dummy::DummyBook; -use helpers::assert_contains_strings; +use dummy_book::{assert_contains_strings, DummyBook}; + +use std::path::Path; +use std::ffi::OsStr; +use walkdir::{DirEntry, WalkDir, WalkDirIterator}; +use select::document::Document; +use select::predicate::{Class, Name, Predicate}; +use mdbook::errors::*; +use mdbook::utils::fs::file_to_string; use mdbook::MDBook; +const BOOK_ROOT: &'static str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/dummy_book"); +const TOC_TOP_LEVEL: &[&'static str] = &["1. First Chapter", + "2. Second Chapter", + "Conclusion", + "Introduction"]; +const TOC_SECOND_LEVEL: &[&'static str] = &["1.1. Nested Chapter"]; + /// Make sure you can load the dummy book and build it without panicking. #[test] fn build_the_dummy_book() { - let temp = DummyBook::default().build(); + let temp = DummyBook::new().build().unwrap(); let mut md = MDBook::new(temp.path()); md.build().unwrap(); @@ -20,7 +36,7 @@ fn build_the_dummy_book() { #[test] fn by_default_mdbook_generates_rendered_content_in_the_book_directory() { - let temp = DummyBook::default().build(); + let temp = DummyBook::new().build().unwrap(); let mut md = MDBook::new(temp.path()); assert!(!temp.path().join("book").exists()); @@ -32,7 +48,7 @@ fn by_default_mdbook_generates_rendered_content_in_the_book_directory() { #[test] fn make_sure_bottom_level_files_contain_links_to_chapters() { - let temp = DummyBook::default().build(); + let temp = DummyBook::new().build().unwrap(); let mut md = MDBook::new(temp.path()); md.build().unwrap(); @@ -52,7 +68,7 @@ fn make_sure_bottom_level_files_contain_links_to_chapters() { #[test] fn check_correct_cross_links_in_nested_dir() { - let temp = DummyBook::default().build(); + let temp = DummyBook::new().build().unwrap(); let mut md = MDBook::new(temp.path()); md.build().unwrap(); @@ -79,7 +95,7 @@ fn check_correct_cross_links_in_nested_dir() { #[test] fn rendered_code_has_playpen_stuff() { - let temp = DummyBook::default().build(); + let temp = DummyBook::new().build().unwrap(); let mut md = MDBook::new(temp.path()); md.build().unwrap(); @@ -100,7 +116,7 @@ fn chapter_content_appears_in_rendered_document() { ("first/index.html", "more text"), ("conclusion.html", "Conclusion")]; - let temp = DummyBook::default().build(); + let temp = DummyBook::new().build().unwrap(); let mut md = MDBook::new(temp.path()); md.build().unwrap(); @@ -111,3 +127,103 @@ fn chapter_content_appears_in_rendered_document() { assert_contains_strings(path, &[text]); } } + + +/// Apply a series of predicates to some root predicate, where each +/// successive predicate is the descendant of the last one. Similar to how you +/// might do `ul.foo li a` in CSS to access all anchor tags in the `foo` list. +macro_rules! descendants { + ($root:expr, $($child:expr),*) => { + $root + $( + .descendant($child) + )* + }; +} + + +/// Make sure that all `*.md` files (excluding `SUMMARY.md`) were rendered +/// and placed in the `book` directory with their extensions set to `*.html`. +#[test] +fn chapter_files_were_rendered_to_html() { + let temp = DummyBook::new().build().unwrap(); + let src = Path::new(BOOK_ROOT).join("src"); + + let chapter_files = WalkDir::new(&src).into_iter() + .filter_entry(|entry| entry_ends_with(entry, ".md")) + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path().to_path_buf()) + .filter(|path| { + path.file_name().and_then(OsStr::to_str) + != Some("SUMMARY.md") + }); + + for chapter in chapter_files { + let rendered_location = temp.path().join(chapter.strip_prefix(&src).unwrap()) + .with_extension("html"); + assert!(rendered_location.exists(), + "{} doesn't exits", + rendered_location.display()); + } +} + +fn entry_ends_with(entry: &DirEntry, ending: &str) -> bool { + entry.file_name().to_string_lossy().ends_with(ending) +} + +/// Read the main page (`book/index.html`) and expose it as a DOM which we +/// can search with the `select` crate +fn root_index_html() -> Result { + let temp = DummyBook::new().build() + .chain_err(|| "Couldn't create the dummy book")?; + MDBook::new(temp.path()).build() + .chain_err(|| "Book building failed")?; + + let index_page = temp.path().join("book").join("index.html"); + let html = file_to_string(&index_page).chain_err(|| "Unable to read index.html")?; + + Ok(Document::from(html.as_str())) +} + +#[test] +fn check_second_toc_level() { + let doc = root_index_html().unwrap(); + let mut should_be = Vec::from(TOC_SECOND_LEVEL); + should_be.sort(); + + let pred = descendants!(Class("chapter"), Name("li"), Name("li"), Name("a")); + + let mut children_of_children: Vec<_> = + doc.find(pred).map(|elem| elem.text().trim().to_string()) + .collect(); + children_of_children.sort(); + + assert_eq!(children_of_children, should_be); +} + +#[test] +fn check_first_toc_level() { + let doc = root_index_html().unwrap(); + let mut should_be = Vec::from(TOC_TOP_LEVEL); + + should_be.extend(TOC_SECOND_LEVEL); + should_be.sort(); + + let pred = descendants!(Class("chapter"), Name("li"), Name("a")); + + let mut children: Vec<_> = doc.find(pred).map(|elem| elem.text().trim().to_string()) + .collect(); + children.sort(); + + assert_eq!(children, should_be); +} + +#[test] +fn check_spacers() { + let doc = root_index_html().unwrap(); + let should_be = 1; + + let num_spacers = + doc.find(Class("chapter").descendant(Name("li").and(Class("spacer")))).count(); + assert_eq!(num_spacers, should_be); +} diff --git a/tests/testing.rs b/tests/testing.rs index 57101336..50bddef2 100644 --- a/tests/testing.rs +++ b/tests/testing.rs @@ -1,15 +1,14 @@ extern crate mdbook; -extern crate tempdir; -mod dummy; +mod dummy_book; -use dummy::DummyBook; +use dummy_book::DummyBook; use mdbook::MDBook; #[test] fn mdbook_can_correctly_test_a_passing_book() { - let temp = DummyBook::default().with_passing_test(true).build(); + let temp = DummyBook::new().with_passing_test(true).build().unwrap(); let mut md = MDBook::new(temp.path()); assert!(md.test(vec![]).is_ok()); @@ -17,7 +16,7 @@ fn mdbook_can_correctly_test_a_passing_book() { #[test] fn mdbook_detects_book_with_failing_tests() { - let temp = DummyBook::default().with_passing_test(false).build(); + let temp = DummyBook::new().with_passing_test(false).build().unwrap(); let mut md: MDBook = MDBook::new(temp.path()); assert!(md.test(vec![]).is_err());