From df4e6715cf31817878d73286d2d4b1ea1b7afdbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20F=C3=A4rber?= <01mf02@gmail.com> Date: Fri, 20 Dec 2024 10:48:17 +0100 Subject: [PATCH] HTML tests (#5594) Co-authored-by: Laurenz --- CONTRIBUTING.md | 4 +- Cargo.lock | 1 + tests/.gitattributes | 1 + tests/Cargo.toml | 2 + tests/README.md | 68 +++--- tests/ref/html/link-basic.html | 21 ++ tests/src/args.rs | 4 +- tests/src/collect.rs | 60 +++-- tests/src/logger.rs | 14 +- tests/src/run.rs | 312 +++++++++++++++++--------- tests/src/tests.rs | 12 +- tests/src/world.rs | 6 +- tests/suite/layout/inline/justify.typ | 3 +- tests/suite/model/link.typ | 2 +- tests/suite/text/coma.typ | 3 +- tools/test-helper/README.md | 4 +- tools/test-helper/package.json | 3 +- tools/test-helper/src/extension.ts | 4 +- 18 files changed, 348 insertions(+), 176 deletions(-) create mode 100644 tests/.gitattributes create mode 100644 tests/ref/html/link-basic.html diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dfa836d18..33c5343c5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,8 +37,8 @@ Below are some signs of a good PR: - Adds/changes as little code and as few interfaces as possible. Should changes to larger-scale abstractions be necessary, these should be discussed throughout the implementation process. -- Adds tests if appropriate (with reference images for visual tests). See the - [testing] readme for more details. +- Adds tests if appropriate (with reference output for visual/HTML tests). See + the [testing] readme for more details. - Contains documentation comments on all new Rust types. - Comes with brief documentation for all new Typst definitions (elements/functions), ideally with a concise example that fits into ~5-10 diff --git a/Cargo.lock b/Cargo.lock index be5117da2..2c0bfe138 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3076,6 +3076,7 @@ dependencies = [ "typst", "typst-assets", "typst-dev-assets", + "typst-html", "typst-library", "typst-pdf", "typst-render", diff --git a/tests/.gitattributes b/tests/.gitattributes new file mode 100644 index 000000000..e54b0b9ec --- /dev/null +++ b/tests/.gitattributes @@ -0,0 +1 @@ +*.html text eol=lf diff --git a/tests/Cargo.toml b/tests/Cargo.toml index eed093eb6..cb979b6c2 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -19,6 +19,7 @@ default = [ "typst", "typst-assets", "typst-dev-assets", + "typst-html", "typst-library", "typst-pdf", "typst-render", @@ -33,6 +34,7 @@ typst-syntax = { workspace = true } typst = { workspace = true, optional = true } typst-assets = { workspace = true, features = ["fonts"], optional = true } typst-dev-assets = { workspace = true, optional = true } +typst-html = { workspace = true, optional = true } typst-library = { workspace = true, optional = true } typst-pdf = { workspace = true, optional = true } typst-render = { workspace = true, optional = true } diff --git a/tests/README.md b/tests/README.md index 4f5a31695..e625581a3 100644 --- a/tests/README.md +++ b/tests/README.md @@ -6,8 +6,8 @@ Top level directory structure: - `suite`: Input files. Mostly organized in parallel to the code. Each file can contain multiple tests, each of which is a section of Typst code following `--- {name} ---`. -- `ref`: Reference images which the output is compared with to determine whether - a test passed or failed. +- `ref`: References which the output is compared with to determine whether a + test passed or failed. - `store`: Store for PNG, PDF, and SVG output files produced by the tests. ## Running the tests @@ -54,18 +54,29 @@ You may find more options in the help message: testit --help ``` -To make the integration tests go faster they don't generate PDFs by default. -Pass the `--pdf` flag to generate those. Mind that PDFs are not tested -automatically at the moment, so you should always check the output manually when -making changes. +To make the integration tests go faster they don't generate PDFs or SVGs by +default. Pass the `--pdf` or `--svg` flag to generate those. Mind that PDFs and +SVGs are **not** tested automatically at the moment, so you should always check +the output manually when making changes. ```bash testit --pdf ``` ## Writing tests -The syntax for an individual test is `--- {name} ---` followed by some Typst -code that should be tested. The name must be globally unique in the test suite, -so that tests can be easily migrated across files. +The syntax for an individual test is `--- {name} {attr}* ---` followed by some +Typst code that should be tested. The name must be globally unique in the test +suite, so that tests can be easily migrated across files. A test name can be +followed by space-separated attributes. For instance, `--- my-test html ---` +adds the `html` modifier to `my-test`, instructing the test runner to also +test HTML output. The following attributes are currently defined: + +- `render`: Tests paged output against a reference image (the default, only + needs to be specified when `html` is also specified to enable both at the + same) +- `html`: Tests HTML output against a reference HTML file. Disables the `render` + default. +- `large`: Permits a reference image size exceeding 20 KiB. Should be used + sparingly. There are, broadly speaking, three kinds of tests: @@ -80,35 +91,42 @@ There are, broadly speaking, three kinds of tests: below. If the code span is in a line further below, you can write ranges like `3:2-3:7` to indicate the 2-7 column in the 3rd non-comment line. -- Tests that ensure certain visual output is produced: Those render the result - of the test with the `typst-render` crate and compare against a reference - image stored in the repository. The test runner automatically detects whether - a test has visual output and requires a reference image in this case. +- Tests that ensure certain output is produced: - To prevent bloat, it is important that the test images are kept as small as - possible. To that effect, the test runner enforces a maximum size of 20 KiB. - If you're updating a test and hit `reference image size exceeds`, see the - section on "Updating reference images" below. If truly necessary, the size - limit can be lifted by adding `// LARGE` as the first line of a test, but this - should be the case very rarely. + - Visual output: By default, the compiler produces paged output, renders it + with the `typst-render` crate, and compares it against a reference image + stored in the repository. The test runner automatically detects whether a + test has visual output and requires a reference image in this case. + + To prevent bloat, it is important that the test images are kept as small as + possible. To that effect, the test runner enforces a maximum size of 20 KiB. + If you're updating a test and hit `reference output size exceeds`, see the + section on "Updating reference images" below. If truly necessary, the size + limit can be lifted by adding a `large` attribute after the test name, but + this should be the case very rarely. + + - HTML output: When a test has the `html` attribute, the compiler produces + HTML output and compares it against a reference file stored in the + repository. By default, this enables testing of paged output, but you can + test both at once by passing both `render` and `html` as attributes. If you have the choice between writing a test using assertions or using reference images, prefer assertions. This makes the test easier to understand in isolation and prevents bloat due to images. ## Updating reference images -If you created a new test or fixed a bug in an existing test, you need to update -the reference image used for comparison. For this, you can use the `--update` -flag: +If you created a new test or fixed a bug in an existing test, you may need to +update the reference output used for comparison. For this, you can use the +`--update` flag: ```bash testit --exact my-test-name --update ``` -This will generally generate compressed reference images (to remain within the -above size limit). +For visual tests, this will generally generate compressed reference images (to +remain within the size limit). If you use the VS Code test helper extension (see the `tools` folder), you can -alternatively use the save button to update the reference image. +alternatively use the save button to update the reference output. ## Making an alias If you want to have a quicker way to run the tests, consider adding a shortcut diff --git a/tests/ref/html/link-basic.html b/tests/ref/html/link-basic.html new file mode 100644 index 000000000..1f4e02e12 --- /dev/null +++ b/tests/ref/html/link-basic.html @@ -0,0 +1,21 @@ + + + + + + + +

+ https://example.com/ +

+

+ Some text text text +

+

+ This link appears in the middle of a paragraph. +

+

+ Contact hi@typst.app or call 123 for more information. +

+ + \ No newline at end of file diff --git a/tests/src/args.rs b/tests/src/args.rs index db5d1a9ba..3d1bc28c2 100644 --- a/tests/src/args.rs +++ b/tests/src/args.rs @@ -23,7 +23,7 @@ pub struct CliArguments { /// Lists what tests will be run, without actually running them. #[arg(long, group = "action")] pub list: bool, - /// Updates the reference images of non-passing tests. + /// Updates the reference output of non-passing tests. #[arg(short, long, group = "action")] pub update: bool, /// The scaling factor to render the output image with. @@ -100,6 +100,6 @@ impl CliArguments { pub enum Command { /// Clears the on-disk test artifact store. Clean, - /// Deletes all dangling reference images. + /// Deletes all dangling reference output. Undangle, } diff --git a/tests/src/collect.rs b/tests/src/collect.rs index 5c7327f13..c6deba77b 100644 --- a/tests/src/collect.rs +++ b/tests/src/collect.rs @@ -23,9 +23,9 @@ pub fn collect() -> Result<(Vec, usize), Vec> { pub struct Test { pub pos: FilePos, pub name: EcoString, + pub attrs: Vec, pub source: Source, pub notes: Vec, - pub large: bool, } impl Display for Test { @@ -57,6 +57,14 @@ impl Display for FilePos { } } +/// A test attribute, given after the test name. +#[derive(Clone, Debug, PartialEq)] +pub enum Attr { + Html, + Render, + Large, +} + /// The size of a file. pub struct FileSize(pub usize); @@ -109,8 +117,7 @@ impl Display for NoteKind { struct Collector { tests: Vec, errors: Vec, - seen: HashMap, - large: HashSet, + seen: HashMap)>, skipped: usize, } @@ -121,7 +128,6 @@ impl Collector { tests: vec![], errors: vec![], seen: HashMap::new(), - large: HashSet::new(), skipped: 0, } } @@ -156,7 +162,7 @@ impl Collector { } } - /// Walks through all reference images and ensure that a test exists for + /// Walks through all reference output and ensures that a test exists for /// each one. fn walk_references(&mut self) { for entry in walkdir::WalkDir::new(crate::REF_PATH).sort_by_file_name() { @@ -169,20 +175,20 @@ impl Collector { let stem = path.file_stem().unwrap().to_string_lossy(); let name = &*stem; - let Some(pos) = self.seen.get(name) else { + let Some((pos, attrs)) = self.seen.get(name) else { self.errors.push(TestParseError { pos: FilePos::new(path, 0), - message: "dangling reference image".into(), + message: "dangling reference output".into(), }); continue; }; let len = path.metadata().unwrap().len() as usize; - if !self.large.contains(name) && len > crate::REF_LIMIT { + if !attrs.contains(&Attr::Large) && len > crate::REF_LIMIT { self.errors.push(TestParseError { pos: pos.clone(), message: format!( - "reference image size exceeds {}, but the test is not marked as `// LARGE`", + "reference output size exceeds {}, but the test is not marked as `large`", FileSize(crate::REF_LIMIT), ), }); @@ -218,6 +224,7 @@ impl<'a> Parser<'a> { while !self.s.done() { let mut name = EcoString::new(); + let mut attrs = Vec::new(); let mut notes = vec![]; if self.s.eat_if("---") { self.s.eat_while(' '); @@ -228,8 +235,8 @@ impl<'a> Parser<'a> { self.error("expected test name"); } else if !is_ident(&name) { self.error(format!("test name `{name}` is not a valid identifier")); - } else if !self.s.eat_if("---") { - self.error("expected closing ---"); + } else { + attrs = self.parse_attrs(); } } else { self.error("expected opening ---"); @@ -247,7 +254,7 @@ impl<'a> Parser<'a> { self.test_start_line = self.line; let pos = FilePos::new(self.path, self.test_start_line); - self.collector.seen.insert(name.clone(), pos.clone()); + self.collector.seen.insert(name.clone(), (pos.clone(), attrs.clone())); while !self.s.done() && !self.s.at("---") { self.s.eat_until(is_newline); @@ -257,10 +264,6 @@ impl<'a> Parser<'a> { } let text = self.s.from(start); - let large = text.starts_with("// LARGE"); - if large { - self.collector.large.insert(name.clone()); - } if !selected(&name, self.path.canonicalize().unwrap()) { self.collector.skipped += 1; @@ -285,10 +288,33 @@ impl<'a> Parser<'a> { } } - self.collector.tests.push(Test { pos, name, source, notes, large }); + self.collector.tests.push(Test { pos, name, source, notes, attrs }); } } + fn parse_attrs(&mut self) -> Vec { + let mut attrs = vec![]; + while !self.s.eat_if("---") { + let attr = match self.s.eat_until(char::is_whitespace) { + "large" => Attr::Large, + "html" => Attr::Html, + "render" => Attr::Render, + found => { + self.error(format!( + "expected attribute or closing ---, found `{found}`" + )); + break; + } + }; + if attrs.contains(&attr) { + self.error(format!("duplicate attribute {attr:?}")); + } + attrs.push(attr); + self.s.eat_while(' '); + } + attrs + } + /// Skips the preamble of a test. fn skip_preamble(&mut self) { let mut errored = false; diff --git a/tests/src/logger.rs b/tests/src/logger.rs index 48bad451b..18eefa5f5 100644 --- a/tests/src/logger.rs +++ b/tests/src/logger.rs @@ -9,8 +9,8 @@ pub struct TestResult { pub errors: String, /// The info log for this test. pub infos: String, - /// Whether the image was mismatched. - pub mismatched_image: bool, + /// Whether the output was mismatched. + pub mismatched_output: bool, } /// Receives status updates by individual test runs. @@ -19,7 +19,7 @@ pub struct Logger<'a> { passed: usize, failed: usize, skipped: usize, - mismatched_image: bool, + mismatched_output: bool, active: Vec<&'a Test>, last_change: Instant, temp_lines: usize, @@ -34,7 +34,7 @@ impl<'a> Logger<'a> { passed: 0, failed: 0, skipped, - mismatched_image: false, + mismatched_output: false, active: vec![], temp_lines: 0, last_change: Instant::now(), @@ -73,7 +73,7 @@ impl<'a> Logger<'a> { self.failed += 1; } - self.mismatched_image |= result.mismatched_image; + self.mismatched_output |= result.mismatched_output; self.last_change = Instant::now(); self.print(move |out| { @@ -102,8 +102,8 @@ impl<'a> Logger<'a> { eprintln!("{passed} passed, {failed} failed, {skipped} skipped"); assert_eq!(selected, passed + failed, "not all tests were executed successfully"); - if self.mismatched_image { - eprintln!(" pass the --update flag to update the reference images"); + if self.mismatched_output { + eprintln!(" pass the --update flag to update the reference output"); } self.failed == 0 diff --git a/tests/src/run.rs b/tests/src/run.rs index 1aa702041..f9a3c0434 100644 --- a/tests/src/run.rs +++ b/tests/src/run.rs @@ -1,16 +1,17 @@ use std::fmt::Write; use std::ops::Range; -use std::path::Path; +use std::path::PathBuf; use ecow::eco_vec; use tiny_skia as sk; use typst::diag::{SourceDiagnostic, Warned}; -use typst::layout::{Abs, Frame, FrameItem, Page, PagedDocument, Transform}; +use typst::html::HtmlDocument; +use typst::layout::{Abs, Frame, FrameItem, PagedDocument, Transform}; use typst::visualize::Color; -use typst::WorldExt; +use typst::{Document, WorldExt}; use typst_pdf::PdfOptions; -use crate::collect::{FileSize, NoteKind, Test}; +use crate::collect::{Attr, FileSize, NoteKind, Test}; use crate::logger::TestResult; use crate::world::TestWorld; @@ -50,7 +51,7 @@ impl<'a> Runner<'a> { result: TestResult { errors: String::new(), infos: String::new(), - mismatched_image: false, + mismatched_output: false, }, not_annotated: String::new(), } @@ -62,6 +63,23 @@ impl<'a> Runner<'a> { log!(into: self.result.infos, "tree: {:#?}", self.test.source.root()); } + let html = self.test.attrs.contains(&Attr::Html); + let render = !html || self.test.attrs.contains(&Attr::Render); + if render { + self.run_test::(); + } + if html { + self.run_test::(); + } + + self.handle_not_emitted(); + self.handle_not_annotated(); + + self.result + } + + /// Run test specific to document format. + fn run_test(&mut self) { let Warned { output, warnings } = typst::compile(&self.world); let (doc, errors) = match output { Ok(doc) => (Some(doc), eco_vec![]), @@ -72,8 +90,8 @@ impl<'a> Runner<'a> { log!(self, "no document, but also no errors"); } - self.check_custom(doc.as_ref()); - self.check_document(doc.as_ref()); + D::check_custom(self, doc.as_ref()); + self.check_output(doc.as_ref()); for error in &errors { self.check_diagnostic(NoteKind::Error, error); @@ -82,11 +100,6 @@ impl<'a> Runner<'a> { for warning in &warnings { self.check_diagnostic(NoteKind::Warning, warning); } - - self.handle_not_emitted(); - self.handle_not_annotated(); - - self.result } /// Handle errors that weren't annotated. @@ -113,86 +126,42 @@ impl<'a> Runner<'a> { } } - /// Run custom checks for which it is not worth to create special - /// annotations. - fn check_custom(&mut self, doc: Option<&PagedDocument>) { - let errors = crate::custom::check(self.test, &self.world, doc); - if !errors.is_empty() { - log!(self, "custom check failed"); - for line in errors.lines() { - log!(self, " {line}"); - } - } - } - /// Check that the document output is correct. - fn check_document(&mut self, document: Option<&PagedDocument>) { - let live_path = format!("{}/render/{}.png", crate::STORE_PATH, self.test.name); - let ref_path = format!("{}/{}.png", crate::REF_PATH, self.test.name); - let has_ref = Path::new(&ref_path).exists(); + fn check_output(&mut self, document: Option<&D>) { + let live_path = D::live_path(&self.test.name); + let ref_path = D::ref_path(&self.test.name); + let ref_data = std::fs::read(&ref_path); let Some(document) = document else { - if has_ref { + if ref_data.is_ok() { log!(self, "missing document"); - log!(self, " ref | {ref_path}"); + log!(self, " ref | {}", ref_path.display()); } return; }; - let skippable = match document.pages.as_slice() { - [] => { + let skippable = match D::is_skippable(document) { + Ok(skippable) => skippable, + Err(()) => { log!(self, "document has zero pages"); return; } - [page] => skippable(page), - _ => false, }; - // Tests without visible output and no reference image don't need to be + // Tests without visible output and no reference output don't need to be // compared. - if skippable && !has_ref { + if skippable && ref_data.is_err() { std::fs::remove_file(&live_path).ok(); return; } - // Render the live version. - let pixmap = render(document, 1.0); - - // Save live version, possibly rerendering if different scale is - // requested. - let mut pixmap_live = &pixmap; - let slot; - let scale = crate::ARGS.scale; - if scale != 1.0 { - slot = render(document, scale); - pixmap_live = &slot; - } - let data = pixmap_live.encode_png().unwrap(); - std::fs::write(&live_path, data).unwrap(); - - // Write PDF if requested. - if crate::ARGS.pdf() { - let pdf_path = format!("{}/pdf/{}.pdf", crate::STORE_PATH, self.test.name); - let pdf = typst_pdf::pdf(document, &PdfOptions::default()).unwrap(); - std::fs::write(pdf_path, pdf).unwrap(); - } - - // Write SVG if requested. - if crate::ARGS.svg() { - let svg_path = format!("{}/svg/{}.svg", crate::STORE_PATH, self.test.name); - let svg = typst_svg::svg_merged(document, Abs::pt(5.0)); - std::fs::write(svg_path, svg).unwrap(); - } - - // Compare against reference image if available. - let equal = has_ref && { - let ref_data = std::fs::read(&ref_path).unwrap(); - let ref_pixmap = sk::Pixmap::decode_png(&ref_data).unwrap(); - approx_equal(&pixmap, &ref_pixmap) - }; + // Render and save live version. + let live = document.make_live(); + document.save_live(&self.test.name, &live); + // Compare against reference output if available. // Test that is ok doesn't need to be updated. - if equal { + if ref_data.as_ref().map_or(false, |r| D::matches(&live, r)) { return; } @@ -201,36 +170,37 @@ impl<'a> Runner<'a> { std::fs::remove_file(&ref_path).unwrap(); log!( into: self.result.infos, - "removed reference image ({ref_path})" + "removed reference output ({})", ref_path.display() ); } else { - let opts = oxipng::Options::max_compression(); - let data = pixmap.encode_png().unwrap(); - let ref_data = oxipng::optimize_from_memory(&data, &opts).unwrap(); - if !self.test.large && ref_data.len() > crate::REF_LIMIT { - log!(self, "reference image would exceed maximum size"); + let ref_data = D::make_ref(live); + if !self.test.attrs.contains(&Attr::Large) + && ref_data.len() > crate::REF_LIMIT + { + log!(self, "reference output would exceed maximum size"); log!(self, " maximum | {}", FileSize(crate::REF_LIMIT)); log!(self, " size | {}", FileSize(ref_data.len())); log!(self, "please try to minimize the size of the test (smaller pages, less text, etc.)"); - log!(self, "if you think the test cannot be reasonably minimized, mark it as `// LARGE`"); + log!(self, "if you think the test cannot be reasonably minimized, mark it as `large`"); return; } std::fs::write(&ref_path, &ref_data).unwrap(); log!( into: self.result.infos, - "updated reference image ({ref_path}, {})", + "updated reference output ({}, {})", + ref_path.display(), FileSize(ref_data.len()), ); } } else { - self.result.mismatched_image = true; - if has_ref { - log!(self, "mismatched rendering"); - log!(self, " live | {live_path}"); - log!(self, " ref | {ref_path}"); + self.result.mismatched_output = true; + if ref_data.is_ok() { + log!(self, "mismatched output"); + log!(self, " live | {}", live_path.display()); + log!(self, " ref | {}", ref_path.display()); } else { - log!(self, "missing reference image"); - log!(self, " live | {live_path}"); + log!(self, "missing reference output"); + log!(self, " live | {}", live_path.display()); } } } @@ -242,6 +212,10 @@ impl<'a> Runner<'a> { if diag.span.id().is_some_and(|id| id != self.test.source.id()) { return; } + // TODO: remove this once HTML export is stable + if diag.message == "html export is under active development and incomplete" { + return; + } let message = diag.message.replace("\\", "/"); let range = self.world.range(diag.span); @@ -349,6 +323,153 @@ impl<'a> Runner<'a> { } } +/// An output type we can test. +trait OutputType: Document { + /// The type that represents live output. + type Live; + + /// The path at which the live output is stored. + fn live_path(name: &str) -> PathBuf; + + /// The path at which the reference output is stored. + fn ref_path(name: &str) -> PathBuf; + + /// Whether the test output is trivial and needs no reference output. + fn is_skippable(&self) -> Result { + Ok(false) + } + + /// Produces the live output. + fn make_live(&self) -> Self::Live; + + /// Saves the live output. + fn save_live(&self, name: &str, live: &Self::Live); + + /// Produces the reference output from the live output. + fn make_ref(live: Self::Live) -> Vec; + + /// Checks whether the live and reference output match. + fn matches(live: &Self::Live, ref_data: &[u8]) -> bool; + + /// Runs additional checks. + #[expect(unused_variables)] + fn check_custom(runner: &mut Runner, doc: Option<&Self>) {} +} + +impl OutputType for PagedDocument { + type Live = tiny_skia::Pixmap; + + fn live_path(name: &str) -> PathBuf { + format!("{}/render/{}.png", crate::STORE_PATH, name).into() + } + + fn ref_path(name: &str) -> PathBuf { + format!("{}/{}.png", crate::REF_PATH, name).into() + } + + fn is_skippable(&self) -> Result { + /// Whether rendering of a frame can be skipped. + fn skippable_frame(frame: &Frame) -> bool { + frame.items().all(|(_, item)| match item { + FrameItem::Group(group) => skippable_frame(&group.frame), + FrameItem::Tag(_) => true, + _ => false, + }) + } + + match self.pages.as_slice() { + [] => Err(()), + [page] => Ok(page.frame.width().approx_eq(Abs::pt(120.0)) + && page.frame.height().approx_eq(Abs::pt(20.0)) + && page.fill.is_auto() + && skippable_frame(&page.frame)), + _ => Ok(false), + } + } + + fn make_live(&self) -> Self::Live { + render(self, 1.0) + } + + fn save_live(&self, name: &str, live: &Self::Live) { + // Save live version, possibly rerendering if different scale is + // requested. + let mut pixmap_live = live; + let slot; + let scale = crate::ARGS.scale; + if scale != 1.0 { + slot = render(self, scale); + pixmap_live = &slot; + } + let data: Vec = pixmap_live.encode_png().unwrap(); + std::fs::write(Self::live_path(name), data).unwrap(); + + // Write PDF if requested. + if crate::ARGS.pdf() { + let pdf_path = format!("{}/pdf/{}.pdf", crate::STORE_PATH, name); + let pdf = typst_pdf::pdf(self, &PdfOptions::default()).unwrap(); + std::fs::write(pdf_path, pdf).unwrap(); + } + + // Write SVG if requested. + if crate::ARGS.svg() { + let svg_path = format!("{}/svg/{}.svg", crate::STORE_PATH, name); + let svg = typst_svg::svg_merged(self, Abs::pt(5.0)); + std::fs::write(svg_path, svg).unwrap(); + } + } + + fn make_ref(live: Self::Live) -> Vec { + let opts = oxipng::Options::max_compression(); + let data = live.encode_png().unwrap(); + oxipng::optimize_from_memory(&data, &opts).unwrap() + } + + fn matches(live: &Self::Live, ref_data: &[u8]) -> bool { + let ref_pixmap = sk::Pixmap::decode_png(ref_data).unwrap(); + approx_equal(live, &ref_pixmap) + } + + fn check_custom(runner: &mut Runner, doc: Option<&Self>) { + let errors = crate::custom::check(runner.test, &runner.world, doc); + if !errors.is_empty() { + log!(runner, "custom check failed"); + for line in errors.lines() { + log!(runner, " {line}"); + } + } + } +} + +impl OutputType for HtmlDocument { + type Live = String; + + fn live_path(name: &str) -> PathBuf { + format!("{}/html/{}.html", crate::STORE_PATH, name).into() + } + + fn ref_path(name: &str) -> PathBuf { + format!("{}/html/{}.html", crate::REF_PATH, name).into() + } + + fn make_live(&self) -> Self::Live { + // TODO: Do this earlier to be able to process export errors. + typst_html::html(self).unwrap() + } + + fn save_live(&self, name: &str, live: &Self::Live) { + std::fs::write(Self::live_path(name), live).unwrap(); + } + + fn make_ref(live: Self::Live) -> Vec { + live.into_bytes() + } + + fn matches(live: &Self::Live, ref_data: &[u8]) -> bool { + live.as_bytes() == ref_data + } +} + /// Draw all frames into one image with padding in between. fn render(document: &PagedDocument, pixel_per_pt: f32) -> sk::Pixmap { for page in &document.pages { @@ -397,23 +518,6 @@ fn render_links(canvas: &mut sk::Pixmap, ts: sk::Transform, frame: &Frame) { } } -/// Whether rendering of a frame can be skipped. -fn skippable(page: &Page) -> bool { - page.frame.width().approx_eq(Abs::pt(120.0)) - && page.frame.height().approx_eq(Abs::pt(20.0)) - && page.fill.is_auto() - && skippable_frame(&page.frame) -} - -/// Whether rendering of a frame can be skipped. -fn skippable_frame(frame: &Frame) -> bool { - frame.items().all(|(_, item)| match item { - FrameItem::Group(group) => skippable_frame(&group.frame), - FrameItem::Tag(_) => true, - _ => false, - }) -} - /// Whether two pixel images are approximately equal. fn approx_equal(a: &sk::Pixmap, b: &sk::Pixmap) -> bool { a.width() == b.width() diff --git a/tests/src/tests.rs b/tests/src/tests.rs index 2b09b29c0..26eb63beb 100644 --- a/tests/src/tests.rs +++ b/tests/src/tests.rs @@ -37,13 +37,13 @@ const STORE_PATH: &str = "tests/store"; /// The directory where syntax trees are stored. const SYNTAX_PATH: &str = "tests/store/syntax"; -/// The directory where the reference images are stored. +/// The directory where the reference output is stored. const REF_PATH: &str = "tests/ref"; /// The file where the skipped tests are stored. const SKIP_PATH: &str = "tests/skip.txt"; -/// The maximum size of reference images that aren't marked as `// LARGE`. +/// The maximum size of reference output that isn't marked as `large`. const REF_LIMIT: usize = 20 * 1024; fn main() { @@ -62,7 +62,7 @@ fn setup() { std::env::set_current_dir("..").unwrap(); // Create the storage. - for ext in ["render", "pdf", "svg"] { + for ext in ["render", "html", "pdf", "svg"] { std::fs::create_dir_all(Path::new(STORE_PATH).join(ext)).unwrap(); } @@ -156,10 +156,10 @@ fn clean() { fn undangle() { match crate::collect::collect() { - Ok(_) => eprintln!("no danging reference images"), + Ok(_) => eprintln!("no danging reference output"), Err(errors) => { for error in errors { - if error.message == "dangling reference image" { + if error.message == "dangling reference output" { std::fs::remove_file(&error.pos.path).unwrap(); eprintln!("✅ deleted {}", error.pos.path.display()); } @@ -188,7 +188,7 @@ fn run_parser_test( let mut result = TestResult { errors: String::new(), infos: String::new(), - mismatched_image: false, + mismatched_output: false, }; let syntax_file = live_path.join(format!("{}.syntax", test.name)); diff --git a/tests/src/world.rs b/tests/src/world.rs index f5e49ba6c..a08f1efa8 100644 --- a/tests/src/world.rs +++ b/tests/src/world.rs @@ -19,7 +19,7 @@ use typst::syntax::{FileId, Source, Span}; use typst::text::{Font, FontBook, TextElem, TextSize}; use typst::utils::{singleton, LazyHash}; use typst::visualize::Color; -use typst::{Library, World}; +use typst::{Feature, Library, World}; /// A world that provides access to the tests environment. #[derive(Clone)] @@ -180,7 +180,9 @@ fn library() -> Library { // Set page width to 120pt with 10pt margins, so that the inner page is // exactly 100pt wide. Page height is unbounded and font size is 10pt so // that it multiplies to nice round numbers. - let mut lib = Library::default(); + let mut lib = Library::builder() + .with_features([Feature::Html].into_iter().collect()) + .build(); // Hook up helpers into the global scope. lib.global.scope_mut().define_func::(); diff --git a/tests/suite/layout/inline/justify.typ b/tests/suite/layout/inline/justify.typ index 6e0317ac5..659441b12 100644 --- a/tests/suite/layout/inline/justify.typ +++ b/tests/suite/layout/inline/justify.typ @@ -7,8 +7,7 @@ forms a "block" with flush edges at both sides. First line indents and hyphenation play nicely with justified text. ---- justify-knuth-story --- -// LARGE +--- justify-knuth-story large --- #set page(width: auto, height: auto) #set par(leading: 4pt, justify: true) #set text(font: "New Computer Modern") diff --git a/tests/suite/model/link.typ b/tests/suite/model/link.typ index 27afd53c1..7cced8560 100644 --- a/tests/suite/model/link.typ +++ b/tests/suite/model/link.typ @@ -1,6 +1,6 @@ // Test hyperlinking. ---- link-basic --- +--- link-basic render html --- // Link syntax. https://example.com/ diff --git a/tests/suite/text/coma.typ b/tests/suite/text/coma.typ index df7576339..6b5eee3d4 100644 --- a/tests/suite/text/coma.typ +++ b/tests/suite/text/coma.typ @@ -1,5 +1,4 @@ ---- coma --- -// LARGE +--- coma large --- #set page(width: 450pt, margin: 1cm) *Technische Universität Berlin* #h(1fr) *WiSe 2019/2020* \ diff --git a/tools/test-helper/README.md b/tools/test-helper/README.md index 6eab6abd8..3ecc1d4f4 100644 --- a/tools/test-helper/README.md +++ b/tools/test-helper/README.md @@ -6,7 +6,7 @@ Code Lens buttons will appear above every test's name: - View: Opens the output and reference image of a test to the side. - Run: Runs the test and shows the results to the side. -- Save: Runs the test with `--update` to save the reference image. +- Save: Runs the test with `--update` to save the reference output. - Terminal: Runs the test in the integrated terminal. In the side panel opened by the Code Lens buttons, there are a few menu buttons @@ -14,7 +14,7 @@ at the top right: - Refresh: Reloads the panel to reflect changes to the images. - Run: Runs the test and shows the results. -- Save: Runs the test with `--update` to save the reference image. +- Save: Runs the test with `--update` to save the reference output. ## Installation In order for VS Code to run the extension with its built-in diff --git a/tools/test-helper/package.json b/tools/test-helper/package.json index de919ef37..d34213fb0 100644 --- a/tools/test-helper/package.json +++ b/tools/test-helper/package.json @@ -28,7 +28,7 @@ }, { "command": "typst-test-helper.saveFromPreview", - "title": "Run and save reference image", + "title": "Run and save reference output", "category": "Typst Test Helper", "icon": "$(save)", "enablement": "typst-test-helper.runButtonEnabled" @@ -102,4 +102,3 @@ "vscode": "^1.88.0" } } - diff --git a/tools/test-helper/src/extension.ts b/tools/test-helper/src/extension.ts index 1f2cbd26c..2e2b7d218 100644 --- a/tools/test-helper/src/extension.ts +++ b/tools/test-helper/src/extension.ts @@ -121,7 +121,7 @@ class TestHelper { const lenses = []; for (let nr = 0; nr < document.lineCount; nr++) { const line = document.lineAt(nr); - const re = /^--- ([\d\w-]+) ---$/; + const re = /^--- ([\d\w-]+)( [\d\w-]+)* ---$/; const m = line.text.match(re); if (!m) { continue; @@ -143,7 +143,7 @@ class TestHelper { }), new vscode.CodeLens(line.range, { title: "Save", - tooltip: "Run and view the test and save the reference image", + tooltip: "Run and view the test and save the reference output", command: "typst-test-helper.saveFromLens", arguments: [name], }),