From 31a4decbf5b7978252cff95b271c775f5cde8908 Mon Sep 17 00:00:00 2001 From: christophe dervieux Date: Wed, 20 May 2026 10:56:31 +0200 Subject: [PATCH 01/22] quarto list extensions change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapted for https://github.com/quarto-dev/quarto-r/issues/301 Default is now in v1.9 ```` ❯ quarto list extensions Id Version Contributes julia-engine 0.1.0 orange-book 0.1.0 formats ```` --- tests/testthat/test-list.R | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/testthat/test-list.R b/tests/testthat/test-list.R index 12a8d827..c5e62892 100644 --- a/tests/testthat/test-list.R +++ b/tests/testthat/test-list.R @@ -5,17 +5,19 @@ test_that("Listing extensions", { skip_if_offline("github.com") qmd <- local_qmd_file(c("content")) withr::local_dir(dirname(qmd)) - expect_null(quarto_list_extensions()) + # TODO: Adapt depending on https://github.com/quarto-dev/quarto-r/issues/301 + # expect_null(quarto_list_extensions()) + default <- quarto_list_extensions()$Id quarto_add_extension("quarto-ext/fontawesome", no_prompt = TRUE, quiet = TRUE) expect_true(dir.exists("_extensions/quarto-ext/fontawesome")) expect_identical( - quarto_list_extensions()$Id, + setdiff(quarto_list_extensions()$Id, default), c("quarto-ext/fontawesome") ) quarto_add_extension("quarto-ext/lightbox", no_prompt = TRUE, quiet = TRUE) expect_true(dir.exists("_extensions/quarto-ext/lightbox")) expect_identical( - quarto_list_extensions()$Id, + setdiff(quarto_list_extensions()$Id, default), c("quarto-ext/fontawesome", "quarto-ext/lightbox") ) }) From 318912a131fe295fc69f6ad68124fc4c1fb4f949 Mon Sep 17 00:00:00 2001 From: christophe dervieux Date: Wed, 20 May 2026 11:11:06 +0200 Subject: [PATCH 02/22] snapshot change --- tests/testthat/_snaps/quarto.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/testthat/_snaps/quarto.md b/tests/testthat/_snaps/quarto.md index b5391a47..4b884212 100644 --- a/tests/testthat/_snaps/quarto.md +++ b/tests/testthat/_snaps/quarto.md @@ -8,7 +8,7 @@ Caused by error: x Error returned by quarto CLI. i Rerun with `quiet = FALSE` to see the full error message. - Caused by error: + Caused by error in `processx::run()`: ! System command 'quarto' failed # quarto_run report full quarto cli error message @@ -26,7 +26,7 @@ Stack trace: - Caused by error: + Caused by error in `processx::run()`: ! System command 'quarto' failed # is_using_quarto correctly check directory From e7d6f512da7b1abb8eaa091eeb7acf4143b06cc9 Mon Sep 17 00:00:00 2001 From: christophe dervieux Date: Wed, 20 May 2026 11:53:30 +0200 Subject: [PATCH 03/22] Adapt quarto_remove_extensions Verify now that extension is in the list before trying to remove. And only test for no extension for quarto before v1.9 due to https://github.com/quarto-dev/quarto-r/issues/301 --- R/remove.R | 20 +++++++++++++++++--- tests/testthat/_snaps/remove.md | 2 +- tests/testthat/test-remove.R | 1 + 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/R/remove.R b/R/remove.R index 7dd68a0a..cf47ed3a 100644 --- a/R/remove.R +++ b/R/remove.R @@ -28,11 +28,25 @@ quarto_remove_extension <- function( rlang::check_required(extension) installed_extensions <- quarto_list_extensions() - if (is.null(installed_extensions)) { + # TODO: adapt based on https://github.com/quarto-dev/quarto-r/issues/301 + if (quarto_available(max = "1.9")) { + if (is.null(installed_extensions)) { + if (!quiet) { + cli::cli_alert_warning("No extensions installed.") + } + return(invisible(FALSE)) + } + } + + # Check extension is installed + is_installed <- extension %in% installed_extensions$Id + if (!is_installed) { if (!quiet) { - cli::cli_alert_warning("No extensions installed.") + cli::cli_alert_warning( + "{.str { extension } } is not among installed extensions." + ) + return(invisible(FALSE)) } - return(invisible(FALSE)) } quarto_bin <- find_quarto() diff --git a/tests/testthat/_snaps/remove.md b/tests/testthat/_snaps/remove.md index 52b77684..5293f9b9 100644 --- a/tests/testthat/_snaps/remove.md +++ b/tests/testthat/_snaps/remove.md @@ -3,7 +3,7 @@ Code expect_false(quarto_remove_extension("quarto-ext/fontawesome", no_prompt = TRUE)) Message - ! No extensions installed. + ! "quarto-ext/fontawesome " is not among installed extensions. --- diff --git a/tests/testthat/test-remove.R b/tests/testthat/test-remove.R index 5ccb8bc8..0c93fb5e 100644 --- a/tests/testthat/test-remove.R +++ b/tests/testthat/test-remove.R @@ -3,6 +3,7 @@ test_that("Removing an extension", { skip_if_offline("github.com") qmd <- local_qmd_file(c("content")) withr::local_dir(dirname(qmd)) + local_edition(3) expect_snapshot(expect_false(quarto_remove_extension( "quarto-ext/fontawesome", no_prompt = TRUE From a2526b77281913dd66200e1cf753af034c8dc613 Mon Sep 17 00:00:00 2001 From: christophe dervieux Date: Wed, 20 May 2026 12:58:26 +0200 Subject: [PATCH 04/22] Extract cli_arg_metadata() helper from quarto_render() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure function that builds the --metadata-file CLI args from the metadata list and metadata_file path. Returns the args plus the temp file path (when one was written) so the caller can register cleanup. No behavior change yet — quarto_render() still uses its inline implementation. Next commit wires the helper in. --- R/quarto-args.R | 22 ++++++++ tests/testthat/test-metadata-args.R | 88 +++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 tests/testthat/test-metadata-args.R diff --git a/R/quarto-args.R b/R/quarto-args.R index b3f79011..f34cbad0 100644 --- a/R/quarto-args.R +++ b/R/quarto-args.R @@ -26,3 +26,25 @@ append_cli_args <- function(new, append_to = NULL, after = length(append_to)) { } new } + +cli_arg_metadata <- function(metadata = NULL, metadata_file = NULL) { + if (is.null(metadata) && is.null(metadata_file)) { + return(list(args = character(), tmp_file = NULL)) + } + if (is.null(metadata)) { + return(list( + args = c("--metadata-file", metadata_file), + tmp_file = NULL + )) + } + if (!is.null(metadata_file)) { + file_content <- yaml::read_yaml(metadata_file, eval.expr = FALSE) + metadata <- merge_list(file_content, metadata) + } + tmp <- tempfile(pattern = "quarto-meta", fileext = ".yml") + write_yaml(metadata, tmp) + list( + args = c("--metadata-file", tmp), + tmp_file = tmp + ) +} diff --git a/tests/testthat/test-metadata-args.R b/tests/testthat/test-metadata-args.R new file mode 100644 index 00000000..5f48bc5d --- /dev/null +++ b/tests/testthat/test-metadata-args.R @@ -0,0 +1,88 @@ +test_that("cli_arg_metadata returns empty args when both inputs NULL", { + result <- cli_arg_metadata(metadata = NULL, metadata_file = NULL) + expect_identical(result$args, character()) + expect_null(result$tmp_file) +}) + +test_that("cli_arg_metadata with metadata only writes temp YAML and uses --metadata-file", { + skip_if_not_installed("yaml") + result <- cli_arg_metadata( + metadata = list(title = "test", lang = "fr"), + metadata_file = NULL + ) + withr::defer(unlink(result$tmp_file)) + + expect_length(result$args, 2L) + expect_identical(result$args[1], "--metadata-file") + expect_true(file.exists(result$args[2])) + expect_identical(result$args[2], result$tmp_file) + + written <- yaml::read_yaml(result$tmp_file) + expect_identical(written, list(title = "test", lang = "fr")) +}) + +test_that("cli_arg_metadata with metadata_file only passes path verbatim", { + skip_if_not_installed("withr") + yml <- withr::local_tempfile(fileext = ".yml") + yaml::write_yaml(list(title = "file-title"), yml) + + result <- cli_arg_metadata(metadata = NULL, metadata_file = yml) + expect_identical(result$args, c("--metadata-file", yml)) + expect_null(result$tmp_file) +}) + +test_that("cli_arg_metadata merges metadata over metadata_file with metadata winning", { + skip_if_not_installed("withr") + skip_if_not_installed("yaml") + yml <- withr::local_tempfile(fileext = ".yml") + yaml::write_yaml(list(title = "from-file", other = "kept"), yml) + + result <- cli_arg_metadata( + metadata = list(title = "from-list", new = "added"), + metadata_file = yml + ) + withr::defer(unlink(result$tmp_file)) + + expect_identical(result$args[1], "--metadata-file") + expect_identical(result$args[2], result$tmp_file) + + written <- yaml::read_yaml(result$tmp_file) + expect_identical( + written, + list(title = "from-list", other = "kept", new = "added") + ) +}) + +test_that("cli_arg_metadata preserves nested list structure in temp YAML", { + skip_if_not_installed("yaml") + nested <- list(format = list(html = list(`toc-title` = "Custom"))) + result <- cli_arg_metadata(metadata = nested, metadata_file = NULL) + withr::defer(unlink(result$tmp_file)) + + written <- yaml::read_yaml(result$tmp_file) + expect_identical(written, nested) +}) + +test_that("cli_arg_metadata merge is shallow (top-level keys replaced wholesale)", { + # Documents current behavior - metadata replaces top-level keys entirely. + # If we ever switch to deep merge, this test should be updated, not silently broken. + skip_if_not_installed("yaml") + yml <- withr::local_tempfile(fileext = ".yml") + yaml::write_yaml( + list(format = list(html = list(toc = TRUE, `toc-title` = "Original"))), + yml + ) + + result <- cli_arg_metadata( + metadata = list(format = list(html = list(`toc-title` = "Overridden"))), + metadata_file = yml + ) + withr::defer(unlink(result$tmp_file)) + + written <- yaml::read_yaml(result$tmp_file) + # toc=TRUE from file is LOST because metadata's `format` replaces wholesale + expect_identical( + written, + list(format = list(html = list(`toc-title` = "Overridden"))) + ) +}) From ebab2ee8e1a94f330d578fb5b9de01f00b2d2703 Mon Sep 17 00:00:00 2001 From: christophe dervieux Date: Wed, 20 May 2026 13:07:16 +0200 Subject: [PATCH 05/22] Use cli_arg_metadata() in quarto_render() Replace the inline metadata branch (~14 lines) with a single call to the extracted helper. Behavior preserved: same args produced, same temp file cleanup via on.exit. Explicit metadata_file = NULL is now treated as omitted instead of hitting yaml::read_yaml(NULL). --- NEWS.md | 5 +++++ R/render.R | 16 ++++------------ 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/NEWS.md b/NEWS.md index 1fd8e202..a85291f7 100644 --- a/NEWS.md +++ b/NEWS.md @@ -4,6 +4,11 @@ - Curly braces in Quarto CLI error messages are now escaped to prevent them from being interpreted as `cli` formatting syntax (#293). +- Internal refactor: extracted `--metadata-file` argument construction into a + dedicated `cli_arg_metadata()` helper. No user-visible behavior change except + that an explicit `metadata_file = NULL` is now treated identically to omitting + the argument. + # quarto 1.5.1 - Make sure tests pass on CRAN checks even when Quarto is not installed by adding a gihub action to test when no quarto is available. Also fix tests that were diff --git a/R/render.R b/R/render.R index 8c17e6bd..15f845cb 100644 --- a/R/render.R +++ b/R/render.R @@ -197,18 +197,10 @@ quarto_render <- function( args <- c(args, "--cache-refresh") } # metadata to pass to quarto render - if (!is.null(metadata)) { - # We merge meta if there is metadata_file passed - if (!missing(metadata_file)) { - file_content <- yaml::read_yaml(metadata_file, eval.expr = FALSE) - metadata <- merge_list(file_content, metadata) - } - meta_file <- tempfile(pattern = "quarto-meta", fileext = ".yml") - on.exit(unlink(meta_file), add = TRUE) - write_yaml(metadata, meta_file) - args <- c(args, "--metadata-file", meta_file) - } else if (!missing(metadata_file)) { - args <- c(args, "--metadata-file", metadata_file) + meta_result <- cli_arg_metadata(metadata, metadata_file) + args <- c(args, meta_result$args) + if (!is.null(meta_result$tmp_file)) { + on.exit(unlink(meta_result$tmp_file), add = TRUE) } if (isTRUE(debug)) { args <- c(args, "--debug") From 7f8ec9b50ef74b7ddd8657f4d38e6dda123e5bf6 Mon Sep 17 00:00:00 2001 From: christophe dervieux Date: Wed, 20 May 2026 13:08:33 +0200 Subject: [PATCH 06/22] Fix test also impacted by #301 - quarto list extensions change in v1.9 --- tests/testthat/test-update.R | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/testthat/test-update.R b/tests/testthat/test-update.R index 25263ec2..9eab6c67 100644 --- a/tests/testthat/test-update.R +++ b/tests/testthat/test-update.R @@ -8,7 +8,8 @@ test_that("Updating an extension", { no_prompt = TRUE, quiet = TRUE ) - expect_equal(quarto_list_extensions()$Version, "0.0.1") + # last installed is fontawesome + expect_equal(tail(quarto_list_extensions(), 1)$Version, "0.0.1") quarto_update_extension( "quarto-ext/fontawesome", no_prompt = TRUE, @@ -16,7 +17,9 @@ test_that("Updating an extension", { ) expect_true(dir.exists("_extensions/quarto-ext/fontawesome")) expect_true( - as.numeric_version(current_version <- quarto_list_extensions()$Version) > + as.numeric_version( + current_version <- tail(quarto_list_extensions(), 1)$Version + ) > "0.0.1" ) expect_false(identical(current_version, "0.0.1")) From 0f478b2487c624d8d50b414cbd63756ef3195c36 Mon Sep 17 00:00:00 2001 From: christophe dervieux Date: Wed, 20 May 2026 13:14:02 +0200 Subject: [PATCH 07/22] Add integration test for metadata override via _quarto.yml Renders a project with a _quarto.yml containing a custom-key value and verifies that quarto_render(metadata = ...) overrides the key. A Lua filter strips all metadata except an allowlist so the native-AST snapshot stays small and stable across Quarto versions (no churn from injected engines metadata or similar). Note: toc-title is a format-level option consumed by Quarto before pandoc sees it, so it does not appear in native AST metadata. The test uses a top-level document metadata key instead, which is the correct surface for verifying --metadata-file override behaviour. --- .../metadata-nested-override.test.out | 9 +++ tests/testthat/test-metadata-render.R | 73 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 tests/testthat/_snaps/metadata-render/metadata-nested-override.test.out create mode 100644 tests/testthat/test-metadata-render.R diff --git a/tests/testthat/_snaps/metadata-render/metadata-nested-override.test.out b/tests/testthat/_snaps/metadata-render/metadata-nested-override.test.out new file mode 100644 index 00000000..10007342 --- /dev/null +++ b/tests/testthat/_snaps/metadata-render/metadata-nested-override.test.out @@ -0,0 +1,9 @@ +Pandoc + Meta + { unMeta = + fromList + [ ( "custom-key" , MetaInlines [ Str "overridden" ] ) + , ( "title" , MetaInlines [ Str "Doc" ] ) + ] + } + [] diff --git a/tests/testthat/test-metadata-render.R b/tests/testthat/test-metadata-render.R new file mode 100644 index 00000000..ab5dc8c3 --- /dev/null +++ b/tests/testthat/test-metadata-render.R @@ -0,0 +1,73 @@ +# Integration test for the actual motivation behind switching to +# --metadata-file in PR #52: top-level (non-format-nested) keys from +# _quarto.yml can be overridden, which --metadata key:value cannot do +# (--metadata only reaches keys under the chosen output format). +# Format-nested keys (e.g. format.html.toc-title) are consumed by +# Quarto before reaching Pandoc, so they do not appear in native AST; +# a top-level key is the right surface to demonstrate the override. +# A Lua filter scrubs all metadata except an allowlist so the +# native-AST snapshot stays small and stable across Quarto versions. + +test_that("metadata overrides keys from _quarto.yml", { + skip_if_no_quarto() + skip_if_not_installed("withr") + skip_if_not_installed("xfun") + + proj <- withr::local_tempdir("quarto-metadata-nested-") + + xfun::write_utf8( + c( + "project:", + " type: default", + "format:", + " html:", + " toc-title: Original", + "custom-key: from-quarto-yml" + ), + file.path(proj, "_quarto.yml") + ) + + xfun::write_utf8( + c( + "---", + "title: Doc", + "---", + "", + "body" + ), + file.path(proj, "index.qmd") + ) + + # Lua filter: keep only allowlisted meta keys, drop document body. + # Result is a tiny, deterministic native AST regardless of what else + # Quarto injects (engines, extension paths, etc.). + xfun::write_utf8( + c( + 'function Pandoc(doc)', + ' local wanted = { ["title"] = true, ["custom-key"] = true }', + ' local kept = pandoc.Meta({})', + ' for k, v in pairs(doc.meta) do', + ' if wanted[k] then kept[k] = v end', + ' end', + ' return pandoc.Pandoc({}, kept)', + 'end' + ), + file.path(proj, "extract-meta.lua") + ) + + withr::local_dir(proj) + + quarto_render( + "index.qmd", + output_format = "native", + metadata = list(`custom-key` = "overridden"), + quarto_args = c("--lua-filter", "extract-meta.lua"), + quiet = TRUE + ) + + announce_snapshot_file(name = "metadata-nested-override.test.out") + expect_snapshot_file( + file.path(proj, "index.native"), + "metadata-nested-override.test.out" + ) +}) From 4263945e190644243beb25a2e5ac685d204438b4 Mon Sep 17 00:00:00 2001 From: christophe dervieux Date: Wed, 20 May 2026 13:22:38 +0200 Subject: [PATCH 08/22] Remove obsolete metadata snapshot tests The three native-AST snapshot tests for the metadata/metadata_file args were testing Quarto's downstream Pandoc behavior, not the R wrapper's contribution. They also churned whenever Quarto changed what it injects into native output (most recently an engines MetaList with absolute extension paths). Coverage now lives in: - test-metadata-args.R: unit tests of cli_arg_metadata() (no Quarto) - test-metadata-render.R: one integration test exercising override of a top-level _quarto.yml key via a Lua filter that allowlists the metadata keys we care about, keeping the snapshot stable. --- .../_snaps/render/metadata-file.test.out | 6 --- .../_snaps/render/metadata-merged.test.out | 10 ----- .../testthat/_snaps/render/metadata.test.out | 6 --- tests/testthat/test-render.R | 40 ------------------- 4 files changed, 62 deletions(-) delete mode 100644 tests/testthat/_snaps/render/metadata-file.test.out delete mode 100644 tests/testthat/_snaps/render/metadata-merged.test.out delete mode 100644 tests/testthat/_snaps/render/metadata.test.out diff --git a/tests/testthat/_snaps/render/metadata-file.test.out b/tests/testthat/_snaps/render/metadata-file.test.out deleted file mode 100644 index 2490cc43..00000000 --- a/tests/testthat/_snaps/render/metadata-file.test.out +++ /dev/null @@ -1,6 +0,0 @@ -Pandoc - Meta - { unMeta = - fromList [ ( "title" , MetaInlines [ Str "test" ] ) ] - } - [ Para [ Str "content" ] ] diff --git a/tests/testthat/_snaps/render/metadata-merged.test.out b/tests/testthat/_snaps/render/metadata-merged.test.out deleted file mode 100644 index 4cb8ddd8..00000000 --- a/tests/testthat/_snaps/render/metadata-merged.test.out +++ /dev/null @@ -1,10 +0,0 @@ -Pandoc - Meta - { unMeta = - fromList - [ ( "any" , MetaInlines [ Str "one" ] ) - , ( "other" , MetaInlines [ Str "thing" ] ) - , ( "title" , MetaInlines [ Str "test2" ] ) - ] - } - [ Para [ Str "content" ] ] diff --git a/tests/testthat/_snaps/render/metadata.test.out b/tests/testthat/_snaps/render/metadata.test.out deleted file mode 100644 index 2490cc43..00000000 --- a/tests/testthat/_snaps/render/metadata.test.out +++ /dev/null @@ -1,6 +0,0 @@ -Pandoc - Meta - { unMeta = - fromList [ ( "title" , MetaInlines [ Str "test" ] ) ] - } - [ Para [ Str "content" ] ] diff --git a/tests/testthat/test-render.R b/tests/testthat/test-render.R index d0c6ca38..7bb92958 100644 --- a/tests/testthat/test-render.R +++ b/tests/testthat/test-render.R @@ -11,46 +11,6 @@ test_that("R Markdown documents can be rendered", { unlink("test.html") }) -test_that("metadata argument works in quarto_render", { - skip_if_no_quarto() - qmd <- local_qmd_file(c("content")) - # metadata - expect_snapshot_qmd_output( - name = "metadata", - input = qmd, - output_format = "native", - metadata = list(title = "test") - ) -}) - -test_that("metadata-file argument works in quarto_render", { - skip_if_no_quarto() - skip_if_not_installed("withr") - qmd <- local_qmd_file(c("content")) - yaml <- withr::local_tempfile(fileext = ".yml") - write_yaml(list(title = "test"), yaml) - expect_snapshot_qmd_output( - name = "metadata-file", - input = qmd, - output_format = "native", - metadata_file = yaml - ) -}) - -test_that("metadata-file and metadata are merged in quarto_render", { - skip_if_no_quarto() - skip_if_not_installed("withr") - qmd <- local_qmd_file(c("content")) - yaml <- withr::local_tempfile(fileext = ".yml") - write_yaml(list(title = "test", other = "thing"), yaml) - expect_snapshot_qmd_output( - name = "metadata-merged", - input = qmd, - output_format = "native", - metadata_file = yaml, - metadata = list(title = "test2", any = "one") - ) -}) test_that("quarto_args in quarto_render", { skip_if_no_quarto() From 6187c6706d28b543665c2e2c5a9699c14e6ac2f7 Mon Sep 17 00:00:00 2001 From: christophe dervieux Date: Wed, 20 May 2026 13:27:39 +0200 Subject: [PATCH 09/22] Address final review: add withr skip guards, rename snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add skip_if_not_installed("withr") to three tests in test-metadata-args.R that use withr:: calls. withr is in Suggests, not Imports, so the no-Quarto CI cell (which may not install suggested packages) needs the guard. - Rename metadata-nested-override.test.out to metadata-toplevel-override.test.out — the test demonstrates a top-level key override, not a nested format key (which would not appear in native AST output). --- ...-override.test.out => metadata-toplevel-override.test.out} | 0 tests/testthat/test-metadata-args.R | 3 +++ tests/testthat/test-metadata-render.R | 4 ++-- 3 files changed, 5 insertions(+), 2 deletions(-) rename tests/testthat/_snaps/metadata-render/{metadata-nested-override.test.out => metadata-toplevel-override.test.out} (100%) diff --git a/tests/testthat/_snaps/metadata-render/metadata-nested-override.test.out b/tests/testthat/_snaps/metadata-render/metadata-toplevel-override.test.out similarity index 100% rename from tests/testthat/_snaps/metadata-render/metadata-nested-override.test.out rename to tests/testthat/_snaps/metadata-render/metadata-toplevel-override.test.out diff --git a/tests/testthat/test-metadata-args.R b/tests/testthat/test-metadata-args.R index 5f48bc5d..6c1271b4 100644 --- a/tests/testthat/test-metadata-args.R +++ b/tests/testthat/test-metadata-args.R @@ -5,6 +5,7 @@ test_that("cli_arg_metadata returns empty args when both inputs NULL", { }) test_that("cli_arg_metadata with metadata only writes temp YAML and uses --metadata-file", { + skip_if_not_installed("withr") skip_if_not_installed("yaml") result <- cli_arg_metadata( metadata = list(title = "test", lang = "fr"), @@ -54,6 +55,7 @@ test_that("cli_arg_metadata merges metadata over metadata_file with metadata win }) test_that("cli_arg_metadata preserves nested list structure in temp YAML", { + skip_if_not_installed("withr") skip_if_not_installed("yaml") nested <- list(format = list(html = list(`toc-title` = "Custom"))) result <- cli_arg_metadata(metadata = nested, metadata_file = NULL) @@ -66,6 +68,7 @@ test_that("cli_arg_metadata preserves nested list structure in temp YAML", { test_that("cli_arg_metadata merge is shallow (top-level keys replaced wholesale)", { # Documents current behavior - metadata replaces top-level keys entirely. # If we ever switch to deep merge, this test should be updated, not silently broken. + skip_if_not_installed("withr") skip_if_not_installed("yaml") yml <- withr::local_tempfile(fileext = ".yml") yaml::write_yaml( diff --git a/tests/testthat/test-metadata-render.R b/tests/testthat/test-metadata-render.R index ab5dc8c3..844a7861 100644 --- a/tests/testthat/test-metadata-render.R +++ b/tests/testthat/test-metadata-render.R @@ -65,9 +65,9 @@ test_that("metadata overrides keys from _quarto.yml", { quiet = TRUE ) - announce_snapshot_file(name = "metadata-nested-override.test.out") + announce_snapshot_file(name = "metadata-toplevel-override.test.out") expect_snapshot_file( file.path(proj, "index.native"), - "metadata-nested-override.test.out" + "metadata-toplevel-override.test.out" ) }) From 38a727a10861aa7e034600b9fa5ff87a9e6de144 Mon Sep 17 00:00:00 2001 From: christophe dervieux Date: Wed, 20 May 2026 14:24:33 +0200 Subject: [PATCH 10/22] skip purl test on v1.9 quarto until https://github.com/quarto-dev/quarto-cli/issues/14529 --- tests/testthat/test-utils-extract.R | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/testthat/test-utils-extract.R b/tests/testthat/test-utils-extract.R index 4ffd3f43..02529557 100644 --- a/tests/testthat/test-utils-extract.R +++ b/tests/testthat/test-utils-extract.R @@ -19,6 +19,9 @@ test_that("qmd_to_r_script() errors on existing script", { test_that("qmd_to_r_script() writes R file that renders", { skip_if_no_quarto() + # TODO: Problem with quarto 1.9 - reactivate when fix. + # https://github.com/quarto-dev/quarto-cli/issues/14529 + skip_if_quarto("1.9") r_script <- withr::local_tempfile(pattern = "purl", fileext = ".R") announce_snapshot_file(name = "purl.R") From e451d982f361c7f366b42d5de44228e41f81b3da Mon Sep 17 00:00:00 2001 From: christophe dervieux Date: Wed, 20 May 2026 14:25:50 +0200 Subject: [PATCH 11/22] don't add to NEWS --- NEWS.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/NEWS.md b/NEWS.md index a85291f7..1fd8e202 100644 --- a/NEWS.md +++ b/NEWS.md @@ -4,11 +4,6 @@ - Curly braces in Quarto CLI error messages are now escaped to prevent them from being interpreted as `cli` formatting syntax (#293). -- Internal refactor: extracted `--metadata-file` argument construction into a - dedicated `cli_arg_metadata()` helper. No user-visible behavior change except - that an explicit `metadata_file = NULL` is now treated identically to omitting - the argument. - # quarto 1.5.1 - Make sure tests pass on CRAN checks even when Quarto is not installed by adding a gihub action to test when no quarto is available. Also fix tests that were From ea36f053376b56399a51edbfcbffc88e06846e90 Mon Sep 17 00:00:00 2001 From: christophe dervieux Date: Wed, 20 May 2026 15:06:01 +0200 Subject: [PATCH 12/22] Correctly cleanup if something is wrong --- R/quarto-args.R | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/R/quarto-args.R b/R/quarto-args.R index f34cbad0..940da43c 100644 --- a/R/quarto-args.R +++ b/R/quarto-args.R @@ -42,7 +42,13 @@ cli_arg_metadata <- function(metadata = NULL, metadata_file = NULL) { metadata <- merge_list(file_content, metadata) } tmp <- tempfile(pattern = "quarto-meta", fileext = ".yml") + # Remove tmp if write_yaml() errors before we hand the path to the + # caller; the caller (e.g. quarto_render()) registers its own + # on.exit cleanup for the success path. + success <- FALSE + on.exit(if (!success) unlink(tmp), add = TRUE) write_yaml(metadata, tmp) + success <- TRUE list( args = c("--metadata-file", tmp), tmp_file = tmp From de255f07b189f3cd58aa9d6b4e1433a595e6727a Mon Sep 17 00:00:00 2001 From: christophe dervieux Date: Wed, 20 May 2026 15:06:21 +0200 Subject: [PATCH 13/22] correctly return always --- R/remove.R | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/R/remove.R b/R/remove.R index cf47ed3a..928fe344 100644 --- a/R/remove.R +++ b/R/remove.R @@ -43,10 +43,10 @@ quarto_remove_extension <- function( if (!is_installed) { if (!quiet) { cli::cli_alert_warning( - "{.str { extension } } is not among installed extensions." + "{.str {extension}} is not among installed extensions." ) - return(invisible(FALSE)) } + return(invisible(FALSE)) } quarto_bin <- find_quarto() From 8fb69216f7b7bb3e455c907cb4c2cb241ea748f0 Mon Sep 17 00:00:00 2001 From: christophe dervieux Date: Wed, 20 May 2026 15:07:02 +0200 Subject: [PATCH 14/22] test also global yaml is used --- .../metadata-toplevel-override.test.out | 1 + tests/testthat/test-metadata-render.R | 11 +++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/testthat/_snaps/metadata-render/metadata-toplevel-override.test.out b/tests/testthat/_snaps/metadata-render/metadata-toplevel-override.test.out index 10007342..0983e9d0 100644 --- a/tests/testthat/_snaps/metadata-render/metadata-toplevel-override.test.out +++ b/tests/testthat/_snaps/metadata-render/metadata-toplevel-override.test.out @@ -4,6 +4,7 @@ Pandoc fromList [ ( "custom-key" , MetaInlines [ Str "overridden" ] ) , ( "title" , MetaInlines [ Str "Doc" ] ) + , ( "yml-only-key" , MetaInlines [ Str "kept" ] ) ] } [] diff --git a/tests/testthat/test-metadata-render.R b/tests/testthat/test-metadata-render.R index 844a7861..cccdb758 100644 --- a/tests/testthat/test-metadata-render.R +++ b/tests/testthat/test-metadata-render.R @@ -22,7 +22,10 @@ test_that("metadata overrides keys from _quarto.yml", { "format:", " html:", " toc-title: Original", - "custom-key: from-quarto-yml" + "custom-key: from-quarto-yml", + # control key only set in _quarto.yml; proves project metadata + # was actually read (not just our --metadata-file override) + "yml-only-key: kept" ), file.path(proj, "_quarto.yml") ) @@ -44,7 +47,11 @@ test_that("metadata overrides keys from _quarto.yml", { xfun::write_utf8( c( 'function Pandoc(doc)', - ' local wanted = { ["title"] = true, ["custom-key"] = true }', + ' local wanted = {', + ' ["title"] = true,', + ' ["custom-key"] = true,', + ' ["yml-only-key"] = true,', + ' }', ' local kept = pandoc.Meta({})', ' for k, v in pairs(doc.meta) do', ' if wanted[k] then kept[k] = v end', From 92a0f1434a9dd311c67ccfb49261b809c27c728d Mon Sep 17 00:00:00 2001 From: christophe dervieux Date: Wed, 20 May 2026 15:07:32 +0200 Subject: [PATCH 15/22] be safe and resolve explicitly the extension --- tests/testthat/_snaps/remove.md | 2 +- tests/testthat/test-update.R | 15 +++++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/tests/testthat/_snaps/remove.md b/tests/testthat/_snaps/remove.md index 5293f9b9..821bdefa 100644 --- a/tests/testthat/_snaps/remove.md +++ b/tests/testthat/_snaps/remove.md @@ -3,7 +3,7 @@ Code expect_false(quarto_remove_extension("quarto-ext/fontawesome", no_prompt = TRUE)) Message - ! "quarto-ext/fontawesome " is not among installed extensions. + ! "quarto-ext/fontawesome" is not among installed extensions. --- diff --git a/tests/testthat/test-update.R b/tests/testthat/test-update.R index 9eab6c67..565757fd 100644 --- a/tests/testthat/test-update.R +++ b/tests/testthat/test-update.R @@ -8,19 +8,18 @@ test_that("Updating an extension", { no_prompt = TRUE, quiet = TRUE ) - # last installed is fontawesome - expect_equal(tail(quarto_list_extensions(), 1)$Version, "0.0.1") + installed <- quarto_list_extensions() + fontawesome_row <- installed[installed$Id == "quarto-ext/fontawesome", ] + expect_equal(fontawesome_row$Version, "0.0.1") quarto_update_extension( "quarto-ext/fontawesome", no_prompt = TRUE, quiet = TRUE ) expect_true(dir.exists("_extensions/quarto-ext/fontawesome")) - expect_true( - as.numeric_version( - current_version <- tail(quarto_list_extensions(), 1)$Version - ) > - "0.0.1" - ) + installed <- quarto_list_extensions() + fontawesome_row <- installed[installed$Id == "quarto-ext/fontawesome", ] + current_version <- fontawesome_row$Version + expect_true(as.numeric_version(current_version) > "0.0.1") expect_false(identical(current_version, "0.0.1")) }) From 891542f7f2531d08f7de1cf44803e98cc8a1a38c Mon Sep 17 00:00:00 2001 From: christophe dervieux Date: Wed, 20 May 2026 15:44:36 +0200 Subject: [PATCH 16/22] Work around expect_snaphot hint problem Reported at https://github.com/r-lib/testthat/issues/2335 --- tests/testthat/_snaps/remove.md | 6 ++++-- tests/testthat/test-remove.R | 37 ++++++++++++++++++++++----------- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/tests/testthat/_snaps/remove.md b/tests/testthat/_snaps/remove.md index 821bdefa..0d18b167 100644 --- a/tests/testthat/_snaps/remove.md +++ b/tests/testthat/_snaps/remove.md @@ -1,14 +1,16 @@ # Removing an extension Code - expect_false(quarto_remove_extension("quarto-ext/fontawesome", no_prompt = TRUE)) + withr::with_dir(wd, expect_false(quarto_remove_extension( + "quarto-ext/fontawesome", no_prompt = TRUE))) Message ! "quarto-ext/fontawesome" is not among installed extensions. --- Code - expect_true(quarto_remove_extension("quarto-ext/fontawesome", no_prompt = TRUE)) + withr::with_dir(wd, expect_true(quarto_remove_extension( + "quarto-ext/fontawesome", no_prompt = TRUE))) Message v Extension `quarto-ext/fontawesome` successfully removed. diff --git a/tests/testthat/test-remove.R b/tests/testthat/test-remove.R index 0c93fb5e..ee3e1e6c 100644 --- a/tests/testthat/test-remove.R +++ b/tests/testthat/test-remove.R @@ -2,17 +2,30 @@ test_that("Removing an extension", { skip_if_no_quarto() skip_if_offline("github.com") qmd <- local_qmd_file(c("content")) - withr::local_dir(dirname(qmd)) + # with expect_snapshot, can't use withr::local_dir() + wd <- dirname(qmd) local_edition(3) - expect_snapshot(expect_false(quarto_remove_extension( - "quarto-ext/fontawesome", - no_prompt = TRUE - ))) - quarto_add_extension("quarto-ext/fontawesome", no_prompt = TRUE, quiet = TRUE) - expect_true(dir.exists("_extensions/quarto-ext/fontawesome")) - expect_snapshot(expect_true(quarto_remove_extension( - "quarto-ext/fontawesome", - no_prompt = TRUE - ))) - expect_false(dir.exists("_extensions")) + expect_snapshot(withr::with_dir( + wd, + expect_false(quarto_remove_extension( + "quarto-ext/fontawesome", + no_prompt = TRUE + )) + )) + withr::with_dir(wd, { + quarto_add_extension( + "quarto-ext/fontawesome", + no_prompt = TRUE, + quiet = TRUE + ) + expect_true(dir.exists("_extensions/quarto-ext/fontawesome")) + }) + expect_snapshot(withr::with_dir( + wd, + expect_true(quarto_remove_extension( + "quarto-ext/fontawesome", + no_prompt = TRUE + )) + )) + withr::with_dir(wd, expect_false(dir.exists("_extensions"))) }) From 40c273a77e6a9444914bddb44833725294ae3318 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 20 May 2026 16:23:53 +0200 Subject: [PATCH 17/22] Add brand.yml to Suggests and skip theme tests when missing Theme tests require brand.yml package (used by bslib::bs_theme(brand=)). Since brand.yml is in bslib's Suggests, it's not installed transitively. --- DESCRIPTION | 3 ++- tests/testthat/test-theme.R | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/DESCRIPTION b/DESCRIPTION index 36ce49b8..558bd3ac 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -32,7 +32,8 @@ Imports: utils, xfun, yaml (>= 2.3.10) -Suggests: +Suggests: + brand.yml, bslib, callr, curl, diff --git a/tests/testthat/test-theme.R b/tests/testthat/test-theme.R index 2e1ba8a0..bf32b358 100644 --- a/tests/testthat/test-theme.R +++ b/tests/testthat/test-theme.R @@ -2,6 +2,7 @@ skip_on_cran() skip_if_no_quarto() skip_if_not_installed("withr") +skip_if_not_installed("brand.yml") # We need to install the package in a temporary library when we are in dev mode install_dev_package() From 18fe90e14b37cb2d9879e5766ceefddca06b00a8 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 20 May 2026 17:07:08 +0200 Subject: [PATCH 18/22] Skip ggiraph test when gdtools font setup fails gdtools::font_set_liberation() can fail with confusing errors when the cache directory cannot be created (e.g., permission issues). See https://github.com/davidgohel/gdtools/issues/82 --- tests/testthat/test-theme.R | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/testthat/test-theme.R b/tests/testthat/test-theme.R index bf32b358..2345c175 100644 --- a/tests/testthat/test-theme.R +++ b/tests/testthat/test-theme.R @@ -44,6 +44,10 @@ test_that("render flextable", { test_that("render ggiraph", { skip_if_not_installed("bslib") skip_if_not_installed("ggiraph") + skip_if( + inherits(tryCatch(gdtools::font_set_liberation(), error = identity), "error"), + "gdtools font setup fails on this system" + ) file <- theme_file("ggiraph.qmd") local_render_theme_file(file) }) From 0080961a8dbff01fc5621c770a9cf760ea70167a Mon Sep 17 00:00:00 2001 From: christophe dervieux Date: Wed, 20 May 2026 18:18:04 +0200 Subject: [PATCH 19/22] Test temp file cleanup when write_yaml errors Covers the on.exit unlink branch in cli_arg_metadata() added in ea36f05. Without coverage a regression could silently leak temp files on write failures. --- tests/testthat/test-metadata-args.R | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/testthat/test-metadata-args.R b/tests/testthat/test-metadata-args.R index 6c1271b4..02fad936 100644 --- a/tests/testthat/test-metadata-args.R +++ b/tests/testthat/test-metadata-args.R @@ -65,6 +65,25 @@ test_that("cli_arg_metadata preserves nested list structure in temp YAML", { expect_identical(written, nested) }) +test_that("cli_arg_metadata removes temp file if write_yaml errors", { + skip_if_not_installed("withr") + captured_tmp <- NULL + local_mocked_bindings( + write_yaml = function(x, file) { + captured_tmp <<- file + file.create(file) + stop("simulated write failure") + } + ) + + expect_error( + cli_arg_metadata(metadata = list(title = "x"), metadata_file = NULL), + "simulated write failure" + ) + expect_false(is.null(captured_tmp)) + expect_false(file.exists(captured_tmp)) +}) + test_that("cli_arg_metadata merge is shallow (top-level keys replaced wholesale)", { # Documents current behavior - metadata replaces top-level keys entirely. # If we ever switch to deep merge, this test should be updated, not silently broken. From 158db042448552ced9d9488a7e5993b3802bf9f7 Mon Sep 17 00:00:00 2001 From: christophe dervieux Date: Wed, 20 May 2026 18:56:51 +0200 Subject: [PATCH 20/22] Strip "Quarto version:" debug-mode prefix from CLI stderr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quarto CLI prepends `Quarto version: X.Y.Z` to stderr whenever the log level is DEBUG, which is auto-enabled by GitHub Actions when a workflow is re-run with debug logging. That prefix line breaks any consumer that parses CLI stderr — in our case `quarto_list_extensions()` was feeding the line to `read.table(header = TRUE)`, where it became the header row and `df$Id` resolved to NULL. Strip the line in `quarto_list_extensions()` before parsing, and in the shared snapshot transform so error-output snapshots stay stable across debug and non-debug runs. See https://github.com/quarto-dev/quarto-cli/issues/14532 for the upstream behaviour. --- R/list.R | 4 ++++ tests/testthat/helpers.R | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/R/list.R b/R/list.R index 597ef8ef..9b796748 100644 --- a/R/list.R +++ b/R/list.R @@ -19,6 +19,10 @@ quarto_list_extensions <- function() { x <- quarto_list(args, quarto_bin = quarto_bin, echo = FALSE) # Clean the stderr output to remove extra spaces and ensure consistent formatting stderr_cleaned <- gsub("\\s+$", "", x$stderr) + # Quarto CLI prepends a "Quarto version: X.Y.Z" line to stderr when log level + # is DEBUG (auto-enabled in GHA debug mode). Strip it so read.table() can use + # the real header row. See https://github.com/quarto-dev/quarto-cli/issues/14532. + stderr_cleaned <- sub("^Quarto version:[^\n]*\n", "", stderr_cleaned) if (grepl("No extensions are installed", stderr_cleaned)) { invisible() } else { diff --git a/tests/testthat/helpers.R b/tests/testthat/helpers.R index 7e50a907..eda8b077 100644 --- a/tests/testthat/helpers.R +++ b/tests/testthat/helpers.R @@ -185,6 +185,11 @@ transform_quarto_cli_in_output <- function( return( function(lines) { + # Quarto CLI prepends "Quarto version: X.Y.Z" on stderr under DEBUG log + # level (auto-on in GHA debug mode). Strip so snapshots stay stable. + # See https://github.com/quarto-dev/quarto-cli/issues/14532. + lines <- lines[!grepl("^\\s*Quarto version:\\s+[0-9]", lines)] + if (hide_stack) { # Hide possible stack first stack_trace_index <- which(grepl("\\s*Stack trace\\:", lines)) From 1473e1119fab4d2bf7736e19858b30418fc9dce0 Mon Sep 17 00:00:00 2001 From: christophe dervieux Date: Thu, 21 May 2026 10:25:49 +0200 Subject: [PATCH 21/22] no deps on gdtools needed --- tests/testthat/test-theme.R | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/testthat/test-theme.R b/tests/testthat/test-theme.R index 2345c175..bf32b358 100644 --- a/tests/testthat/test-theme.R +++ b/tests/testthat/test-theme.R @@ -44,10 +44,6 @@ test_that("render flextable", { test_that("render ggiraph", { skip_if_not_installed("bslib") skip_if_not_installed("ggiraph") - skip_if( - inherits(tryCatch(gdtools::font_set_liberation(), error = identity), "error"), - "gdtools font setup fails on this system" - ) file <- theme_file("ggiraph.qmd") local_render_theme_file(file) }) From fc6ae31ece5285619c2a6257ae946d93027aca6a Mon Sep 17 00:00:00 2001 From: christophe dervieux Date: Thu, 21 May 2026 11:00:16 +0200 Subject: [PATCH 22/22] bslib 0.9.0 is required --- DESCRIPTION | 2 +- tests/testthat/test-theme.R | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/DESCRIPTION b/DESCRIPTION index 558bd3ac..1a8c7c62 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -34,7 +34,7 @@ Imports: yaml (>= 2.3.10) Suggests: brand.yml, - bslib, + bslib (>= 0.9.0), callr, curl, dplyr, diff --git a/tests/testthat/test-theme.R b/tests/testthat/test-theme.R index bf32b358..587376b5 100644 --- a/tests/testthat/test-theme.R +++ b/tests/testthat/test-theme.R @@ -3,6 +3,7 @@ skip_on_cran() skip_if_no_quarto() skip_if_not_installed("withr") skip_if_not_installed("brand.yml") +skip_if_not_installed("bslib", "0.9.0") # We need to install the package in a temporary library when we are in dev mode install_dev_package()