Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .github/workflows/test-smokes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion news/changelog-1.10.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
- ([#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/`.
23 changes: 7 additions & 16 deletions src/command/render/pandoc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
34 changes: 34 additions & 0 deletions src/extension/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { Metadata, QuartoFilter } from "../config/types.ts";
import {
kSkipHidden,
normalizePath,
pathWithForwardSlashes,
resolvePathGlobs,
safeExistsSync,
} from "../core/path.ts";
Expand Down Expand Up @@ -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>,
): 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ _quarto:
html:
printsMessage:
level: INFO
regex: 'resources[/\\]extension-subtrees[/\\]'
regex: 'extension-subtrees[/\\]'
negate: true
---

Expand Down
122 changes: 122 additions & 0 deletions tests/unit/extension/filter-bundled-engines.test.ts
Original file line number Diff line number Diff line change
@@ -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],
);
},
);
Loading