diff --git a/DESCRIPTION b/DESCRIPTION index 00f9dc6..db22c1b 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: squash Title: Several Quarto As Single HTML -Version: 1.2.0 +Version: 1.3.0 Authors@R: c( person("Antoine", "Languillaume", , "antoine@thinkr.fr", role = c("aut", "cre")), person("Colin", "Fay", , "colin@thinkr.fr", role = "aut"), diff --git a/NEWS.md b/NEWS.md index d2741be..92a6a62 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,10 @@ +# squash 1.3.0 + +* Each qmd is now rendered inside an isolated throwaway copy of the quarto project (option `squash.isolate_render`, `TRUE` by default). Concurrent renders no longer share the project state (`.quarto/` crossref index, `_extensions/` resolution), which removes the random `Failed to render` errors of parallel compilation (quarto-dev/quarto-cli#2749). +* Isolation falls back to rendering in place whenever it is not possible: no quarto project above the qmd, project with a project-level `output-dir` (websites, books), or filesystem without symlink support (e.g. Windows without the symlink privilege). Every filesystem step of the isolation is checked so a partial isolated project can never be rendered silently. +* Outputs are copied back to the real chapter when they are new or updated, so re-renders refresh files left by a previous run. +* `render_single_qmd()` now reports the underlying error message instead of discarding it. + # squash 1.2.0 * The rendering of the Qmd is now performed insistently with purrr::insistently(), and can be configured in the rendering functions. diff --git a/R/isolate_qmd_render.R b/R/isolate_qmd_render.R new file mode 100644 index 0000000..2358c8c --- /dev/null +++ b/R/isolate_qmd_render.R @@ -0,0 +1,327 @@ +#' Build an isolated quarto project to render a single qmd +#' +#' Concurrent `quarto render` processes sharing one quarto project race on the +#' project state (`.quarto/`, `_extensions/` resolution), which surfaces as +#' transient "Unable to read the extension" failures and corrupted crossref +#' indexes. This helper materializes a throwaway project in `tempdir()` where +#' the render can run alone: the repository tree is exposed through symlinks +#' (so `here::here()` and data paths keep resolving), while the project +#' configuration (`_quarto*.yml`), `_extensions/` and the chapter of the +#' target qmd are real private copies. Each isolated render therefore gets +#' its own fresh `.quarto/` state. +#' +#' Every filesystem step is checked: on any failure (symlinks forbidden, +#' restricted mounts, Windows without the symlink privilege) an error is +#' raised so the caller can fall back to rendering in place. Projects that +#' define a `project: output-dir:` (websites, books) are refused as well, +#' because their outputs land outside the chapter and could not be collected. +#' +#' @param qmd character. Path to the qmd file to render. +#' +#' @return A list with `iso_qmd` (path of the qmd inside the isolated +#' project), `iso_chapter`, `real_chapter`, `iso_root` and +#' `files_before` (inventory of the isolated chapter before rendering, +#' with modification time and size). +#' +#' @noRd +build_isolated_project <- function(qmd) { + qmd <- normalizePath(qmd, mustWork = TRUE) + chapter <- dirname(qmd) + + proj <- chapter + while (!any(file.exists(file.path(proj, c("_quarto.yml", "_quarto.yaml"))))) { + parent <- dirname(proj) + if (identical(parent, proj)) { + stop( + errorCondition( + message = paste0("No quarto project found above ", qmd), + class = c("squash_no_project", "error", "condition") + ) + ) + } + proj <- parent + } + + refuse_project_with_output_dir(proj = proj) + + iso <- tempfile(pattern = "squash_iso_") + dir_create_or_stop(iso) + cleanup_armed <- TRUE + on.exit( + { + if (cleanup_armed) { + unlink(iso, recursive = TRUE) + } + }, + add = TRUE + ) + + # repository level: symlink everything except the quarto project dir. + # The exposed root is bounded to the enclosing repository (.git / .here + # anchor): without such an anchor, nothing above the project is exposed. + root <- find_repository_root(proj = proj) + if (!is.null(root)) { + rel_proj_parts <- split_path_below(root = root, descendant = proj) + iso_cur <- iso + real_cur <- root + for (i in seq_along(rel_proj_parts)) { + link_siblings_or_stop( + real_dir = real_cur, + iso_dir = iso_cur, + keep_real = rel_proj_parts[i] + ) + real_cur <- file.path(real_cur, rel_proj_parts[i]) + iso_cur <- file.path(iso_cur, rel_proj_parts[i]) + if (i < length(rel_proj_parts)) { + dir_create_or_stop(iso_cur) + } + } + iso_proj <- iso_cur + } else { + iso_proj <- file.path(iso, basename(proj)) + } + + # make sure here::here() anchors on the isolated root + if (!file.exists(file.path(iso, ".here"))) { + if (!file.create(file.path(iso, ".here"))) { + stop("Could not create ", file.path(iso, ".here")) + } + } + + # project level: private copies of config and extensions, own .quarto + dir_create_or_stop(iso_proj) + rel_parts <- split_path_below(root = proj, descendant = chapter) + first_kept <- if (length(rel_parts) > 0) { + rel_parts[1] + } else { + NULL + } + for (entry in list.files(proj, all.files = TRUE, no.. = TRUE)) { + if (entry %in% c(".quarto", "_freeze")) { + next + } + if (identical(entry, first_kept)) { + next + } + is_config <- grepl("^_quarto.*\\.ya?ml$", entry) || entry == "_extensions" + # when the qmd sits directly in the project dir, the project dir IS the + # chapter: plain files must be real private copies as well + is_chapter_file <- length(rel_parts) == 0 && + !dir.exists(file.path(proj, entry)) + if (is_config || is_chapter_file) { + copy_or_stop( + from = file.path(proj, entry), + to = iso_proj, + recursive = TRUE + ) + } else { + link_or_stop( + from = file.path(proj, entry), + to = file.path(iso_proj, entry) + ) + } + } + + # descend to the chapter: intermediate levels are real dirs with + # symlinked siblings, the chapter itself is a real copy + iso_cur <- iso_proj + real_cur <- proj + for (i in seq_along(rel_parts)) { + real_cur <- file.path(real_cur, rel_parts[i]) + iso_next <- file.path(iso_cur, rel_parts[i]) + dir_create_or_stop(iso_next) + if (i < length(rel_parts)) { + link_siblings_or_stop( + real_dir = real_cur, + iso_dir = iso_next, + keep_real = rel_parts[i + 1] + ) + } else { + entries <- list.files( + real_cur, + full.names = TRUE, + all.files = TRUE, + no.. = TRUE + ) + if (length(entries) > 0) { + copy_or_stop(from = entries, to = iso_next, recursive = TRUE) + } + } + iso_cur <- iso_next + } + + iso_chapter <- iso_cur + + cleanup_armed <- FALSE + return( + list( + iso_qmd = file.path(iso_chapter, basename(qmd)), + iso_chapter = iso_chapter, + real_chapter = chapter, + iso_root = iso, + files_before = chapter_inventory(iso_chapter) + ) + ) +} + +#' Copy files produced or updated by an isolated render back to the real chapter +#' +#' Compares the isolated chapter with the inventory captured before rendering: +#' files that are new, or whose size or modification time changed, are copied +#' back (so a re-render refreshes outputs that already existed). +#' +#' @param isolation list. As returned by [build_isolated_project()]. +#' +#' @return Invisibly, the copied file paths. +#' +#' @noRd +collect_isolated_outputs <- function(isolation) { + after <- chapter_inventory(isolation$iso_chapter) + before <- isolation$files_before + + is_new <- !(after$path %in% before$path) + matched <- match(after$path, before$path) + is_changed <- !is_new & + (after$mtime != before$mtime[matched] | after$size != before$size[matched]) + to_copy <- after$path[is_new | is_changed] + + for (rel_file in to_copy) { + dest <- file.path(isolation$real_chapter, rel_file) + dir.create(dirname(dest), recursive = TRUE, showWarnings = FALSE) + file.copy( + from = file.path(isolation$iso_chapter, rel_file), + to = dest, + overwrite = TRUE + ) + } + + return(invisible(file.path(isolation$real_chapter, to_copy))) +} + +#' Inventory of a chapter directory: relative path, mtime, size +#' @noRd +chapter_inventory <- function(dir) { + paths <- list.files( + dir, + recursive = TRUE, + all.files = TRUE, + full.names = FALSE + ) + info <- file.info(file.path(dir, paths)) + return( + data.frame( + path = paths, + mtime = as.numeric(info$mtime), + size = info$size, + stringsAsFactors = FALSE + ) + ) +} + +#' Nearest ancestor of the project marking a repository root (.git or .here) +#' +#' Returns NULL when no marker exists above the project: in that case nothing +#' above the project directory is exposed in the isolated tree. +#' +#' @noRd +find_repository_root <- function(proj) { + cur <- dirname(proj) + while (!identical(cur, dirname(cur))) { + if (any(file.exists(file.path(cur, c(".git", ".here"))))) { + return(cur) + } + cur <- dirname(cur) + } + return(NULL) +} + +#' Split the path of `descendant` relative to `root` into components +#' @noRd +split_path_below <- function(root, descendant) { + if (identical(root, descendant)) { + return(character(0)) + } + rel <- substring(descendant, nchar(root) + 2) + return(strsplit(rel, split = "/", fixed = TRUE)[[1]]) +} + +#' Refuse isolation for projects rendering into a project-level output-dir +#' @importFrom yaml read_yaml +#' @noRd +refuse_project_with_output_dir <- function(proj) { + configs <- list.files( + proj, + pattern = "^_quarto.*\\.ya?ml$", + full.names = TRUE + ) + for (config in configs) { + parsed <- tryCatch( + expr = { + read_yaml(config) + }, + error = \(error_message) { + NULL + } + ) + if (!is.null(parsed$project$`output-dir`)) { + stop( + errorCondition( + message = paste0( + "Project ", proj, " renders into an output-dir (", + parsed$project$`output-dir`, + "), its outputs could not be collected from an isolated copy" + ), + class = c("squash_no_isolation", "error", "condition") + ) + ) + } + } + return(invisible(NULL)) +} + +#' dir.create that errors on failure +#' @noRd +dir_create_or_stop <- function(path) { + if (!dir.create(path, showWarnings = FALSE)) { + stop("Could not create directory ", path) + } + return(invisible(path)) +} + +#' file.symlink that errors on failure +#' @noRd +link_or_stop <- function(from, to) { + ok <- suppressWarnings(file.symlink(from = from, to = to)) + if (!isTRUE(all(ok))) { + stop("Could not symlink ", from, " -> ", to) + } + return(invisible(to)) +} + +#' file.copy that errors on failure +#' @noRd +copy_or_stop <- function(from, to, recursive = FALSE) { + ok <- file.copy(from = from, to = to, recursive = recursive) + if (!isTRUE(all(ok))) { + stop( + "Could not copy ", + paste(from[!ok], collapse = ", "), + " into ", to + ) + } + return(invisible(to)) +} + +#' Symlink every entry of real_dir into iso_dir, except keep_real +#' @noRd +link_siblings_or_stop <- function(real_dir, iso_dir, keep_real) { + for (entry in list.files(real_dir, all.files = TRUE, no.. = TRUE)) { + if (!identical(entry, keep_real)) { + link_or_stop( + from = file.path(real_dir, entry), + to = file.path(iso_dir, entry) + ) + } + } + return(invisible(iso_dir)) +} diff --git a/R/render_single_qmd.R b/R/render_single_qmd.R index 7f947ca..8d42e1e 100644 --- a/R/render_single_qmd.R +++ b/R/render_single_qmd.R @@ -6,6 +6,7 @@ #' @param img_root_dir character. Path to the main image folder to extract media to #' @param metadata list. List of metadata to be used for rendering single qmd file #' @param purrr_insistently_rate_backoff function. Function to use to retry rendering qmd files in case of failure. Should be a purrr::rate_backoff function. +#' @param isolate logical. If TRUE (default, controlled by option "squash.isolate_render"), render inside a throwaway copy of the quarto project so concurrent renders do not share project state. Outputs are copied back to the real chapter afterwards. When isolation is not possible (no quarto project above the qmd, project with a project-level output-dir, filesystem without symlink support), the qmd is rendered in place as before. #' #' @inheritParams compile_qmd_course #' @@ -51,7 +52,8 @@ render_single_qmd <- function( purrr_insistently_rate_backoff = purrr::rate_backoff( pause_base = 0.1, max_times = 5 - ) + ), + isolate = getOption("squash.isolate_render", TRUE) ) { # set image sub-folder name chapter <- dirname(qmd) @@ -66,22 +68,64 @@ render_single_qmd <- function( rate = purrr_insistently_rate_backoff ) + # concurrent renders sharing one quarto project race on the project + # state, so each render runs in its own throwaway copy of the project + isolation <- NULL + if (isTRUE(isolate)) { + isolation <- tryCatch( + expr = { + build_isolated_project(qmd) + }, + squash_no_project = \(condition) { + # no quarto project above the qmd: there is no shared project + # state to isolate from, rendering in place is the normal path + NULL + }, + squash_no_isolation = \(condition) { + cli_alert_info( + "{conditionMessage(condition)}, rendering in place" + ) + NULL + }, + error = \(error_message) { + cli_alert_warning( + "Could not isolate {qmd} ({conditionMessage(error_message)}), rendering in place" + ) + NULL + } + ) + } + + input <- if (is.null(isolation)) { + qmd + } else { + isolation$iso_qmd + } + # try rendering qmd and warn user if successful / fail tryCatch( expr = { quarto_render_insistently( - input = qmd, + input = input, metadata = c(metadata, list(`extract-media` = img_dir)), output_format = output_format, quiet = quiet ) + if (!is.null(isolation)) { + collect_isolated_outputs(isolation) + } return(TRUE) }, error = \(error_message) { - cli_alert_danger("Failed to render {qmd}, cleaning and existing") - - # throw an error with original error message + cli_alert_danger( + "Failed to render {qmd} ({conditionMessage(error_message)})" + ) return(FALSE) + }, + finally = { + if (!is.null(isolation)) { + unlink(isolation$iso_root, recursive = TRUE) + } } ) } diff --git a/man/render_single_qmd.Rd b/man/render_single_qmd.Rd index 1d17d00..281c811 100644 --- a/man/render_single_qmd.Rd +++ b/man/render_single_qmd.Rd @@ -10,7 +10,8 @@ render_single_qmd( output_format = "revealjs", metadata = NULL, quiet = TRUE, - purrr_insistently_rate_backoff = purrr::rate_backoff(pause_base = 0.1, max_times = 5) + purrr_insistently_rate_backoff = purrr::rate_backoff(pause_base = 0.1, max_times = 5), + isolate = getOption("squash.isolate_render", TRUE) ) } \arguments{ @@ -25,6 +26,8 @@ render_single_qmd( \item{quiet}{logical. Output info in user console} \item{purrr_insistently_rate_backoff}{function. Function to use to retry rendering qmd files in case of failure. Should be a purrr::rate_backoff function.} + +\item{isolate}{logical. If TRUE (default, controlled by option "squash.isolate_render"), render inside a throwaway copy of the quarto project so concurrent renders do not share project state. Outputs are copied back to the real chapter afterwards. When isolation is not possible (no quarto project above the qmd, project with a project-level output-dir, filesystem without symlink support), the qmd is rendered in place as before.} } \value{ logical. TRUE if rendering succeeded, FALSE otherwise. Side effect : render qmd as html diff --git a/tests/testthat/test-compile_qmd_course.R b/tests/testthat/test-compile_qmd_course.R index d744540..41bd816 100644 --- a/tests/testthat/test-compile_qmd_course.R +++ b/tests/testthat/test-compile_qmd_course.R @@ -375,6 +375,25 @@ skip_if_no_chrome <- function() { if (inherits(has_chrome, "try-error")) { skip("chrome not installed") } + # finding the binary is not enough: probe that a headless session + # can actually be established (fails on some CI runners) + probe_html <- tempfile(fileext = ".html") + writeLines("probe", probe_html) + probe_pdf <- try( + { + pagedown::chrome_print( + input = probe_html, + output = tempfile(fileext = ".pdf"), + timeout = 30 + ) + }, + silent = TRUE + ) + unlink(probe_html) + if (inherits(probe_pdf, "try-error")) { + skip("chrome is installed but headless printing is not functional here") + } + unlink(probe_pdf) } run_with_multiple_quarto <- function(expr) { diff --git a/tests/testthat/test-isolate_qmd_render.R b/tests/testthat/test-isolate_qmd_render.R new file mode 100644 index 0000000..1716228 --- /dev/null +++ b/tests/testthat/test-isolate_qmd_render.R @@ -0,0 +1,213 @@ +symlinks_supported <- function() { + probe_dir <- tempfile(pattern = "symlink_probe") + dir.create(probe_dir) + on.exit(unlink(probe_dir, recursive = TRUE), add = TRUE) + target <- file.path(probe_dir, "target") + file.create(target) + ok <- suppressWarnings( + file.symlink(from = target, to = file.path(probe_dir, "link")) + ) + isTRUE(ok) && nzchar(Sys.readlink(file.path(probe_dir, "link"))) +} + +make_fake_repo <- function() { + root <- tempfile(pattern = "iso_repo") + chapter <- file.path(root, "courses", "M01", "S01") + sibling <- file.path(root, "courses", "M01", "S02") + ext <- file.path(root, "courses", "_extensions", "acme", "theme") + + dir.create(chapter, recursive = TRUE) + dir.create(sibling, recursive = TRUE) + dir.create(ext, recursive = TRUE) + file.create(file.path(root, ".here")) + + writeLines("format: revealjs", file.path(root, "courses", "_quarto.yml")) + writeLines("title: theme", file.path(ext, "_extension.yml")) + writeLines("a,b", file.path(root, "data.csv")) + qmd <- file.path(chapter, "doc.qmd") + writeLines("# doc", qmd) + writeLines("img", file.path(chapter, "logo.png")) + writeLines("# other", file.path(sibling, "other.qmd")) + + list(root = root, chapter = chapter, qmd = qmd) +} + +test_that("build_isolated_project creates a private project and keeps the repo reachable", { + skip_if_not(symlinks_supported(), "symlinks are not supported here") + + repo <- make_fake_repo() + on.exit(unlink(repo$root, recursive = TRUE), add = TRUE) + + isolation <- build_isolated_project(repo$qmd) + on.exit(unlink(isolation$iso_root, recursive = TRUE), add = TRUE) + + iso_courses <- file.path(isolation$iso_root, "courses") + + # chapter and its content are real copies + expect_true(file.exists(isolation$iso_qmd)) + expect_false(nzchar(Sys.readlink(isolation$iso_chapter))) + expect_true(file.exists(file.path(isolation$iso_chapter, "logo.png"))) + + # project config and extensions are private copies, not symlinks + expect_true(file.exists(file.path(iso_courses, "_quarto.yml"))) + expect_false(nzchar(Sys.readlink(file.path(iso_courses, "_extensions")))) + expect_true( + file.exists(file.path(iso_courses, "_extensions", "acme", "theme", "_extension.yml")) + ) + + # sibling chapter and repo-level files stay reachable through symlinks + expect_true(nzchar(Sys.readlink(file.path(iso_courses, "M01", "S02")))) + expect_true(file.exists(file.path(iso_courses, "M01", "S02", "other.qmd"))) + expect_true(file.exists(file.path(isolation$iso_root, "data.csv"))) + + # here::here() anchor is present at the isolated root + expect_true(file.exists(file.path(isolation$iso_root, ".here"))) + + # no shared .quarto state is exposed in the isolated project + expect_false(dir.exists(file.path(iso_courses, ".quarto"))) +}) + +test_that("collect_isolated_outputs copies new and updated outputs back", { + skip_if_not(symlinks_supported(), "symlinks are not supported here") + + repo <- make_fake_repo() + on.exit(unlink(repo$root, recursive = TRUE), add = TRUE) + + # a stale output from a previous render already sits in the real chapter + writeLines("OLD", file.path(repo$chapter, "doc.html")) + + isolation <- build_isolated_project(repo$qmd) + on.exit(unlink(isolation$iso_root, recursive = TRUE), add = TRUE) + + # simulate a render: the html is REGENERATED and media appear + Sys.sleep(1.1) + writeLines("NEW", file.path(isolation$iso_chapter, "doc.html")) + media <- file.path(isolation$iso_chapter, "out_img", "S01_img") + dir.create(media, recursive = TRUE) + writeLines("img", file.path(media, "fig.png")) + + collect_isolated_outputs(isolation) + + # updated pre-existing output is refreshed, new files are brought back + expect_identical( + readLines(file.path(repo$chapter, "doc.html")), + "NEW" + ) + expect_true(file.exists(file.path(repo$chapter, "out_img", "S01_img", "fig.png"))) + # untouched inputs are not altered + expect_identical(readLines(file.path(repo$chapter, "doc.qmd")), "# doc") +}) + +test_that("a qmd directly in the project dir gets a real private copy", { + skip_if_not(symlinks_supported(), "symlinks are not supported here") + + root <- tempfile(pattern = "iso_repo") + proj <- file.path(root, "courses") + dir.create(proj, recursive = TRUE) + file.create(file.path(root, ".here")) + writeLines("format: revealjs", file.path(proj, "_quarto.yml")) + qmd <- file.path(proj, "doc.qmd") + writeLines("# doc", qmd) + on.exit(unlink(root, recursive = TRUE), add = TRUE) + + isolation <- build_isolated_project(qmd) + on.exit(unlink(isolation$iso_root, recursive = TRUE), add = TRUE) + + expect_true(file.exists(isolation$iso_qmd)) + expect_false(nzchar(Sys.readlink(isolation$iso_qmd))) +}) + +test_that("nothing above the project is exposed without a repository marker", { + skip_if_not(symlinks_supported(), "symlinks are not supported here") + + base <- tempfile(pattern = "iso_norepo") + proj <- file.path(base, "courses") + chapter <- file.path(proj, "M01") + dir.create(chapter, recursive = TRUE) + # a sibling of the project that must NOT leak into the isolated tree + dir.create(file.path(base, "unrelated")) + writeLines("format: revealjs", file.path(proj, "_quarto.yml")) + qmd <- file.path(chapter, "doc.qmd") + writeLines("# doc", qmd) + on.exit(unlink(base, recursive = TRUE), add = TRUE) + + isolation <- build_isolated_project(qmd) + on.exit(unlink(isolation$iso_root, recursive = TRUE), add = TRUE) + + expect_false(file.exists(file.path(isolation$iso_root, "unrelated"))) + expect_true(file.exists(isolation$iso_qmd)) +}) + +test_that("projects with a project-level output-dir are refused", { + root <- tempfile(pattern = "iso_site") + proj <- file.path(root, "site") + dir.create(proj, recursive = TRUE) + writeLines( + c("project:", " type: website", " output-dir: _site"), + file.path(proj, "_quarto.yml") + ) + qmd <- file.path(proj, "index.qmd") + writeLines("# home", qmd) + on.exit(unlink(root, recursive = TRUE), add = TRUE) + + expect_error( + build_isolated_project(qmd), + class = "squash_no_isolation" + ) +}) + +test_that("an isolated render produces up-to-date output in the real chapter", { + skip_if_not(symlinks_supported(), "symlinks are not supported here") + skip_if_not(quarto::quarto_available(), "quarto is not available") + + # dedicated fixture: a real render requires a well-formed project + # (the fake _extensions of make_fake_repo() would make quarto error) + root <- tempfile(pattern = "iso_render") + chapter <- file.path(root, "courses", "M01", "S01") + dir.create(chapter, recursive = TRUE) + file.create(file.path(root, ".here")) + writeLines("format: revealjs", file.path(root, "courses", "_quarto.yml")) + repo <- list( + root = root, + chapter = chapter, + qmd = file.path(chapter, "doc.qmd") + ) + on.exit(unlink(repo$root, recursive = TRUE), add = TRUE) + writeLines( + c("---", "title: v1", "---", "", "# FIRST-VERSION"), + repo$qmd + ) + + expect_true( + render_single_qmd( + qmd = repo$qmd, + img_root_dir = "iso_img", + output_format = "revealjs" + ) + ) + html <- file.path(repo$chapter, "doc.html") + expect_true(file.exists(html)) + expect_true(any(grepl("FIRST-VERSION", readLines(html, warn = FALSE)))) + + # re-render with modified content: the real chapter must be refreshed + writeLines( + c("---", "title: v2", "---", "", "# SECOND-VERSION"), + repo$qmd + ) + expect_true( + render_single_qmd( + qmd = repo$qmd, + img_root_dir = "iso_img", + output_format = "revealjs" + ) + ) + expect_true(any(grepl("SECOND-VERSION", readLines(html, warn = FALSE)))) + + # no isolated project tree is left behind + leftovers <- list.files( + tempdir(), + pattern = "^squash_iso_", + full.names = TRUE + ) + expect_length(leftovers, 0) +})