//! Tests for custom preprocessors. use crate::book_test::list_all_files; use crate::prelude::*; use anyhow::Result; use mdbook_core::book::{Book, BookItem, Chapter}; use mdbook_driver::builtin_preprocessors::CmdPreprocessor; use mdbook_preprocessor::{Preprocessor, PreprocessorContext}; use snapbox::IntoData; use std::sync::{Arc, Mutex}; struct Spy(Arc>); #[derive(Debug, Default)] struct Inner { run_count: usize, rendered_with: Vec, } impl Preprocessor for Spy { fn name(&self) -> &str { "dummy" } fn run(&self, ctx: &PreprocessorContext, book: Book) -> Result { let mut inner = self.0.lock().unwrap(); inner.run_count += 1; inner.rendered_with.push(ctx.renderer.clone()); Ok(book) } } // Test that preprocessor gets run. #[test] fn runs_preprocessors() { let test = BookTest::init(|_| {}); let spy: Arc> = Default::default(); let mut book = test.load_book(); book.with_preprocessor(Spy(Arc::clone(&spy))); book.build().unwrap(); let inner = spy.lock().unwrap(); assert_eq!(inner.run_count, 1); assert_eq!(inner.rendered_with, ["html"]); } // Run tests with a custom preprocessor. #[test] fn test_with_custom_preprocessor() { let test = BookTest::init(|_| {}); let spy: Arc> = Default::default(); let mut book = test.load_book(); book.with_preprocessor(Spy(Arc::clone(&spy))); book.test(vec![]).unwrap(); let inner = spy.lock().unwrap(); assert_eq!(inner.run_count, 1); assert_eq!(inner.rendered_with, ["test"]); } // No-op preprocessor works. #[test] fn nop_preprocessor() { BookTest::from_dir("preprocessor/nop_preprocessor").run("build", |cmd| { cmd.expect_stdout(str![[""]]).expect_stderr(str![[r#" INFO Book building has started INFO Running the html backend INFO HTML book written to `[ROOT]/book` "#]]); }); } // Failing preprocessor generates an error. #[test] fn failing_preprocessor() { BookTest::from_dir("preprocessor/failing_preprocessor").run("build", |cmd| { cmd.expect_failure() .expect_stdout(str![[""]]) .expect_stderr(str![[r#" INFO Book building has started Boom!!1! ERROR The "nop-preprocessor" preprocessor exited unsuccessfully with [EXIT_STATUS]: 1 status "#]]); }); } fn example() -> CmdPreprocessor { CmdPreprocessor::new( "nop-preprocessor".to_string(), "cargo run --quiet --example nop-preprocessor --".to_string(), std::env::current_dir().unwrap(), false, ) } #[test] fn example_supports_whatever() { let cmd = example(); let got = cmd.supports_renderer("whatever").unwrap(); assert_eq!(got, true); } #[test] fn example_doesnt_support_not_supported() { let cmd = example(); let got = cmd.supports_renderer("not-supported").unwrap(); assert_eq!(got, false); } // Checks the behavior of a relative path to a preprocessor. #[test] fn relative_command_path() { let mut test = BookTest::init(|_| {}); test.rust_program( "preprocessors/my-preprocessor", r#" fn main() { let mut args = std::env::args().skip(1); if args.next().as_deref() == Some("supports") { std::fs::write("support-check", args.next().unwrap()).unwrap(); return; } use std::io::Read; let mut s = String::new(); std::io::stdin().read_to_string(&mut s).unwrap(); std::fs::write("preprocessor-ran", "test").unwrap(); println!("{{\"items\": []}}"); } "#, ) .change_file( "book.toml", "[preprocessor.my-preprocessor]\n\ command = 'preprocessors/my-preprocessor'\n", ) .run("build", |cmd| { cmd.expect_stdout(str![""]).expect_stderr(str![[r#" INFO Book building has started INFO Running the html backend INFO HTML book written to `[ROOT]/book` "#]]); }) .check_file("support-check", "html") .check_file("preprocessor-ran", "test") // Try again, but outside of the book root to check relative path behavior. .rm_r("support-check") .rm_r("preprocessor-ran") .run("build ..", |cmd| { cmd.current_dir(cmd.dir.join("src")) .expect_stdout(str![""]) .expect_stderr(str![[r#" INFO Book building has started INFO Running the html backend INFO HTML book written to `[ROOT]/src/../book` "#]]); }) .check_file("support-check", "html") .check_file("preprocessor-ran", "test"); } // Preprocessor command is missing. #[test] fn missing_preprocessor() { BookTest::from_dir("preprocessor/missing_preprocessor").run("build", |cmd| { cmd.expect_failure() .expect_stdout(str![[""]]) .expect_stderr(str![[r#" INFO Book building has started ERROR The command `trduyvbhijnorgevfuhn` wasn't found, is the `missing` preprocessor installed? If you want to ignore this error when the `missing` preprocessor is not installed, set `optional = true` in the `[preprocessor.missing]` section of the book.toml configuration file. ERROR Unable to run the preprocessor `missing` [TAB]Caused by: [NOT_FOUND] "#]]); }); } // Optional missing is not an error. #[test] fn missing_optional_not_fatal() { BookTest::from_dir("preprocessor/missing_optional_not_fatal").run("build", |cmd| { cmd.expect_stdout(str![[""]]).expect_stderr(str![[r#" INFO Book building has started WARN The command `trduyvbhijnorgevfuhn` for preprocessor `missing` was not found, but is marked as optional. INFO Running the html backend INFO HTML book written to `[ROOT]/book` "#]]); }); } // with_preprocessor of an existing name. #[test] fn with_preprocessor_same_name() { let mut test = BookTest::init(|_| {}); test.change_file( "book.toml", "[preprocessor.dummy]\n\ command = 'mdbook-preprocessor-does-not-exist'\n", ); let spy: Arc> = Default::default(); let mut book = test.load_book(); book.with_preprocessor(Spy(Arc::clone(&spy))); // Unfortunately this is unable to capture the output when using the API. book.build().unwrap(); let inner = spy.lock().unwrap(); assert_eq!(inner.run_count, 1); assert_eq!(inner.rendered_with, ["html"]); } // Checks that the interface stays backwards compatible. The interface here // should not be changed to fix a compatibility issue unless there is a // major-semver version update to mdbook. // // Note that this tests both preprocessors and renderers. It's in this module // for lack of a better location. #[test] fn extension_compatibility() { // This is here to force you to look at this test if you alter any of // these types such as adding new fields/variants. This test should be // updated accordingly. For example, new `BookItem` variants should be // added to the extension_compatibility book, or new fields should be // added to the expected input/output. This is also a check that these // should only be changed in a semver-breaking release let chapter = Chapter { name: "example".to_string(), content: "content".to_string(), number: None, sub_items: Vec::new(), path: None, source_path: None, parent_names: Vec::new(), }; let item = BookItem::Chapter(chapter); match &item { BookItem::Chapter(_) => {} BookItem::Separator => {} BookItem::PartTitle(_) => {} } let items = vec![item]; let _book = Book { items }; let mut test = BookTest::from_dir("preprocessor/extension_compatibility"); // Run it once with the preprocessor disabled so that we can verify // that the built book is identical with the preprocessor enabled. test.run("build", |cmd| { cmd.expect_stdout(str![[""]]).expect_stderr(str![[r#" INFO Book building has started WARN The command `./my-preprocessor` for preprocessor `my-preprocessor` was not found, but is marked as optional. INFO Running the html backend INFO HTML book written to `[ROOT]/book/html` WARN The command `./my-preprocessor` for preprocessor `my-preprocessor` was not found, but is marked as optional. INFO Running the my-renderer backend INFO Invoking the "my-renderer" renderer WARN The command `./my-renderer` for backend `my-renderer` was not found, but is marked as optional. "#]]); }); let orig_dir = test.dir.join("book.orig"); let pre_dir = test.dir.join("book"); std::fs::rename(&pre_dir, &orig_dir).unwrap(); // **CAUTION** DO NOT modify this value unless this is a major-semver change. let book_output = serde_json::json!({ "items": [ { "Chapter": { "content": "# Prefix chapter\n", "name": "Prefix chapter", "number": null, "parent_names": [], "path": "prefix.md", "source_path": "prefix.md", "sub_items": [] } }, { "Chapter": { "content": "# Chapter 1\n", "name": "Chapter 1", "number": [ 1 ], "parent_names": [], "path": "chapter_1.md", "source_path": "chapter_1.md", "sub_items": [] } }, { "Chapter": { "content": "", "name": "Draft chapter", "number": [ 2 ], "parent_names": [], "path": null, "source_path": null, "sub_items": [] } }, { "PartTitle": "Part title" }, { "Chapter": { "content": "# Part chapter\n", "name": "Part chapter", "number": [ 3 ], "parent_names": [], "path": "part/chapter.md", "source_path": "part/chapter.md", "sub_items": [ { "Chapter": { "content": "# Part sub chapter\n", "name": "Part sub chapter", "number": [ 3, 1 ], "parent_names": [ "Part chapter" ], "path": "part/sub-chapter.md", "source_path": "part/sub-chapter.md", "sub_items": [] } } ] } }, "Separator", { "Chapter": { "content": "# Suffix chapter\n", "name": "Suffix chapter", "number": null, "parent_names": [], "path": "suffix.md", "source_path": "suffix.md", "sub_items": [] } } ] }); let output_str = serde_json::to_string(&book_output).unwrap(); // **CAUTION** The only updates allowed here in a semver-compatible // release is to add new fields. let expected_config = serde_json::json!({ "book": { "authors": [], "description": null, "language": "en", "text-direction": null, "title": "extension_compatibility" }, "output": { "html": {}, "my-renderer": { "command": "./my-renderer", "custom-config": "renderer settings", "custom-table": { "extra": "xyz" }, "optional": true } }, "preprocessor": { "my-preprocessor": { "command": "./my-preprocessor", "custom-config": true, "custom-table": { "extra": "abc" }, "optional": true } } }); // **CAUTION** The only updates allowed here in a semver-compatible // release is to add new fields. The output should not change. let expected_preprocessor_input = serde_json::json!([ { "config": expected_config, "mdbook_version": "[VERSION]", "renderer": "html", "root": "[ROOT]" }, book_output ]); let expected_renderer_input = serde_json::json!( { "version": "[VERSION]", "root": "[ROOT]", "book": book_output, "config": expected_config, "destination": "[ROOT]/book/my-renderer", } ); // This preprocessor writes its input to some files, and writes the // hard-coded output specified above. test.rust_program( "my-preprocessor", &r###" use std::fs::OpenOptions; use std::io::{Read, Write}; fn main() { let mut args = std::env::args().skip(1); if args.next().as_deref() == Some("supports") { let mut file = OpenOptions::new() .create(true) .append(true) .open("support-check") .unwrap(); let renderer = args.next().unwrap(); writeln!(file, "{renderer}").unwrap(); if renderer != "html" { std::process::exit(1); } return; } let mut s = String::new(); std::io::stdin().read_to_string(&mut s).unwrap(); std::fs::write("preprocessor-input", &s).unwrap(); let output = r##"OUTPUT_REPLACE"##; println!("{output}"); } "### .replace("OUTPUT_REPLACE", &output_str), ) // This renderer writes its input to a file. .rust_program( "my-renderer", &r#" fn main() { use std::io::Read; let mut s = String::new(); std::io::stdin().read_to_string(&mut s).unwrap(); std::fs::write("renderer-input", &s).unwrap(); } "#, ) .run("build", |cmd| { cmd.expect_stdout(str![[""]]).expect_stderr(str![[r#" INFO Book building has started INFO Running the html backend INFO HTML book written to `[ROOT]/book/html` INFO Running the my-renderer backend INFO Invoking the "my-renderer" renderer "#]]); }) .check_file("support-check", "html\nmy-renderer\n") .check_file( "preprocessor-input", serde_json::to_string(&expected_preprocessor_input) .unwrap() .is_json(), ) .check_file( "book/my-renderer/renderer-input", serde_json::to_string(&expected_renderer_input) .unwrap() .is_json(), ); // Verify both directories have the exact same output. test.rm_r("book/my-renderer/renderer-input"); let orig_files = list_all_files(&orig_dir); let pre_files = list_all_files(&pre_dir); assert_eq!(orig_files, pre_files); for file in &orig_files { let orig_path = orig_dir.join(file); if orig_path.is_file() { let orig = std::fs::read(&orig_path).unwrap(); let pre = std::fs::read(&pre_dir.join(file)).unwrap(); test.assert.eq(pre, orig); } } }