summaryrefslogtreecommitdiff
path: root/tests/src/tests.rs
diff options
context:
space:
mode:
Diffstat (limited to 'tests/src/tests.rs')
-rw-r--r--tests/src/tests.rs1179
1 files changed, 82 insertions, 1097 deletions
diff --git a/tests/src/tests.rs b/tests/src/tests.rs
index e4f60bb6..6d58e969 100644
--- a/tests/src/tests.rs
+++ b/tests/src/tests.rs
@@ -1,1127 +1,112 @@
-/*! This is Typst's test runner.
+//! Typst's test runner.
-Tests are Typst files composed of a header part followed by subtests.
+mod args;
+mod collect;
+mod logger;
+mod run;
+mod world;
-The header may contain:
-- a small description `// tests that features X works well`
-- metadata (see [metadata::TestConfiguration])
-
-The subtests may use extra testing functions defined in [library], most
-importantly, `test(x, y)` which will fail the test `if x != y`.
-*/
-
-#![allow(clippy::comparison_chain)]
-mod metadata;
-
-use self::metadata::*;
-
-use std::borrow::Cow;
-use std::collections::{HashMap, HashSet};
-use std::ffi::OsStr;
-use std::fmt::Write as _;
-use std::io::{self, IsTerminal, Write as _};
-use std::ops::Range;
-use std::path::{Path, PathBuf, MAIN_SEPARATOR_STR};
-use std::sync::{OnceLock, RwLock};
-use std::{env, fs};
+use std::path::Path;
+use std::time::Duration;
use clap::Parser;
-use comemo::{Prehashed, Track};
-use oxipng::{InFile, Options, OutFile};
+use once_cell::sync::Lazy;
+use parking_lot::Mutex;
use rayon::iter::{ParallelBridge, ParallelIterator};
-use tiny_skia as sk;
-use typst::diag::{bail, FileError, FileResult, Severity, SourceDiagnostic, StrResult};
-use typst::eval::Tracer;
-use typst::foundations::{func, Bytes, Datetime, NoneValue, Repr, Smart, Value};
-use typst::introspection::Meta;
-use typst::layout::{Abs, Frame, FrameItem, Margin, Page, PageElem, Transform};
-use typst::model::Document;
-use typst::syntax::{FileId, Source, SyntaxNode, VirtualPath};
-use typst::text::{Font, FontBook, TextElem, TextSize};
-use typst::visualize::Color;
-use typst::{Library, World, WorldExt};
-use walkdir::WalkDir;
-// These directories are all relative to the tests/ directory.
-const TYP_DIR: &str = "typ";
-const REF_DIR: &str = "ref";
-const PNG_DIR: &str = "png";
-const PDF_DIR: &str = "pdf";
-const SVG_DIR: &str = "svg";
+use crate::args::{CliArguments, Command};
+use crate::logger::Logger;
-/// Arguments that modify test behaviour.
-///
-/// Specify them like this when developing:
-/// `cargo test --workspace --test tests -- --help`
-#[derive(Debug, Clone, Parser)]
-#[clap(name = "typst-test", author)]
-struct Args {
- /// All the tests that contains a filter string will be run (unless
- /// `--exact` is specified, which is even stricter).
- filter: Vec<String>,
- /// Runs only the specified subtest.
- #[arg(short, long)]
- #[arg(allow_hyphen_values = true)]
- subtest: Option<isize>,
- /// Runs only the test with the exact name specified in your command.
- ///
- /// Example:
- /// `cargo test --workspace --test tests -- compiler/bytes.typ --exact`
- #[arg(long)]
- exact: bool,
- /// Updates the reference images in `tests/ref`.
- #[arg(long, default_value_t = env::var_os("UPDATE_EXPECT").is_some())]
- update: bool,
- /// Exports the tests as PDF into `tests/pdf`.
- #[arg(long)]
- pdf: bool,
- /// Configuration of what to print.
- #[command(flatten)]
- print: PrintConfig,
- /// Running `cargo test --workspace -- --nocapture` for the unit tests would
- /// fail the test runner without argument.
- // TODO: would it really still happen?
- #[arg(long)]
- nocapture: bool,
- /// Prevents the terminal from being cleared of test names and includes
- /// non-essential test messages.
- #[arg(short, long)]
- verbose: bool,
-}
+/// The parsed command line arguments.
+static ARGS: Lazy<CliArguments> = Lazy::new(CliArguments::parse);
-/// Which things to print out for debugging.
-#[derive(Default, Debug, Copy, Clone, Eq, PartialEq, Parser)]
-struct PrintConfig {
- /// Print the syntax tree.
- #[arg(long)]
- syntax: bool,
- /// Print the content model.
- #[arg(long)]
- model: bool,
- /// Print the layouted frames.
- #[arg(long)]
- frames: bool,
-}
+/// The directory where the test suite is located.
+const SUITE_PATH: &str = "tests/suite";
-impl Args {
- fn matches(&self, canonicalized_path: &Path) -> bool {
- let path = canonicalized_path.to_string_lossy();
- if !self.exact {
- return self.filter.is_empty()
- || self.filter.iter().any(|v| path.contains(v));
- }
+/// The directory where the full test results are stored.
+const STORE_PATH: &str = "tests/store";
- self.filter.iter().any(|v| match path.strip_suffix(v) {
- None => false,
- Some(residual) => {
- residual.is_empty() || residual.ends_with(MAIN_SEPARATOR_STR)
- }
- })
- }
-}
-
-/// Tests all test files and prints a summary.
-fn main() {
- let args = Args::parse();
-
- // Create loader and context.
- let world = TestWorld::new(args.print);
-
- println!("Running tests...");
- let results = WalkDir::new(TYP_DIR)
- .sort_by_file_name()
- .into_iter()
- .par_bridge()
- .filter_map(|entry| {
- let entry = entry.unwrap();
- if entry.depth() == 0 {
- return None;
- }
+/// The directory where the reference images are stored.
+const REF_PATH: &str = "tests/ref";
- if entry.path().starts_with("typ/benches") {
- return None;
- }
-
- let src_path = entry.into_path(); // Relative to TYP_DIR.
- if src_path.extension() != Some(OsStr::new("typ")) {
- return None;
- }
-
- if args.matches(&src_path.canonicalize().unwrap()) {
- Some(src_path)
- } else {
- None
- }
- })
- .map_with(world, |world, src_path| {
- let path = src_path.strip_prefix(TYP_DIR).unwrap();
- let png_path = Path::new(PNG_DIR).join(path).with_extension("png");
- let ref_path = Path::new(REF_DIR).join(path).with_extension("png");
- let svg_path = Path::new(SVG_DIR).join(path).with_extension("svg");
- let pdf_path =
- args.pdf.then(|| Path::new(PDF_DIR).join(path).with_extension("pdf"));
-
- test(
- world,
- &src_path,
- &png_path,
- &ref_path,
- pdf_path.as_deref(),
- &svg_path,
- &args,
- ) as usize
- })
- .collect::<Vec<_>>();
-
- let len = results.len();
- let ok = results.iter().sum::<usize>();
- if len > 0 {
- println!("{ok} / {len} test{} passed.", if len > 1 { "s" } else { "" });
- } else {
- println!("No test ran.");
- }
-
- if ok != len {
- println!(
- "Set the UPDATE_EXPECT environment variable or pass the \
- --update flag to update the reference image(s)."
- );
- }
-
- if ok < len {
- std::process::exit(1);
- }
-}
+/// The maximum size of reference images that aren't marked as `// LARGE`.
+const REF_LIMIT: usize = 20 * 1024;
-fn library() -> Library {
- #[func]
- fn test(lhs: Value, rhs: Value) -> StrResult<NoneValue> {
- if lhs != rhs {
- bail!("Assertion failed: {} != {}", lhs.repr(), rhs.repr());
- }
- Ok(NoneValue)
- }
-
- #[func]
- fn test_repr(lhs: Value, rhs: Value) -> StrResult<NoneValue> {
- if lhs.repr() != rhs.repr() {
- bail!("Assertion failed: {} != {}", lhs.repr(), rhs.repr());
- }
- Ok(NoneValue)
- }
-
- #[func]
- fn print(#[variadic] values: Vec<Value>) -> NoneValue {
- let mut stdout = io::stdout().lock();
- write!(stdout, "> ").unwrap();
- for (i, value) in values.into_iter().enumerate() {
- if i > 0 {
- write!(stdout, ", ").unwrap();
- }
- write!(stdout, "{value:?}").unwrap();
- }
- writeln!(stdout).unwrap();
- NoneValue
- }
-
- // 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();
- lib.styles
- .set(PageElem::set_width(Smart::Custom(Abs::pt(120.0).into())));
- lib.styles.set(PageElem::set_height(Smart::Auto));
- lib.styles.set(PageElem::set_margin(Margin::splat(Some(Smart::Custom(
- Abs::pt(10.0).into(),
- )))));
- lib.styles.set(TextElem::set_size(TextSize(Abs::pt(10.0).into())));
-
- // Hook up helpers into the global scope.
- lib.global.scope_mut().define_func::<test>();
- lib.global.scope_mut().define_func::<test_repr>();
- lib.global.scope_mut().define_func::<print>();
- lib.global
- .scope_mut()
- .define("conifer", Color::from_u8(0x9f, 0xEB, 0x52, 0xFF));
- lib.global
- .scope_mut()
- .define("forest", Color::from_u8(0x43, 0xA1, 0x27, 0xFF));
-
- lib
-}
-
-/// A world that provides access to the tests environment.
-struct TestWorld {
- print: PrintConfig,
- main: FileId,
- library: Prehashed<Library>,
- book: Prehashed<FontBook>,
- fonts: Vec<Font>,
- slots: RwLock<HashMap<FileId, FileSlot>>,
-}
-
-#[derive(Clone)]
-struct FileSlot {
- source: OnceLock<FileResult<Source>>,
- buffer: OnceLock<FileResult<Bytes>>,
-}
-
-impl TestWorld {
- fn new(print: PrintConfig) -> Self {
- let fonts: Vec<_> = typst_assets::fonts()
- .chain(typst_dev_assets::fonts())
- .flat_map(|data| Font::iter(Bytes::from_static(data)))
- .collect();
-
- Self {
- print,
- main: FileId::new(None, VirtualPath::new("main.typ")),
- library: Prehashed::new(library()),
- book: Prehashed::new(FontBook::from_fonts(&fonts)),
- fonts,
- slots: RwLock::new(HashMap::new()),
- }
- }
-}
-
-impl World for TestWorld {
- fn library(&self) -> &Prehashed<Library> {
- &self.library
- }
-
- fn book(&self) -> &Prehashed<FontBook> {
- &self.book
- }
-
- fn main(&self) -> Source {
- self.source(self.main).unwrap()
- }
-
- fn source(&self, id: FileId) -> FileResult<Source> {
- self.slot(id, |slot| {
- slot.source
- .get_or_init(|| {
- let buf = read(&system_path(id)?)?;
- let text = String::from_utf8(buf.into_owned())?;
- Ok(Source::new(id, text))
- })
- .clone()
- })
- }
-
- fn file(&self, id: FileId) -> FileResult<Bytes> {
- self.slot(id, |slot| {
- slot.buffer
- .get_or_init(|| {
- read(&system_path(id)?).map(|cow| match cow {
- Cow::Owned(buf) => buf.into(),
- Cow::Borrowed(buf) => Bytes::from_static(buf),
- })
- })
- .clone()
- })
- }
-
- fn font(&self, id: usize) -> Option<Font> {
- Some(self.fonts[id].clone())
- }
-
- fn today(&self, _: Option<i64>) -> Option<Datetime> {
- Some(Datetime::from_ymd(1970, 1, 1).unwrap())
- }
-}
-
-impl TestWorld {
- fn set(&mut self, path: &Path, text: String) -> Source {
- self.main = FileId::new(None, VirtualPath::new(path));
- let source = Source::new(self.main, text);
- self.slot(self.main, |slot| {
- slot.source = OnceLock::from(Ok(source.clone()));
- source
- })
- }
-
- fn slot<F, T>(&self, id: FileId, f: F) -> T
- where
- F: FnOnce(&mut FileSlot) -> T,
- {
- f(self.slots.write().unwrap().entry(id).or_insert_with(|| FileSlot {
- source: OnceLock::new(),
- buffer: OnceLock::new(),
- }))
- }
-}
+fn main() {
+ setup();
-impl Clone for TestWorld {
- fn clone(&self) -> Self {
- Self {
- print: self.print,
- main: self.main,
- library: self.library.clone(),
- book: self.book.clone(),
- fonts: self.fonts.clone(),
- slots: RwLock::new(self.slots.read().unwrap().clone()),
- }
+ match &ARGS.command {
+ None => test(),
+ Some(Command::Clean) => std::fs::remove_dir_all(STORE_PATH).unwrap(),
}
}
-/// The file system path for a file ID.
-fn system_path(id: FileId) -> FileResult<PathBuf> {
- let root: PathBuf = match id.package() {
- Some(spec) => format!("packages/{}-{}", spec.name, spec.version).into(),
- None => PathBuf::new(),
- };
-
- id.vpath().resolve(&root).ok_or(FileError::AccessDenied)
-}
-
-/// Read a file.
-fn read(path: &Path) -> FileResult<Cow<'static, [u8]>> {
- // Basically symlinks `assets/files` to `tests/files` so that the assets
- // are within the test project root.
- let resolved = path.to_path_buf();
- if let Ok(suffix) = path.strip_prefix("assets/") {
- return typst_dev_assets::get(&suffix.to_string_lossy())
- .map(Cow::Borrowed)
- .ok_or_else(|| FileError::NotFound(path.into()));
- }
-
- let f = |e| FileError::from_io(e, path);
- if fs::metadata(&resolved).map_err(f)?.is_dir() {
- Err(FileError::IsDirectory)
- } else {
- fs::read(&resolved).map(Cow::Owned).map_err(f)
- }
-}
+fn setup() {
+ // Make all paths relative to the workspace. That's nicer for IDEs when
+ // clicking on paths printed to the terminal.
+ std::env::set_current_dir("..").unwrap();
-/// Tests a test file and prints the result.
-///
-/// Also tests that the header of each test is written correctly.
-/// See [parse_part_metadata] for more details.
-fn test(
- world: &mut TestWorld,
- src_path: &Path,
- png_path: &Path,
- ref_path: &Path,
- pdf_path: Option<&Path>,
- svg_path: &Path,
- args: &Args,
-) -> bool {
- struct PanicGuard<'a>(&'a Path);
- impl Drop for PanicGuard<'_> {
- fn drop(&mut self) {
- if std::thread::panicking() {
- println!("Panicked in {}", self.0.display());
- }
- }
+ // Create the storage.
+ for ext in ["render", "pdf", "svg"] {
+ std::fs::create_dir_all(Path::new(STORE_PATH).join(ext)).unwrap();
}
- let name = src_path.strip_prefix(TYP_DIR).unwrap_or(src_path);
- let text = fs::read_to_string(src_path).unwrap();
- let _guard = PanicGuard(name);
-
- let mut output = String::new();
- let mut ok = true;
- let mut updated = false;
- let mut pages = vec![];
- let mut line = 0;
- let mut header_configuration = None;
- let mut compare_ever = false;
- let mut rng = LinearShift::new();
-
- let parts: Vec<_> = text
- .split("\n---")
- .map(|s| s.strip_suffix('\r').unwrap_or(s))
- .collect();
-
- for (i, &part) in parts.iter().enumerate() {
- if let Some(x) = args.subtest {
- let x = usize::try_from(
- x.rem_euclid(isize::try_from(parts.len()).unwrap_or_default()),
- )
+ // Set up the thread pool.
+ if let Some(num_threads) = ARGS.num_threads {
+ rayon::ThreadPoolBuilder::new()
+ .num_threads(num_threads)
+ .build_global()
.unwrap();
- if x != i {
- writeln!(output, " Skipped subtest {i}.").unwrap();
- continue;
- }
- }
- let is_header = i == 0
- && parts.len() > 1
- && part
- .lines()
- .all(|s| s.starts_with("//") || s.chars().all(|c| c.is_whitespace()));
-
- if is_header {
- let source = Source::detached(part.to_string());
- let metadata = parse_part_metadata(&source, true);
- match metadata {
- Ok(metadata) => {
- header_configuration = Some(metadata.config);
- }
- Err(invalid_data) => {
- ok = false;
- writeln!(
- output,
- " Test {}: invalid metadata in header, failing the test:",
- name.display()
- )
- .unwrap();
- InvalidMetadata::write(
- invalid_data,
- &mut output,
- &mut |annotation, output| {
- print_annotation(output, &source, line, annotation)
- },
- );
- }
- }
- } else {
- let (part_ok, compare_here, part_frames) = test_part(
- &mut output,
- world,
- src_path,
- part.into(),
- line,
- i,
- header_configuration.as_ref().unwrap_or(&Default::default()),
- &mut rng,
- args.verbose,
- );
-
- ok &= part_ok;
- compare_ever |= compare_here;
- pages.extend(part_frames);
- }
-
- line += part.lines().count() + 1;
- }
-
- let document = Document { pages, ..Default::default() };
- if compare_ever {
- if let Some(pdf_path) = pdf_path {
- let pdf_data = typst_pdf::pdf(
- &document,
- Smart::Custom(&format!("typst-test: {}", name.display())),
- world.today(Some(0)),
- );
- fs::create_dir_all(pdf_path.parent().unwrap()).unwrap();
- fs::write(pdf_path, pdf_data).unwrap();
- }
-
- if world.print.frames {
- for frame in &document.pages {
- writeln!(output, "{frame:#?}\n").unwrap();
- }
- }
-
- let canvas = render(&document);
- fs::create_dir_all(png_path.parent().unwrap()).unwrap();
- canvas.save_png(png_path).unwrap();
-
- let svg = typst_svg::svg_merged(&document, Abs::pt(5.0));
-
- fs::create_dir_all(svg_path.parent().unwrap()).unwrap();
- std::fs::write(svg_path, svg.as_bytes()).unwrap();
-
- if let Ok(ref_pixmap) = sk::Pixmap::load_png(ref_path) {
- if canvas.width() != ref_pixmap.width()
- || canvas.height() != ref_pixmap.height()
- || canvas
- .data()
- .iter()
- .zip(ref_pixmap.data())
- .any(|(&a, &b)| a.abs_diff(b) > 2)
- {
- if args.update {
- update_image(png_path, ref_path);
- updated = true;
- } else {
- writeln!(output, " Does not match reference image.").unwrap();
- ok = false;
- }
- }
- } else if !document.pages.is_empty() {
- if args.update {
- update_image(png_path, ref_path);
- updated = true;
- } else {
- writeln!(output, " Failed to open reference image.").unwrap();
- ok = false;
- }
- }
}
-
- {
- let mut stdout = io::stdout().lock();
- stdout.write_all(name.to_string_lossy().as_bytes()).unwrap();
- if ok {
- writeln!(stdout, " ✔").unwrap();
- // Don't clear the line when in verbose mode or when the reference image
- // was updated, to show in the output which test had its image updated.
- if !updated && !args.verbose && stdout.is_terminal() {
- // ANSI escape codes: cursor moves up and clears the line.
- write!(stdout, "\x1b[1A\x1b[2K").unwrap();
- }
- } else {
- writeln!(stdout, " ❌").unwrap();
- }
- if updated {
- writeln!(stdout, " Updated reference image.").unwrap();
- }
- if !output.is_empty() {
- stdout.write_all(output.as_bytes()).unwrap();
- }
- }
-
- ok
}
-fn update_image(png_path: &Path, ref_path: &Path) {
- oxipng::optimize(
- &InFile::Path(png_path.to_owned()),
- &OutFile::from_path(ref_path.to_owned()),
- &Options::max_compression(),
- )
- .unwrap();
-}
-
-#[allow(clippy::too_many_arguments)]
-fn test_part(
- output: &mut String,
- world: &mut TestWorld,
- src_path: &Path,
- text: String,
- line: usize,
- i: usize,
- header_configuration: &TestConfig,
- rng: &mut LinearShift,
- verbose: bool,
-) -> (bool, bool, Vec<Page>) {
- let source = world.set(src_path, text);
- if world.print.syntax {
- writeln!(output, "Syntax Tree:\n{:#?}\n", source.root()).unwrap();
- }
-
- if world.print.model {
- print_model(world, &source, output);
- }
-
- let mut tracer = Tracer::new();
- let (mut frames, diagnostics) = match typst::compile(world, &mut tracer) {
- Ok(document) => (document.pages, tracer.warnings()),
+fn test() {
+ let (tests, skipped) = match crate::collect::collect() {
+ Ok(output) => output,
Err(errors) => {
- let mut warnings = tracer.warnings();
- warnings.extend(errors);
- (vec![], warnings)
- }
- };
-
- let metadata = parse_part_metadata(&source, false);
- match metadata {
- Ok(metadata) => {
- let mut ok = true;
- let compare_ref = metadata
- .config
- .compare_ref
- .unwrap_or(header_configuration.compare_ref.unwrap_or(true));
- let validate_hints = metadata
- .config
- .validate_hints
- .unwrap_or(header_configuration.validate_hints.unwrap_or(true));
- let validate_autocomplete = metadata
- .config
- .validate_autocomplete
- .unwrap_or(header_configuration.validate_autocomplete.unwrap_or(false));
-
- if verbose {
- writeln!(output, "Subtest {i} runs with compare_ref={compare_ref}; validate_hints={validate_hints}; validate_autocomplete={validate_autocomplete};").unwrap();
- }
- ok &= test_spans(output, source.root());
- ok &= test_reparse(output, source.text(), i, rng);
-
- // Don't retain frames if we don't want to compare with reference images.
- if !compare_ref {
- frames.clear();
- }
-
- // we never check autocomplete and error at the same time
-
- let diagnostic_annotations = metadata
- .annotations
- .iter()
- .filter(|a| {
- !matches!(
- a.kind,
- AnnotationKind::AutocompleteContains
- | AnnotationKind::AutocompleteExcludes
- )
- })
- .cloned()
- .collect::<HashSet<_>>();
-
- if validate_autocomplete {
- // warns and ignores diagnostics
- if !diagnostic_annotations.is_empty() {
- writeln!(
- output,
- " Subtest {i} contains diagnostics but is in autocomplete mode."
- )
- .unwrap();
- for annotation in diagnostic_annotations {
- write!(output, " Ignored | ").unwrap();
- print_annotation(output, &source, line, &annotation);
- }
- }
-
- test_autocomplete(
- output,
- world,
- &source,
- line,
- i,
- &mut ok,
- metadata.annotations.iter(),
- );
- } else {
- test_diagnostics(
- output,
- world,
- &source,
- line,
- i,
- &mut ok,
- validate_hints,
- diagnostics.iter(),
- &diagnostic_annotations,
- );
- }
-
- (ok, compare_ref, frames)
- }
- Err(invalid_data) => {
- writeln!(output, " Subtest {i} has invalid metadata, failing the test:")
- .unwrap();
- InvalidMetadata::write(
- invalid_data,
- output,
- &mut |annotation: &Annotation, output: &mut String| {
- print_annotation(output, &source, line, annotation)
- },
- );
-
- (false, false, frames)
- }
- }
-}
-
-#[allow(clippy::too_many_arguments)]
-fn test_autocomplete<'a>(
- output: &mut String,
- world: &mut TestWorld,
- source: &Source,
- line: usize,
- i: usize,
- ok: &mut bool,
- annotations: impl Iterator<Item = &'a Annotation>,
-) {
- for annotation in annotations.filter(|a| {
- matches!(
- a.kind,
- AnnotationKind::AutocompleteContains | AnnotationKind::AutocompleteExcludes
- )
- }) {
- // Ok cause we checked in parsing that range was Some for this annotation
- let cursor = annotation.range.as_ref().unwrap().start;
-
- // todo, use document if is_some to test labels autocomplete
- let completions = typst_ide::autocomplete(world, None, source, cursor, true)
- .map(|(_, c)| c)
- .unwrap_or_default()
- .into_iter()
- .map(|c| c.label.to_string())
- .collect::<HashSet<_>>();
- let completions =
- completions.iter().map(|s| s.as_str()).collect::<HashSet<&str>>();
-
- let must_contain_or_exclude = parse_string_list(&annotation.text);
- let missing =
- must_contain_or_exclude.difference(&completions).collect::<Vec<_>>();
-
- if !missing.is_empty()
- && matches!(annotation.kind, AnnotationKind::AutocompleteContains)
- {
- writeln!(output, " Subtest {i} does not match expected completions.")
- .unwrap();
- write!(output, " for annotation | ").unwrap();
- print_annotation(output, source, line, annotation);
-
- write!(output, " Not contained // ").unwrap();
- for item in missing {
- write!(output, "{item:?}, ").unwrap()
+ eprintln!("failed to collect tests");
+ for error in errors {
+ eprintln!("❌ {error}");
}
- writeln!(output).unwrap();
- *ok = false;
+ std::process::exit(1);
}
-
- let undesired =
- must_contain_or_exclude.intersection(&completions).collect::<Vec<_>>();
-
- if !undesired.is_empty()
- && matches!(annotation.kind, AnnotationKind::AutocompleteExcludes)
- {
- writeln!(output, " Subtest {i} does not match expected completions.")
- .unwrap();
- write!(output, " for annotation | ").unwrap();
- print_annotation(output, source, line, annotation);
-
- write!(output, " Not excluded // ").unwrap();
- for item in undesired {
- write!(output, "{item:?}, ").unwrap()
- }
- writeln!(output).unwrap();
- *ok = false;
- }
- }
-}
-
-#[allow(clippy::too_many_arguments)]
-fn test_diagnostics<'a>(
- output: &mut String,
- world: &mut TestWorld,
- source: &Source,
- line: usize,
- i: usize,
- ok: &mut bool,
- validate_hints: bool,
- diagnostics: impl Iterator<Item = &'a SourceDiagnostic>,
- diagnostic_annotations: &HashSet<Annotation>,
-) {
- // Map diagnostics to range and message format, discard traces and errors from
- // other files, collect hints.
- //
- // This has one caveat: due to the format of the expected hints, we can not
- // verify if a hint belongs to a diagnostic or not. That should be irrelevant
- // however, as the line of the hint is still verified.
- let mut actual_diagnostics = HashSet::new();
- for diagnostic in diagnostics {
- // Ignore diagnostics from other files.
- if diagnostic.span.id().is_some_and(|id| id != source.id()) {
- continue;
- }
-
- let annotation = Annotation {
- kind: match diagnostic.severity {
- Severity::Error => AnnotationKind::Error,
- Severity::Warning => AnnotationKind::Warning,
- },
- range: world.range(diagnostic.span),
- text: diagnostic.message.replace("\\", "/"),
- };
-
- if validate_hints {
- for hint in &diagnostic.hints {
- actual_diagnostics.insert(Annotation {
- kind: AnnotationKind::Hint,
- text: hint.clone(),
- range: annotation.range.clone(),
- });
- }
- }
-
- actual_diagnostics.insert(annotation);
- }
-
- // Basically symmetric_difference, but we need to know where an item is coming from.
- let mut unexpected_outputs = actual_diagnostics
- .difference(diagnostic_annotations)
- .collect::<Vec<_>>();
- let mut missing_outputs = diagnostic_annotations
- .difference(&actual_diagnostics)
- .collect::<Vec<_>>();
-
- unexpected_outputs.sort_by_key(|&v| v.range.as_ref().map(|r| r.start));
- missing_outputs.sort_by_key(|&v| v.range.as_ref().map(|r| r.start));
-
- // This prints all unexpected emits first, then all missing emits.
- // Is this reasonable or subject to change?
- if !(unexpected_outputs.is_empty() && missing_outputs.is_empty()) {
- writeln!(output, " Subtest {i} does not match expected errors.").unwrap();
- *ok = false;
-
- for unexpected in unexpected_outputs {
- write!(output, " Not annotated // ").unwrap();
- print_annotation(output, source, line, unexpected)
- }
-
- for missing in missing_outputs {
- write!(output, " Not emitted // ").unwrap();
- print_annotation(output, source, line, missing)
- }
- }
-}
-
-fn print_model(world: &mut TestWorld, source: &Source, output: &mut String) {
- let world = (world as &dyn World).track();
- let route = typst::engine::Route::default();
- let mut tracer = typst::eval::Tracer::new();
-
- let module =
- typst::eval::eval(world, route.track(), tracer.track_mut(), source).unwrap();
- writeln!(output, "Model:\n{:#?}\n", module.content()).unwrap();
-}
-
-fn print_annotation(
- output: &mut String,
- source: &Source,
- line: usize,
- annotation: &Annotation,
-) {
- let Annotation { range, text, kind } = annotation;
- write!(output, "{kind}: ").unwrap();
- if let Some(range) = range {
- let start_line = 1 + line + source.byte_to_line(range.start).unwrap();
- let start_col = 1 + source.byte_to_column(range.start).unwrap();
- let end_line = 1 + line + source.byte_to_line(range.end).unwrap();
- let end_col = 1 + source.byte_to_column(range.end).unwrap();
- write!(output, "{start_line}:{start_col}-{end_line}:{end_col} ").unwrap();
- }
- writeln!(output, "{text}").unwrap();
-}
-
-/// Pseudorandomly edit the source file and test whether a reparse produces the
-/// same result as a clean parse.
-///
-/// The method will first inject 10 strings once every 400 source characters
-/// and then select 5 leaf node boundaries to inject an additional, randomly
-/// chosen string from the injection list.
-fn test_reparse(
- output: &mut String,
- text: &str,
- i: usize,
- rng: &mut LinearShift,
-) -> bool {
- let supplements = [
- "[",
- "]",
- "{",
- "}",
- "(",
- ")",
- "#rect()",
- "a word",
- ", a: 1",
- "10.0",
- ":",
- "if i == 0 {true}",
- "for",
- "* hello *",
- "//",
- "/*",
- "\\u{12e4}",
- "```typst",
- " ",
- "trees",
- "\\",
- "$ a $",
- "2.",
- "-",
- "5",
- ];
-
- let mut ok = true;
- let mut apply = |replace: Range<usize>, with| {
- let mut incr_source = Source::detached(text);
- if incr_source.root().len() != text.len() {
- println!(
- " Subtest {i} tree length {} does not match string length {} ❌",
- incr_source.root().len(),
- text.len(),
- );
- return false;
- }
-
- incr_source.edit(replace.clone(), with);
-
- let edited_src = incr_source.text();
- let ref_source = Source::detached(edited_src);
- let ref_root = ref_source.root();
- let incr_root = incr_source.root();
-
- // Ensures that the span numbering invariants hold.
- let spans_ok = test_spans(output, ref_root) && test_spans(output, incr_root);
-
- // Ensure that the reference and incremental trees are the same.
- let tree_ok = ref_root.spanless_eq(incr_root);
-
- if !tree_ok {
- writeln!(
- output,
- " Subtest {i} reparse differs from clean parse when inserting '{with}' at {}-{} ❌\n",
- replace.start, replace.end,
- ).unwrap();
- writeln!(output, " Expected reference tree:\n{ref_root:#?}\n").unwrap();
- writeln!(output, " Found incremental tree:\n{incr_root:#?}").unwrap();
- writeln!(
- output,
- " Full source ({}):\n\"{edited_src:?}\"",
- edited_src.len()
- )
- .unwrap();
- }
-
- spans_ok && tree_ok
- };
-
- let mut pick = |range: Range<usize>| {
- let ratio = rng.next();
- (range.start as f64 + ratio * (range.end - range.start) as f64).floor() as usize
};
- let insertions = (text.len() as f64 / 400.0).ceil() as usize;
- for _ in 0..insertions {
- let supplement = supplements[pick(0..supplements.len())];
- let start = pick(0..text.len());
- let end = pick(start..text.len());
-
- if !text.is_char_boundary(start) || !text.is_char_boundary(end) {
- continue;
- }
-
- ok &= apply(start..end, supplement);
- }
-
- let source = Source::detached(text);
- let leafs = leafs(source.root());
- let start = source.find(leafs[pick(0..leafs.len())].span()).unwrap().offset();
- let supplement = supplements[pick(0..supplements.len())];
- ok &= apply(start..start, supplement);
-
- ok
-}
-
-/// Returns all leaf descendants of a node (may include itself).
-fn leafs(node: &SyntaxNode) -> Vec<SyntaxNode> {
- if node.children().len() == 0 {
- vec![node.clone()]
- } else {
- node.children().flat_map(leafs).collect()
- }
-}
-
-/// Ensure that all spans are properly ordered (and therefore unique).
-#[track_caller]
-fn test_spans(output: &mut String, root: &SyntaxNode) -> bool {
- test_spans_impl(output, root, 0..u64::MAX)
-}
-
-#[track_caller]
-fn test_spans_impl(output: &mut String, node: &SyntaxNode, within: Range<u64>) -> bool {
- if !within.contains(&node.span().number()) {
- writeln!(output, " Node: {node:#?}").unwrap();
- writeln!(
- output,
- " Wrong span order: {} not in {within:?} ❌",
- node.span().number()
- )
- .unwrap();
- }
-
- let start = node.span().number() + 1;
- let mut children = node.children().peekable();
- while let Some(child) = children.next() {
- let end = children.peek().map_or(within.end, |next| next.span().number());
- if !test_spans_impl(output, child, start..end) {
- return false;
- }
- }
-
- true
-}
-
-/// Draw all frames into one image with padding in between.
-fn render(document: &Document) -> sk::Pixmap {
- let pixel_per_pt = 2.0;
- let padding = Abs::pt(5.0);
-
- for page in &document.pages {
- let limit = Abs::cm(100.0);
- if page.frame.width() > limit || page.frame.height() > limit {
- panic!("overlarge frame: {:?}", page.frame.size());
- }
- }
-
- let mut pixmap = typst_render::render_merged(
- document,
- pixel_per_pt,
- Color::WHITE,
- padding,
- Color::BLACK,
- );
-
- let padding = (pixel_per_pt * padding.to_pt() as f32).round();
- let [x, mut y] = [padding; 2];
- for page in &document.pages {
- let ts =
- sk::Transform::from_scale(pixel_per_pt, pixel_per_pt).post_translate(x, y);
- render_links(&mut pixmap, ts, &page.frame);
- y += (pixel_per_pt * page.frame.height().to_pt() as f32).round().max(1.0)
- + padding;
- }
-
- pixmap
-}
-
-/// Draw extra boxes for links so we can see whether they are there.
-fn render_links(canvas: &mut sk::Pixmap, ts: sk::Transform, frame: &Frame) {
- for (pos, item) in frame.items() {
- let ts = ts.pre_translate(pos.x.to_pt() as f32, pos.y.to_pt() as f32);
- match *item {
- FrameItem::Group(ref group) => {
- let ts = ts.pre_concat(to_sk_transform(&group.transform));
- render_links(canvas, ts, &group.frame);
- }
- FrameItem::Meta(Meta::Link(_), size) => {
- let w = size.x.to_pt() as f32;
- let h = size.y.to_pt() as f32;
- let rect = sk::Rect::from_xywh(0.0, 0.0, w, h).unwrap();
- let mut paint = sk::Paint::default();
- paint.set_color_rgba8(40, 54, 99, 40);
- canvas.fill_rect(rect, &paint, ts, None);
- }
- _ => {}
- }
- }
-}
-
-fn to_sk_transform(transform: &Transform) -> sk::Transform {
- let Transform { sx, ky, kx, sy, tx, ty } = *transform;
- sk::Transform::from_row(
- sx.get() as _,
- ky.get() as _,
- kx.get() as _,
- sy.get() as _,
- tx.to_pt() as f32,
- ty.to_pt() as f32,
- )
-}
-
-/// A Linear-feedback shift register using XOR as its shifting function.
-/// Can be used as PRNG.
-struct LinearShift(u64);
-
-impl LinearShift {
- /// Initialize the shift register with a pre-set seed.
- pub fn new() -> Self {
- Self(0xACE5)
- }
-
- /// Return a pseudo-random number between `0.0` and `1.0`.
- pub fn next(&mut self) -> f64 {
- self.0 ^= self.0 >> 3;
- self.0 ^= self.0 << 14;
- self.0 ^= self.0 >> 28;
- self.0 ^= self.0 << 36;
- self.0 ^= self.0 >> 52;
- self.0 as f64 / u64::MAX as f64
+ let filtered = tests.len();
+ if filtered == 0 {
+ eprintln!("no test selected");
+ return;
+ }
+
+ // Run the tests.
+ let logger = Mutex::new(Logger::new(filtered, skipped));
+ std::thread::scope(|scope| {
+ let logger = &logger;
+ let (sender, receiver) = std::sync::mpsc::channel();
+
+ // Regularly refresh the logger in case we make no progress.
+ scope.spawn(move || {
+ while receiver.recv_timeout(Duration::from_millis(500)).is_err() {
+ logger.lock().refresh();
+ }
+ });
+
+ // Run the tests.
+ //
+ // We use `par_bridge` instead of `par_iter` because the former
+ // results in a stack overflow during PDF export. Probably related
+ // to `typst::util::Deferred` yielding.
+ tests.iter().par_bridge().for_each(|test| {
+ logger.lock().start(test);
+ let result = std::panic::catch_unwind(|| run::run(test));
+ logger.lock().end(test, result);
+ });
+
+ sender.send(()).unwrap();
+ });
+
+ let passed = logger.into_inner().finish();
+ if !passed {
+ std::process::exit(1);
}
}