From 020294fca9a7065d4b9cf4e677f606ebaaa29b00 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Sat, 13 Apr 2024 10:39:45 +0200 Subject: Better test runner (#3922) --- tests/src/tests.rs | 1179 ++++------------------------------------------------ 1 file changed, 82 insertions(+), 1097 deletions(-) (limited to 'tests/src/tests.rs') 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, - /// Runs only the specified subtest. - #[arg(short, long)] - #[arg(allow_hyphen_values = true)] - subtest: Option, - /// 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 = 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::>(); - - let len = results.len(); - let ok = results.iter().sum::(); - 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 { - if lhs != rhs { - bail!("Assertion failed: {} != {}", lhs.repr(), rhs.repr()); - } - Ok(NoneValue) - } - - #[func] - fn test_repr(lhs: Value, rhs: Value) -> StrResult { - if lhs.repr() != rhs.repr() { - bail!("Assertion failed: {} != {}", lhs.repr(), rhs.repr()); - } - Ok(NoneValue) - } - - #[func] - fn print(#[variadic] values: Vec) -> 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::(); - lib.global.scope_mut().define_func::(); - lib.global.scope_mut().define_func::(); - 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, - book: Prehashed, - fonts: Vec, - slots: RwLock>, -} - -#[derive(Clone)] -struct FileSlot { - source: OnceLock>, - buffer: OnceLock>, -} - -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 { - &self.library - } - - fn book(&self) -> &Prehashed { - &self.book - } - - fn main(&self) -> Source { - self.source(self.main).unwrap() - } - - fn source(&self, id: FileId) -> FileResult { - 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 { - 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 { - Some(self.fonts[id].clone()) - } - - fn today(&self, _: Option) -> Option { - 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(&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 { - 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> { - // 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) { - 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::>(); - - 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, -) { - 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::>(); - let completions = - completions.iter().map(|s| s.as_str()).collect::>(); - - let must_contain_or_exclude = parse_string_list(&annotation.text); - let missing = - must_contain_or_exclude.difference(&completions).collect::>(); - - 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::>(); - - 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, - diagnostic_annotations: &HashSet, -) { - // 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::>(); - let mut missing_outputs = diagnostic_annotations - .difference(&actual_diagnostics) - .collect::>(); - - 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, 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| { - 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 { - 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) -> 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); } } -- cgit v1.2.3