Skip to content

Commit 5eaae38

Browse files
committed
Add a test for extension compatibility
This adds a test to ensure that the interface for preprocessors and renderers does not change unexpectedly, particularly in a semver compatible release. Closes #1574
1 parent be63b44 commit 5eaae38

File tree

9 files changed

+331
-1
lines changed

9 files changed

+331
-1
lines changed

tests/testsuite/book_test.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,3 +533,18 @@ pub fn glob_one<P: AsRef<Path>>(path: P, pattern: &str) -> PathBuf {
533533
}
534534
first
535535
}
536+
537+
/// Lists all files at the given directory.
538+
///
539+
/// Recursively walks the tree. Paths are relative to the directory.
540+
pub fn list_all_files(dir: &Path) -> Vec<PathBuf> {
541+
walkdir::WalkDir::new(dir)
542+
.sort_by_file_name()
543+
.into_iter()
544+
.map(|entry| {
545+
let entry = entry.unwrap();
546+
let path = entry.path();
547+
path.strip_prefix(dir).unwrap().to_path_buf()
548+
})
549+
.collect()
550+
}

tests/testsuite/preprocessor.rs

Lines changed: 278 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
//! Tests for custom preprocessors.
22
3+
use crate::book_test::list_all_files;
34
use crate::prelude::*;
45
use anyhow::Result;
5-
use mdbook_core::book::Book;
6+
use mdbook_core::book::{Book, BookItem, Chapter};
67
use mdbook_driver::builtin_preprocessors::CmdPreprocessor;
78
use mdbook_preprocessor::{Preprocessor, PreprocessorContext};
9+
use snapbox::IntoData;
810
use std::sync::{Arc, Mutex};
911

1012
struct Spy(Arc<Mutex<Inner>>);
@@ -199,3 +201,278 @@ fn with_preprocessor_same_name() {
199201
assert_eq!(inner.run_count, 1);
200202
assert_eq!(inner.rendered_with, ["html"]);
201203
}
204+
205+
// Checks that the interface stays backwards compatible. The interface here
206+
// should not be changed to fix a compatibility issue unless there is a
207+
// major-semver version update to mdbook.
208+
//
209+
// Note that this tests both preprocessors and renderers. It's in this module
210+
// for lack of a better location.
211+
#[test]
212+
fn extension_compatibility() {
213+
// This is here to force you to look at this test if you alter any of
214+
// these types such as adding new fields/variants. This test should be
215+
// updated accordingly. For example, new `BookItem` variants should be
216+
// added to the extension_compatibility book, or new fields should be
217+
// added to the expected input/output. This is also a check that these
218+
// should only be changed in a semver-breaking release
219+
let chapter = Chapter {
220+
name: "example".to_string(),
221+
content: "content".to_string(),
222+
number: None,
223+
sub_items: Vec::new(),
224+
path: None,
225+
source_path: None,
226+
parent_names: Vec::new(),
227+
};
228+
let item = BookItem::Chapter(chapter);
229+
match &item {
230+
BookItem::Chapter(_) => {}
231+
BookItem::Separator => {}
232+
BookItem::PartTitle(_) => {}
233+
}
234+
let items = vec![item];
235+
let _book = Book { items };
236+
237+
let mut test = BookTest::from_dir("preprocessor/extension_compatibility");
238+
// Run it once with the preprocessor disabled so that we can verify
239+
// that the built book is identical with the preprocessor enabled.
240+
test.run("build", |cmd| {
241+
cmd.expect_stdout(str![[""]]).expect_stderr(str![[r#"
242+
[TIMESTAMP] [INFO] (mdbook_driver::mdbook): Book building has started
243+
[TIMESTAMP] [WARN] (mdbook_driver): The command `./my-preprocessor` for preprocessor `my-preprocessor` was not found, but is marked as optional.
244+
[TIMESTAMP] [INFO] (mdbook_driver::mdbook): Running the html backend
245+
[TIMESTAMP] [INFO] (mdbook_html::html_handlebars::hbs_renderer): HTML book written to `[ROOT]/book/html`
246+
[TIMESTAMP] [WARN] (mdbook_driver): The command `./my-preprocessor` for preprocessor `my-preprocessor` was not found, but is marked as optional.
247+
[TIMESTAMP] [INFO] (mdbook_driver::mdbook): Running the my-renderer backend
248+
[TIMESTAMP] [INFO] (mdbook_driver::builtin_renderers): Invoking the "my-renderer" renderer
249+
[TIMESTAMP] [WARN] (mdbook_driver): The command `./my-renderer` for backend `my-renderer` was not found, but is marked as optional.
250+
251+
"#]]);
252+
});
253+
let orig_dir = test.dir.join("book.orig");
254+
let pre_dir = test.dir.join("book");
255+
std::fs::rename(&pre_dir, &orig_dir).unwrap();
256+
257+
// **CAUTION** DO NOT modify this value unless this is a major-semver change.
258+
let book_output = serde_json::json!({
259+
"items": [
260+
{
261+
"Chapter": {
262+
"content": "# Prefix chapter\n",
263+
"name": "Prefix chapter",
264+
"number": null,
265+
"parent_names": [],
266+
"path": "prefix.md",
267+
"source_path": "prefix.md",
268+
"sub_items": []
269+
}
270+
},
271+
{
272+
"Chapter": {
273+
"content": "# Chapter 1\n",
274+
"name": "Chapter 1",
275+
"number": [
276+
1
277+
],
278+
"parent_names": [],
279+
"path": "chapter_1.md",
280+
"source_path": "chapter_1.md",
281+
"sub_items": []
282+
}
283+
},
284+
{
285+
"Chapter": {
286+
"content": "",
287+
"name": "Draft chapter",
288+
"number": [
289+
2
290+
],
291+
"parent_names": [],
292+
"path": null,
293+
"source_path": null,
294+
"sub_items": []
295+
}
296+
},
297+
{
298+
"PartTitle": "Part title"
299+
},
300+
{
301+
"Chapter": {
302+
"content": "# Part chapter\n",
303+
"name": "Part chapter",
304+
"number": [
305+
3
306+
],
307+
"parent_names": [],
308+
"path": "part/chapter.md",
309+
"source_path": "part/chapter.md",
310+
"sub_items": [
311+
{
312+
"Chapter": {
313+
"content": "# Part sub chapter\n",
314+
"name": "Part sub chapter",
315+
"number": [
316+
3,
317+
1
318+
],
319+
"parent_names": [
320+
"Part chapter"
321+
],
322+
"path": "part/sub-chapter.md",
323+
"source_path": "part/sub-chapter.md",
324+
"sub_items": []
325+
}
326+
}
327+
]
328+
}
329+
},
330+
"Separator",
331+
{
332+
"Chapter": {
333+
"content": "# Suffix chapter\n",
334+
"name": "Suffix chapter",
335+
"number": null,
336+
"parent_names": [],
337+
"path": "suffix.md",
338+
"source_path": "suffix.md",
339+
"sub_items": []
340+
}
341+
}
342+
]
343+
});
344+
let output_str = serde_json::to_string(&book_output).unwrap();
345+
// **CAUTION** The only updates allowed here in a semver-compatible
346+
// release is to add new fields.
347+
let expected_config = serde_json::json!({
348+
"book": {
349+
"authors": [],
350+
"description": null,
351+
"language": "en",
352+
"text-direction": null,
353+
"title": "extension_compatibility"
354+
},
355+
"output": {
356+
"html": {},
357+
"my-renderer": {
358+
"command": "./my-renderer",
359+
"custom-config": "renderer settings",
360+
"custom-table": {
361+
"extra": "xyz"
362+
},
363+
"optional": true
364+
}
365+
},
366+
"preprocessor": {
367+
"my-preprocessor": {
368+
"command": "./my-preprocessor",
369+
"custom-config": true,
370+
"custom-table": {
371+
"extra": "abc"
372+
},
373+
"optional": true
374+
}
375+
}
376+
});
377+
378+
// **CAUTION** The only updates allowed here in a semver-compatible
379+
// release is to add new fields. The output should not change.
380+
let expected_preprocessor_input = serde_json::json!([
381+
{
382+
"config": expected_config,
383+
"mdbook_version": "[VERSION]",
384+
"renderer": "html",
385+
"root": "[ROOT]"
386+
},
387+
book_output
388+
]);
389+
let expected_renderer_input = serde_json::json!(
390+
{
391+
"version": "[VERSION]",
392+
"root": "[ROOT]",
393+
"book": book_output,
394+
"config": expected_config,
395+
"destination": "[ROOT]/book/my-renderer",
396+
}
397+
);
398+
399+
// This preprocessor writes its input to some files, and writes the
400+
// hard-coded output specified above.
401+
test.rust_program(
402+
"my-preprocessor",
403+
&r###"
404+
use std::fs::OpenOptions;
405+
use std::io::{Read, Write};
406+
fn main() {
407+
let mut args = std::env::args().skip(1);
408+
if args.next().as_deref() == Some("supports") {
409+
let mut file = OpenOptions::new()
410+
.create(true)
411+
.append(true)
412+
.open("support-check")
413+
.unwrap();
414+
let renderer = args.next().unwrap();
415+
writeln!(file, "{renderer}").unwrap();
416+
if renderer != "html" {
417+
std::process::exit(1);
418+
}
419+
return;
420+
}
421+
let mut s = String::new();
422+
std::io::stdin().read_to_string(&mut s).unwrap();
423+
std::fs::write("preprocessor-input", &s).unwrap();
424+
let output = r##"OUTPUT_REPLACE"##;
425+
println!("{output}");
426+
}
427+
"###
428+
.replace("OUTPUT_REPLACE", &output_str),
429+
)
430+
// This renderer writes its input to a file.
431+
.rust_program(
432+
"my-renderer",
433+
&r#"
434+
fn main() {
435+
use std::io::Read;
436+
let mut s = String::new();
437+
std::io::stdin().read_to_string(&mut s).unwrap();
438+
std::fs::write("renderer-input", &s).unwrap();
439+
}
440+
"#,
441+
)
442+
.run("build", |cmd| {
443+
cmd.expect_stdout(str![[""]]).expect_stderr(str![[r#"
444+
[TIMESTAMP] [INFO] (mdbook_driver::mdbook): Book building has started
445+
[TIMESTAMP] [INFO] (mdbook_driver::mdbook): Running the html backend
446+
[TIMESTAMP] [INFO] (mdbook_html::html_handlebars::hbs_renderer): HTML book written to `[ROOT]/book/html`
447+
[TIMESTAMP] [INFO] (mdbook_driver::mdbook): Running the my-renderer backend
448+
[TIMESTAMP] [INFO] (mdbook_driver::builtin_renderers): Invoking the "my-renderer" renderer
449+
450+
"#]]);
451+
})
452+
.check_file("support-check", "html\nmy-renderer\n")
453+
.check_file(
454+
"preprocessor-input",
455+
serde_json::to_string(&expected_preprocessor_input)
456+
.unwrap()
457+
.is_json(),
458+
)
459+
.check_file(
460+
"book/my-renderer/renderer-input",
461+
serde_json::to_string(&expected_renderer_input)
462+
.unwrap()
463+
.is_json(),
464+
);
465+
// Verify both directories have the exact same output.
466+
test.rm_r("book/my-renderer/renderer-input");
467+
let orig_files = list_all_files(&orig_dir);
468+
let pre_files = list_all_files(&pre_dir);
469+
assert_eq!(orig_files, pre_files);
470+
for file in &orig_files {
471+
let orig_path = orig_dir.join(file);
472+
if orig_path.is_file() {
473+
let orig = std::fs::read(&orig_path).unwrap();
474+
let pre = std::fs::read(&pre_dir.join(file)).unwrap();
475+
test.assert.eq(pre, orig);
476+
}
477+
}
478+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[book]
2+
title = "extension_compatibility"
3+
4+
[preprocessor.my-preprocessor]
5+
command = "./my-preprocessor"
6+
custom-config = true
7+
optional = true
8+
[preprocessor.my-preprocessor.custom-table]
9+
extra = "abc"
10+
11+
[output.html]
12+
13+
[output.my-renderer]
14+
command = "./my-renderer"
15+
custom-config = "renderer settings"
16+
optional = true
17+
[output.my-renderer.custom-table]
18+
extra = "xyz"
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Summary
2+
3+
[Prefix chapter](./prefix.md)
4+
5+
- [Chapter 1](./chapter_1.md)
6+
- [Draft chapter]()
7+
8+
# Part title
9+
10+
- [Part chapter](./part/chapter.md)
11+
- [Part sub chapter](./part/sub-chapter.md)
12+
13+
---
14+
15+
[Suffix chapter](./suffix.md)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Chapter 1
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Part chapter
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Part sub chapter
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Prefix chapter
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Suffix chapter

0 commit comments

Comments
 (0)