diff --git a/.github/workflows/test-smokes.yml b/.github/workflows/test-smokes.yml index 501ee581623..f5bc8749093 100644 --- a/.github/workflows/test-smokes.yml +++ b/.github/workflows/test-smokes.yml @@ -117,10 +117,16 @@ jobs: restore-keys: | ${{ runner.os }}-playwright- + - name: Install Playwright system dependencies + if: ${{ runner.os != 'Windows' || github.event_name == 'schedule' }} + timeout-minutes: 15 + run: npx playwright install-deps + working-directory: ./tests/integration/playwright + - name: Install Playwright Browsers if: ${{ runner.os != 'Windows' || github.event_name == 'schedule' }} - timeout-minutes: 10 - run: npx playwright install --with-deps + timeout-minutes: 15 + run: npx playwright install working-directory: ./tests/integration/playwright - name: Install MECA validator diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index e9f6a320ad3..b8fcb720ae8 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -73,4 +73,5 @@ All changes included in 1.10: - ([#14445](https://github.com/quarto-dev/quarto-cli/issues/14445)): Fix intermittent `Uncaught (in promise) TypeError: Writable stream is closed or errored.` aborting renders on Linux. `execProcess` now awaits and swallows the rejection from `process.stdin.close()` when the child closes its stdin first. The captured stderr is now also surfaced when `typst-gather analyze` falls back to staging all packages, so failures are diagnosable without bypassing `quarto`. - ([#14359](https://github.com/quarto-dev/quarto-cli/issues/14359)): Fix intermediate `.quarto_ipynb` file not being deleted after rendering a `.qmd` with Jupyter engine, causing numbered variants (`_1`, `_2`, ...) to accumulate on disk across renders. - ([#14461](https://github.com/quarto-dev/quarto-cli/issues/14461)): Fix `quarto render --to pdf` aborting with `ERROR: Problem running 'fmtutil-sys --all' to rebuild format tree.` when an automatically-installed LaTeX package's post-update format rebuild fails. Format-tree rebuild is now treated as best-effort housekeeping (matching upstream `tinytex` R behavior) — the failure is logged as a warning and the package install completes. -- ([#14472](https://github.com/quarto-dev/quarto-cli/issues/14472)): Add support for Kotlin in code annotations and YAML cell options. (author: @barendgehrels) \ No newline at end of file +- ([#14472](https://github.com/quarto-dev/quarto-cli/issues/14472)): Add support for Kotlin in code annotations and YAML cell options. (author: @barendgehrels) +- ([#14529](https://github.com/quarto-dev/quarto-cli/issues/14529)): Fix bundled Julia engine path leaking into rendered YAML metadata and pandoc log output when running an installed Quarto. The internal subtree-engine filter only matched the source-tree share-path layout (`resources/extension-subtrees/`) and missed installed layouts where the path is `share/extension-subtrees/`. \ No newline at end of file diff --git a/src/command/render/pandoc.ts b/src/command/render/pandoc.ts index a9e7ff3dc27..6df97871aa9 100644 --- a/src/command/render/pandoc.ts +++ b/src/command/render/pandoc.ts @@ -59,6 +59,7 @@ import { metadataGetDeep, } from "../../config/metadata.ts"; import { pandocBinaryPath, resourcePath } from "../../core/resources.ts"; +import { filterBundledSubtreeEngines } from "../../extension/extension.ts"; import { pandocAutoIdentifier } from "../../core/pandoc/pandoc-id.ts"; import { partitionYamlFrontMatter, @@ -439,15 +440,9 @@ export async function runPandoc( // This can cause issue on regex test for printed output cleanQuartoTestsMetadata(metadata); - // Filter out bundled engines from the engines array + // Filter out bundled engines from the engines array (#14529) if (Array.isArray(metadata.engines)) { - const filteredEngines = metadata.engines.filter((engine) => { - const enginePath = typeof engine === "string" ? engine : engine.path; - // Keep user engines, filter out bundled ones - return !enginePath?.replace(/\\/g, "/").includes( - "resources/extension-subtrees/", - ); - }); + const filteredEngines = filterBundledSubtreeEngines(metadata.engines); // Remove the engines key entirely if empty, otherwise assign filtered array if (filteredEngines.length === 0) { @@ -1323,15 +1318,11 @@ export async function runPandoc( // and it breaks ensureFileRegexMatches cleanQuartoTestsMetadata(pandocPassedMetadata); - // Filter out bundled engines from metadata passed to Pandoc + // Filter out bundled engines from metadata passed to Pandoc (#14529) if (Array.isArray(pandocPassedMetadata.engines)) { - const filteredEngines = pandocPassedMetadata.engines.filter((engine) => { - const enginePath = typeof engine === "string" ? engine : engine.path; - if (!enginePath) return true; - return !enginePath.replace(/\\/g, "/").includes( - "resources/extension-subtrees/", - ); - }); + const filteredEngines = filterBundledSubtreeEngines( + pandocPassedMetadata.engines, + ); if (filteredEngines.length === 0) { delete pandocPassedMetadata.engines; diff --git a/src/extension/extension.ts b/src/extension/extension.ts index 4dfdbddd1d0..eb5674e9c2f 100644 --- a/src/extension/extension.ts +++ b/src/extension/extension.ts @@ -28,6 +28,7 @@ import { Metadata, QuartoFilter } from "../config/types.ts"; import { kSkipHidden, normalizePath, + pathWithForwardSlashes, resolvePathGlobs, safeExistsSync, } from "../core/path.ts"; @@ -710,6 +711,39 @@ export function builtinSubtreeExtensions() { return resourcePath("extension-subtrees"); } +// Predicate: does `enginePath` live under the built-in subtree path? +// Works in both source-tree (QUARTO_SHARE_PATH=src/resources) and +// installed (QUARTO_SHARE_PATH=share) layouts. See #14529. +// Uses a path-boundary check so a sibling directory whose name merely +// starts with the same prefix (e.g. `extension-subtrees-custom/`) does +// not register as bundled. +export function isBundledSubtreeEnginePath( + enginePath: string, + subtreePath: string, +): boolean { + const normalizedEngine = pathWithForwardSlashes(enginePath); + const normalizedSubtree = pathWithForwardSlashes(subtreePath).replace( + /\/+$/, + "", + ); + return normalizedEngine === normalizedSubtree || + normalizedEngine.startsWith(normalizedSubtree + "/"); +} + +// Filters out bundled subtree engines from a metadata `engines` array. +export function filterBundledSubtreeEngines( + engines: ReadonlyArray, +): unknown[] { + const subtreePath = builtinSubtreeExtensions(); + return engines.filter((engine) => { + const enginePath = typeof engine === "string" + ? engine + : (engine as { path?: string } | null | undefined)?.path; + if (!enginePath) return true; + return !isBundledSubtreeEnginePath(enginePath, subtreePath); + }); +} + // Validate the extension function validateExtension(extension: Extension) { let contribCount = 0; diff --git a/tests/docs/smoke-all/2025/12/22/markdown-engine-no-extension-subtrees.qmd b/tests/docs/smoke-all/2025/12/22/markdown-engine-no-extension-subtrees.qmd index aeec583796b..ebfa6ca29d4 100644 --- a/tests/docs/smoke-all/2025/12/22/markdown-engine-no-extension-subtrees.qmd +++ b/tests/docs/smoke-all/2025/12/22/markdown-engine-no-extension-subtrees.qmd @@ -6,7 +6,7 @@ _quarto: html: printsMessage: level: INFO - regex: 'resources[/\\]extension-subtrees[/\\]' + regex: 'extension-subtrees[/\\]' negate: true --- diff --git a/tests/unit/extension/filter-bundled-engines.test.ts b/tests/unit/extension/filter-bundled-engines.test.ts new file mode 100644 index 00000000000..ecc0764fb88 --- /dev/null +++ b/tests/unit/extension/filter-bundled-engines.test.ts @@ -0,0 +1,122 @@ +/* + * filter-bundled-engines.test.ts + * + * Tests that bundled subtree engines are filtered out of metadata + * regardless of the share path layout (source tree vs installed). + * Related to issue #14529. + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { unitTest } from "../../test.ts"; +import { assert, assertEquals } from "testing/asserts"; +import { + builtinSubtreeExtensions, + filterBundledSubtreeEngines, + isBundledSubtreeEnginePath, +} from "../../../src/extension/extension.ts"; +import { join } from "../../../src/deno_ral/path.ts"; + +unitTest( + "isBundledSubtreeEnginePath - source-tree share path", + // deno-lint-ignore require-await + async () => { + const subtreePath = "/repo/src/resources/extension-subtrees"; + const enginePath = + "/repo/src/resources/extension-subtrees/julia-engine/_extensions/julia-engine/julia-engine.js"; + assert(isBundledSubtreeEnginePath(enginePath, subtreePath)); + }, +); + +unitTest( + "isBundledSubtreeEnginePath - installed POSIX share path (#14529)", + // deno-lint-ignore require-await + async () => { + const subtreePath = "/usr/local/share/quarto/extension-subtrees"; + const enginePath = + "/usr/local/share/quarto/extension-subtrees/julia-engine/_extensions/julia-engine/julia-engine.js"; + assert(isBundledSubtreeEnginePath(enginePath, subtreePath)); + }, +); + +unitTest( + "isBundledSubtreeEnginePath - installed Windows share path (#14529)", + // deno-lint-ignore require-await + async () => { + const subtreePath = + "C:\\Users\\me\\scoop\\apps\\quarto\\current\\share\\extension-subtrees"; + const enginePath = + "C:\\Users\\me\\scoop\\apps\\quarto\\current\\share\\extension-subtrees\\julia-engine\\_extensions\\julia-engine\\julia-engine.js"; + assert(isBundledSubtreeEnginePath(enginePath, subtreePath)); + }, +); + +unitTest( + "isBundledSubtreeEnginePath - user-supplied engine path is not bundled", + // deno-lint-ignore require-await + async () => { + const subtreePath = "/usr/local/share/quarto/extension-subtrees"; + const userEnginePath = "/home/me/project/_extensions/myext/my-engine.js"; + assert(!isBundledSubtreeEnginePath(userEnginePath, subtreePath)); + }, +); + +unitTest( + "isBundledSubtreeEnginePath - sibling directory sharing prefix is not bundled", + // deno-lint-ignore require-await + async () => { + const subtreePath = "/usr/local/share/quarto/extension-subtrees"; + const siblingPath = + "/usr/local/share/quarto/extension-subtrees-custom/foo/_extensions/foo/foo.js"; + assert(!isBundledSubtreeEnginePath(siblingPath, subtreePath)); + }, +); + +unitTest( + "isBundledSubtreeEnginePath - Windows sibling directory sharing prefix is not bundled", + // deno-lint-ignore require-await + async () => { + const subtreePath = + "C:\\Users\\me\\scoop\\apps\\quarto\\current\\share\\extension-subtrees"; + const siblingPath = + "C:\\Users\\me\\scoop\\apps\\quarto\\current\\share\\extension-subtrees-custom\\foo\\_extensions\\foo\\foo.js"; + assert(!isBundledSubtreeEnginePath(siblingPath, subtreePath)); + }, +); + +unitTest( + "filterBundledSubtreeEngines - drops bundled julia-engine in current env", + // deno-lint-ignore require-await + async () => { + // Build a path that matches the resolved built-in subtree location, + // so the test exercises the real path the production code sees. + const bundled = { + path: join( + builtinSubtreeExtensions(), + "julia-engine", + "_extensions", + "julia-engine", + "julia-engine.js", + ), + }; + const userEngine = { + path: "/home/me/project/_extensions/myext/my-engine.js", + }; + assertEquals( + filterBundledSubtreeEngines([bundled, userEngine, "julia"]), + [userEngine, "julia"], + ); + }, +); + +unitTest( + "filterBundledSubtreeEngines - keeps string engines and engines with no path", + // deno-lint-ignore require-await + async () => { + const noPath = { name: "something" } as unknown; + assertEquals( + filterBundledSubtreeEngines(["julia", "jupyter", noPath]), + ["julia", "jupyter", noPath], + ); + }, +);