From cef0596b417773dd2d1af19152893544976c1f83 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 10 Feb 2026 16:48:00 +0100 Subject: [PATCH 01/13] Add Chrome for Testing download utilities Shared infrastructure for downloading binaries from the Google Chrome for Testing (CfT) API. Provides platform detection, API fetching, binary search, and download+extract functions that will be used by installable tool implementations. Co-Authored-By: Claude Opus 4.6 --- src/deno_ral/fs.ts | 16 ++ src/deno_ral/platform.ts | 2 + src/tools/impl/chrome-for-testing.ts | 177 ++++++++++++++++++++ tests/unit/tools/chrome-for-testing.test.ts | 172 +++++++++++++++++++ 4 files changed, 367 insertions(+) create mode 100644 src/tools/impl/chrome-for-testing.ts create mode 100644 tests/unit/tools/chrome-for-testing.test.ts diff --git a/src/deno_ral/fs.ts b/src/deno_ral/fs.ts index 05bc526b4f1..5a7dab5ec60 100644 --- a/src/deno_ral/fs.ts +++ b/src/deno_ral/fs.ts @@ -9,6 +9,7 @@ import { resolve, SEP as SEPARATOR } from "./path.ts"; import { copySync } from "fs/copy"; import { existsSync } from "fs/exists"; import { originalRealPathSync } from "./original-real-path.ts"; +import { debug } from "./log.ts"; export { ensureDir, ensureDirSync } from "fs/ensure-dir"; export { existsSync } from "fs/exists"; @@ -175,3 +176,18 @@ export function safeModeFromFile(path: string): number | undefined { } } } + +/** + * Set file mode in a platform-safe way. No-op on Windows (where chmod + * is not supported). Swallows errors on other platforms since permission + * changes are often non-fatal (e.g., on filesystems that don't support it). + */ +export function safeChmodSync(path: string, mode: number): void { + if (Deno.build.os !== "windows") { + try { + Deno.chmodSync(path, mode); + } catch (e) { + debug(`safeChmodSync: failed to chmod ${path}: ${e}`); + } + } +} diff --git a/src/deno_ral/platform.ts b/src/deno_ral/platform.ts index acf4fcd952f..62a1b422f2c 100644 --- a/src/deno_ral/platform.ts +++ b/src/deno_ral/platform.ts @@ -9,3 +9,5 @@ export const isMac = Deno.build.os === "darwin"; export const isLinux = Deno.build.os === "linux"; export const os = Deno.build.os; + +export const arch = Deno.build.arch; diff --git a/src/tools/impl/chrome-for-testing.ts b/src/tools/impl/chrome-for-testing.ts new file mode 100644 index 00000000000..55a2fc92cea --- /dev/null +++ b/src/tools/impl/chrome-for-testing.ts @@ -0,0 +1,177 @@ +/* + * chrome-for-testing.ts + * + * Utilities for downloading binaries from the Chrome for Testing (CfT) API. + * https://github.com/GoogleChromeLabs/chrome-for-testing + * https://googlechromelabs.github.io/chrome-for-testing/ + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { basename } from "../../deno_ral/path.ts"; +import { safeChmodSync, safeRemoveSync, walkSync } from "../../deno_ral/fs.ts"; +import { debug } from "../../deno_ral/log.ts"; +import { arch, isWindows, os } from "../../deno_ral/platform.ts"; +import { unzip } from "../../core/zip.ts"; +import { InstallContext } from "../types.ts"; + +/** CfT platform identifiers matching the Google Chrome for Testing API. */ +export type CftPlatform = + | "linux64" + | "mac-arm64" + | "mac-x64" + | "win32" + | "win64"; + +/** Platform detection result. */ +export interface PlatformInfo { + platform: CftPlatform; + os: string; + arch: string; +} + +/** + * Map os + arch to a CfT platform string. + * Throws on unsupported platforms (e.g., linux aarch64 — to be handled by Playwright CDN). + */ +export function detectCftPlatform(): PlatformInfo { + const platformMap: Record = { + "linux-x86_64": "linux64", + "darwin-aarch64": "mac-arm64", + "darwin-x86_64": "mac-x64", + "windows-x86_64": "win64", + "windows-x86": "win32", + }; + + const key = `${os}-${arch}`; + const platform = platformMap[key]; + + if (!platform) { + if (os === "linux" && arch === "aarch64") { + throw new Error( + "linux-arm64 is not supported by Chrome for Testing. " + + "Use 'quarto install chromium' for arm64 support.", + ); + } + throw new Error( + `Unsupported platform for Chrome for Testing: ${os} ${arch}`, + ); + } + + return { platform, os, arch }; +} + +/** A single download entry from the CfT API. */ +export interface CftDownload { + platform: CftPlatform; + url: string; +} + +/** Parsed stable release from the CfT last-known-good-versions API. */ +export interface CftStableRelease { + version: string; + downloads: { + chrome?: CftDownload[]; + "chrome-headless-shell"?: CftDownload[]; + chromedriver?: CftDownload[]; + }; +} + +const kCftVersionsUrl = + "https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json"; + +/** + * Fetch the latest stable Chrome version and download URLs from the CfT API. + */ +export async function fetchLatestCftRelease(): Promise { + let response: Response; + try { + response = await fetch(kCftVersionsUrl); + } catch (e) { + throw new Error( + `Failed to fetch Chrome for Testing API: ${ + e instanceof Error ? e.message : String(e) + }`, + ); + } + + if (!response.ok) { + throw new Error( + `Chrome for Testing API returned ${response.status}: ${response.statusText}`, + ); + } + + // deno-lint-ignore no-explicit-any + let data: any; + try { + data = await response.json(); + } catch { + throw new Error("Chrome for Testing API returned invalid JSON"); + } + + const stable = data?.channels?.Stable; + if (!stable || !stable.version || !stable.downloads) { + throw new Error( + "Chrome for Testing API response missing expected 'channels.Stable' structure", + ); + } + + return { + version: stable.version, + downloads: stable.downloads, + }; +} + +/** + * Find a named executable inside an extracted CfT directory. + * Handles platform-specific naming (.exe on Windows) and nested directory structures. + * Returns absolute path to the executable, or undefined if not found. + */ +export function findCftExecutable( + extractedDir: string, + binaryName: string, +): string | undefined { + const target = isWindows ? `${binaryName}.exe` : binaryName; + + for (const entry of walkSync(extractedDir, { includeDirs: false })) { + if (basename(entry.path) === target) { + return entry.path; + } + } + + return undefined; +} + +/** + * Download a CfT zip from URL, extract to targetDir, set executable permissions. + * Uses InstallContext.download() for progress reporting with the given label. + * When binaryName is provided, sets executable permission only on that binary. + * Returns the target directory path. + */ +export async function downloadAndExtractCft( + label: string, + url: string, + targetDir: string, + context: InstallContext, + binaryName?: string, +): Promise { + const tempZipPath = Deno.makeTempFileSync({ suffix: ".zip" }); + + try { + await context.download(label, url, tempZipPath); + await unzip(tempZipPath, targetDir); + } finally { + safeRemoveSync(tempZipPath); + } + + if (binaryName) { + const executable = findCftExecutable(targetDir, binaryName); + if (executable) { + safeChmodSync(executable, 0o755); + } else { + debug(`downloadAndExtractCft: expected binary '${binaryName}' not found in ${targetDir}`); + } + } + + return targetDir; +} diff --git a/tests/unit/tools/chrome-for-testing.test.ts b/tests/unit/tools/chrome-for-testing.test.ts new file mode 100644 index 00000000000..1610532ff48 --- /dev/null +++ b/tests/unit/tools/chrome-for-testing.test.ts @@ -0,0 +1,172 @@ +/* + * chrome-for-testing.test.ts + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { unitTest } from "../../test.ts"; +import { assert, assertEquals } from "testing/asserts"; +import { arch, os } from "../../../src/deno_ral/platform.ts"; +import { join } from "../../../src/deno_ral/path.ts"; +import { safeRemoveSync } from "../../../src/deno_ral/fs.ts"; +import { isWindows } from "../../../src/deno_ral/platform.ts"; +import { runningInCI } from "../../../src/core/ci-info.ts"; +import { InstallContext } from "../../../src/tools/types.ts"; +import { + detectCftPlatform, + downloadAndExtractCft, + fetchLatestCftRelease, + findCftExecutable, +} from "../../../src/tools/impl/chrome-for-testing.ts"; + +// Step 1: detectCftPlatform() +unitTest("detectCftPlatform - returns valid CftPlatform for current system", async () => { + const result = detectCftPlatform(); + const validPlatforms = ["linux64", "mac-arm64", "mac-x64", "win32", "win64"]; + assert( + validPlatforms.includes(result.platform), + `Expected one of ${validPlatforms.join(", ")}, got: ${result.platform}`, + ); + assert(result.os.length > 0, "os should be non-empty"); + assert(result.arch.length > 0, "arch should be non-empty"); +}); + +unitTest("detectCftPlatform - returns win64 on Windows x86_64", async () => { + if (os !== "windows" || arch !== "x86_64") { + return; // Skip on non-Windows + } + const result = detectCftPlatform(); + assertEquals(result.platform, "win64"); + assertEquals(result.os, "windows"); + assertEquals(result.arch, "x86_64"); +}); + +// Step 2: fetchLatestCftRelease() +// These tests make real HTTP calls to the CfT API — skip on CI. +unitTest("fetchLatestCftRelease - returns valid version string", async () => { + const release = await fetchLatestCftRelease(); + assert(release.version, "version should be non-empty"); + assert( + /^\d+\.\d+\.\d+\.\d+$/.test(release.version), + `version should match X.Y.Z.W format, got: ${release.version}`, + ); +}, { ignore: runningInCI() }); + +unitTest("fetchLatestCftRelease - has chrome-headless-shell downloads", async () => { + const release = await fetchLatestCftRelease(); + const downloads = release.downloads["chrome-headless-shell"]; + assert(downloads, "chrome-headless-shell downloads should exist"); + assert(downloads!.length > 0, "should have at least one download"); +}, { ignore: runningInCI() }); + +unitTest("fetchLatestCftRelease - download URLs are valid", async () => { + const release = await fetchLatestCftRelease(); + const downloads = release.downloads["chrome-headless-shell"]!; + for (const dl of downloads) { + assert( + dl.url.startsWith("https://storage.googleapis.com/"), + `URL should start with googleapis.com, got: ${dl.url}`, + ); + assert( + dl.url.includes(release.version), + `URL should contain version ${release.version}, got: ${dl.url}`, + ); + } +}, { ignore: runningInCI() }); + +// Step 3: findCftExecutable() +unitTest("findCftExecutable - finds binary in CfT directory structure", async () => { + const tempDir = Deno.makeTempDirSync(); + try { + const subdir = join(tempDir, "chrome-headless-shell-linux64"); + Deno.mkdirSync(subdir); + const binaryName = isWindows + ? "chrome-headless-shell.exe" + : "chrome-headless-shell"; + const binaryPath = join(subdir, binaryName); + Deno.writeTextFileSync(binaryPath, "fake binary"); + + const found = findCftExecutable(tempDir, "chrome-headless-shell"); + assert(found !== undefined, "should find the binary"); + assert( + found!.endsWith(binaryName), + `found path should end with ${binaryName}, got: ${found}`, + ); + } finally { + safeRemoveSync(tempDir, { recursive: true }); + } +}); + +unitTest("findCftExecutable - returns undefined for empty directory", async () => { + const tempDir = Deno.makeTempDirSync(); + try { + const found = findCftExecutable(tempDir, "chrome-headless-shell"); + assertEquals(found, undefined); + } finally { + safeRemoveSync(tempDir, { recursive: true }); + } +}); + +unitTest("findCftExecutable - finds binary in nested structure", async () => { + const tempDir = Deno.makeTempDirSync(); + try { + const nested = join(tempDir, "chrome-headless-shell-win64", "subfolder"); + Deno.mkdirSync(nested, { recursive: true }); + const binaryName = isWindows + ? "chrome-headless-shell.exe" + : "chrome-headless-shell"; + const binaryPath = join(nested, binaryName); + Deno.writeTextFileSync(binaryPath, "fake binary"); + + const found = findCftExecutable(tempDir, "chrome-headless-shell"); + assert(found !== undefined, "should find the binary in nested dir"); + } finally { + safeRemoveSync(tempDir, { recursive: true }); + } +}); + +// Step 4: downloadAndExtractCft() — integration test, downloads ~50MB +unitTest( + "downloadAndExtractCft - downloads and extracts chrome-headless-shell", + async () => { + const release = await fetchLatestCftRelease(); + const { platform } = detectCftPlatform(); + const downloads = release.downloads["chrome-headless-shell"]!; + const dl = downloads.find((d) => d.platform === platform); + assert(dl, `No download found for platform ${platform}`); + + const targetDir = Deno.makeTempDirSync(); + try { + const mockContext: InstallContext = { + workingDir: targetDir, + info: (_msg: string) => {}, + withSpinner: async (_options, op) => { + await op(); + }, + error: (_msg: string) => {}, + confirm: async (_msg: string) => true, + download: async (_name: string, url: string, target: string) => { + const resp = await fetch(url); + if (!resp.ok) throw new Error(`Download failed: ${resp.status}`); + const data = new Uint8Array(await resp.arrayBuffer()); + Deno.writeFileSync(target, data); + }, + props: {}, + flags: {}, + }; + + await downloadAndExtractCft("Chrome Headless Shell", dl!.url, targetDir, mockContext, "chrome-headless-shell"); + + const found = findCftExecutable(targetDir, "chrome-headless-shell"); + assert( + found !== undefined, + "should find chrome-headless-shell after extraction", + ); + } finally { + safeRemoveSync(targetDir, { recursive: true }); + } + }, + { + ignore: runningInCI(), + }, +); From 877323b63ccae5d995fed4cd79ef01b7ace1a868 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 10 Feb 2026 19:31:49 +0100 Subject: [PATCH 02/13] Add chrome-headless-shell InstallableTool implementation Implement quarto install/uninstall lifecycle for chrome-headless-shell using Chrome for Testing (CfT) infrastructure. Stores binaries under quartoDataDir("chrome-headless-shell") with a plain text version file matching the tinytex/verapdf pattern. Key exports for downstream tasks: - chromeHeadlessShellInstallable: InstallableTool registration - chromeHeadlessShellExecutablePath(): browser discovery - chromeHeadlessShellInstallDir(): install location Co-Authored-By: Claude Opus 4.6 --- src/tools/impl/chrome-headless-shell.ts | 176 +++++++++++++ .../unit/tools/chrome-headless-shell.test.ts | 240 ++++++++++++++++++ 2 files changed, 416 insertions(+) create mode 100644 src/tools/impl/chrome-headless-shell.ts create mode 100644 tests/unit/tools/chrome-headless-shell.test.ts diff --git a/src/tools/impl/chrome-headless-shell.ts b/src/tools/impl/chrome-headless-shell.ts new file mode 100644 index 00000000000..7ac9c038c89 --- /dev/null +++ b/src/tools/impl/chrome-headless-shell.ts @@ -0,0 +1,176 @@ +/* + * chrome-headless-shell.ts + * + * InstallableTool implementation for Chrome Headless Shell via Chrome for Testing (CfT). + * Provides quarto install/uninstall chrome-headless-shell functionality. + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { join } from "../../deno_ral/path.ts"; +import { existsSync, safeMoveSync, safeRemoveSync } from "../../deno_ral/fs.ts"; +import { quartoDataDir } from "../../core/appdirs.ts"; +import { + InstallableTool, + InstallContext, + PackageInfo, + RemotePackageInfo, +} from "../types.ts"; +import { + detectCftPlatform, + downloadAndExtractCft, + fetchLatestCftRelease, + findCftExecutable, +} from "./chrome-for-testing.ts"; + +const kVersionFileName = "version"; + +// -- Version helpers -- + +/** Return the chrome-headless-shell install directory under quartoDataDir. */ +export function chromeHeadlessShellInstallDir(): string { + return quartoDataDir("chrome-headless-shell"); +} + +/** + * Find the chrome-headless-shell executable in the install directory. + * Returns the absolute path if installed, undefined otherwise. + */ +export function chromeHeadlessShellExecutablePath(): string | undefined { + const dir = chromeHeadlessShellInstallDir(); + if (!existsSync(dir)) { + return undefined; + } + return findCftExecutable(dir, "chrome-headless-shell"); +} + +/** Record the installed version as a plain text file. */ +export function noteInstalledVersion(dir: string, version: string): void { + Deno.writeTextFileSync(join(dir, kVersionFileName), version); +} + +/** Read the installed version. Returns undefined if not present. */ +export function readInstalledVersion(dir: string): string | undefined { + const path = join(dir, kVersionFileName); + if (!existsSync(path)) { + return undefined; + } + const text = Deno.readTextFileSync(path).trim(); + return text || undefined; +} + +/** Check if chrome-headless-shell is installed in the given directory. */ +export function isInstalled(dir: string): boolean { + return existsSync(join(dir, kVersionFileName)) && + findCftExecutable(dir, "chrome-headless-shell") !== undefined; +} + +// -- InstallableTool methods -- + +async function installed(): Promise { + return isInstalled(chromeHeadlessShellInstallDir()); +} + +function installDirIfInstalled(): Promise { + const dir = chromeHeadlessShellInstallDir(); + if (isInstalled(dir)) { + return Promise.resolve(dir); + } + return Promise.resolve(undefined); +} + +async function installedVersion(): Promise { + return readInstalledVersion(chromeHeadlessShellInstallDir()); +} + +async function latestRelease(): Promise { + const release = await fetchLatestCftRelease(); + const { platform } = detectCftPlatform(); + + const downloads = release.downloads["chrome-headless-shell"]; + if (!downloads) { + throw new Error("Chrome for Testing API has no chrome-headless-shell downloads"); + } + + const dl = downloads.find((d) => d.platform === platform); + if (!dl) { + throw new Error( + `No chrome-headless-shell download for platform ${platform}`, + ); + } + + return { + url: dl.url, + version: release.version, + assets: [{ name: "chrome-headless-shell", url: dl.url }], + }; +} + +async function preparePackage(ctx: InstallContext): Promise { + const release = await latestRelease(); + const workingDir = Deno.makeTempDirSync({ prefix: "quarto-chrome-hs-" }); + + try { + await downloadAndExtractCft( + "Chrome Headless Shell", + release.url, + workingDir, + ctx, + "chrome-headless-shell", + ); + } catch (e) { + safeRemoveSync(workingDir, { recursive: true }); + throw e; + } + + return { + filePath: workingDir, + version: release.version, + }; +} + +async function install(pkg: PackageInfo, _ctx: InstallContext): Promise { + const installDir = chromeHeadlessShellInstallDir(); + + // Clear existing contents + if (existsSync(installDir)) { + for (const entry of Deno.readDirSync(installDir)) { + safeRemoveSync(join(installDir, entry.name), { recursive: true }); + } + } + + // Move extracted contents into install directory + for (const entry of Deno.readDirSync(pkg.filePath)) { + safeMoveSync(join(pkg.filePath, entry.name), join(installDir, entry.name)); + } + + noteInstalledVersion(installDir, pkg.version); +} + +async function afterInstall(_ctx: InstallContext): Promise { + return false; +} + +async function uninstall(ctx: InstallContext): Promise { + await ctx.withSpinner( + { message: "Removing Chrome Headless Shell..." }, + async () => { + safeRemoveSync(chromeHeadlessShellInstallDir(), { recursive: true }); + }, + ); +} + +// -- Exported tool definition -- + +export const chromeHeadlessShellInstallable: InstallableTool = { + name: "Chrome Headless Shell", + prereqs: [], + installed, + installDir: installDirIfInstalled, + installedVersion, + latestRelease, + preparePackage, + install, + afterInstall, + uninstall, +}; diff --git a/tests/unit/tools/chrome-headless-shell.test.ts b/tests/unit/tools/chrome-headless-shell.test.ts new file mode 100644 index 00000000000..e79c2602606 --- /dev/null +++ b/tests/unit/tools/chrome-headless-shell.test.ts @@ -0,0 +1,240 @@ +/* + * chrome-headless-shell.test.ts + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { unitTest } from "../../test.ts"; +import { assert, assertEquals } from "testing/asserts"; +import { join } from "../../../src/deno_ral/path.ts"; +import { existsSync, safeRemoveSync } from "../../../src/deno_ral/fs.ts"; +import { isWindows } from "../../../src/deno_ral/platform.ts"; +import { runningInCI } from "../../../src/core/ci-info.ts"; +import { InstallContext } from "../../../src/tools/types.ts"; +import { findCftExecutable } from "../../../src/tools/impl/chrome-for-testing.ts"; +import { + chromeHeadlessShellInstallable, + chromeHeadlessShellInstallDir, + chromeHeadlessShellExecutablePath, + isInstalled, + noteInstalledVersion, + readInstalledVersion, +} from "../../../src/tools/impl/chrome-headless-shell.ts"; + +// -- Step 1: Install directory + executable path -- + +unitTest("chromeHeadlessShellInstallDir - path ends with chrome-headless-shell", async () => { + const dir = chromeHeadlessShellInstallDir(); + assert( + dir.replace(/\\/g, "/").endsWith("chrome-headless-shell"), + `Expected path ending with chrome-headless-shell, got: ${dir}`, + ); +}); + +unitTest("chromeHeadlessShellExecutablePath - returns undefined when not installed", async () => { + // If chrome-headless-shell happens to be installed, this test is still valid: + // it should return either a valid path or undefined, never throw. + const result = chromeHeadlessShellExecutablePath(); + if (result !== undefined) { + assert( + result.includes("chrome-headless-shell"), + `Expected path containing chrome-headless-shell, got: ${result}`, + ); + } + // No assertion failure means the function works correctly either way +}); + +// -- Step 2: Version helpers -- + +unitTest("version - round-trip write and read", async () => { + const tempDir = Deno.makeTempDirSync(); + try { + noteInstalledVersion(tempDir, "145.0.7632.46"); + const read = readInstalledVersion(tempDir); + assertEquals(read, "145.0.7632.46"); + } finally { + safeRemoveSync(tempDir, { recursive: true }); + } +}); + +unitTest("version - returns undefined for empty dir", async () => { + const tempDir = Deno.makeTempDirSync(); + try { + assertEquals(readInstalledVersion(tempDir), undefined); + } finally { + safeRemoveSync(tempDir, { recursive: true }); + } +}); + +// -- Step 3: isInstalled() -- + +unitTest("isInstalled - returns false when directory is empty", async () => { + const tempDir = Deno.makeTempDirSync(); + try { + assertEquals(isInstalled(tempDir), false); + } finally { + safeRemoveSync(tempDir, { recursive: true }); + } +}); + +unitTest("isInstalled - returns false when only version file exists", async () => { + const tempDir = Deno.makeTempDirSync(); + try { + noteInstalledVersion(tempDir, "145.0.0.0"); + assertEquals(isInstalled(tempDir), false); + } finally { + safeRemoveSync(tempDir, { recursive: true }); + } +}); + +unitTest("isInstalled - returns false when only binary exists (no version file)", async () => { + const tempDir = Deno.makeTempDirSync(); + try { + const subdir = join(tempDir, "chrome-headless-shell-win64"); + Deno.mkdirSync(subdir); + const binaryName = isWindows ? "chrome-headless-shell.exe" : "chrome-headless-shell"; + Deno.writeTextFileSync(join(subdir, binaryName), "fake"); + assertEquals(isInstalled(tempDir), false); + } finally { + safeRemoveSync(tempDir, { recursive: true }); + } +}); + +unitTest("isInstalled - returns true when version file and binary exist", async () => { + const tempDir = Deno.makeTempDirSync(); + try { + noteInstalledVersion(tempDir, "145.0.0.0"); + const subdir = join(tempDir, "chrome-headless-shell-win64"); + Deno.mkdirSync(subdir); + const binaryName = isWindows ? "chrome-headless-shell.exe" : "chrome-headless-shell"; + Deno.writeTextFileSync(join(subdir, binaryName), "fake"); + + assertEquals(isInstalled(tempDir), true); + } finally { + safeRemoveSync(tempDir, { recursive: true }); + } +}); + +// -- Step 4: latestRelease() (external HTTP call, skip on CI) -- + +unitTest("latestRelease - returns valid RemotePackageInfo", async () => { + const release = await chromeHeadlessShellInstallable.latestRelease(); + assert(release.version, "version should be non-empty"); + assert( + /^\d+\.\d+\.\d+\.\d+$/.test(release.version), + `version format wrong: ${release.version}`, + ); + assert(release.url.startsWith("https://"), `URL should be https: ${release.url}`); + assert(release.url.includes(release.version), "URL should contain version"); + assert(release.assets.length > 0, "should have at least one asset"); + assertEquals(release.assets[0].name, "chrome-headless-shell"); +}, { ignore: runningInCI() }); + +// -- Step 5: preparePackage() (downloads ~50MB, skip on CI) -- + +function createMockContext(workingDir: string): InstallContext { + return { + workingDir, + info: (_msg: string) => {}, + withSpinner: async (_options, op) => { + await op(); + }, + error: (_msg: string) => {}, + confirm: async (_msg: string) => true, + download: async (_name: string, url: string, target: string) => { + const resp = await fetch(url); + if (!resp.ok) throw new Error(`Download failed: ${resp.status}`); + const data = new Uint8Array(await resp.arrayBuffer()); + Deno.writeFileSync(target, data); + }, + props: {}, + flags: {}, + }; +} + +unitTest("preparePackage - downloads and extracts chrome-headless-shell", async () => { + const tempDir = Deno.makeTempDirSync(); + const ctx = createMockContext(tempDir); + const pkg = await chromeHeadlessShellInstallable.preparePackage(ctx); + try { + assert(pkg.version, "version should be non-empty"); + assert(pkg.filePath, "filePath should be non-empty"); + const binary = findCftExecutable(pkg.filePath, "chrome-headless-shell"); + assert(binary !== undefined, "binary should exist in extracted dir"); + } finally { + safeRemoveSync(pkg.filePath, { recursive: true }); + safeRemoveSync(tempDir, { recursive: true }); + } +}, { ignore: runningInCI() }); + +// -- Step 6: afterInstall -- + +unitTest("afterInstall - returns false", async () => { + const tempDir = Deno.makeTempDirSync(); + const ctx = createMockContext(tempDir); + try { + const result = await chromeHeadlessShellInstallable.afterInstall(ctx); + assertEquals(result, false); + } finally { + safeRemoveSync(tempDir, { recursive: true }); + } +}); + +// -- Step 7: chromeHeadlessShellInstallable export -- + +unitTest("chromeHeadlessShellInstallable - has correct name and methods", async () => { + assertEquals(chromeHeadlessShellInstallable.name, "Chrome Headless Shell"); + assertEquals(chromeHeadlessShellInstallable.prereqs.length, 0); + assert(typeof chromeHeadlessShellInstallable.installed === "function"); + assert(typeof chromeHeadlessShellInstallable.installDir === "function"); + assert(typeof chromeHeadlessShellInstallable.installedVersion === "function"); + assert(typeof chromeHeadlessShellInstallable.latestRelease === "function"); + assert(typeof chromeHeadlessShellInstallable.preparePackage === "function"); + assert(typeof chromeHeadlessShellInstallable.install === "function"); + assert(typeof chromeHeadlessShellInstallable.afterInstall === "function"); + assert(typeof chromeHeadlessShellInstallable.uninstall === "function"); +}); + +// -- Integration: full install/uninstall lifecycle -- + +unitTest("install lifecycle - prepare, install, verify, uninstall", async () => { + const tool = chromeHeadlessShellInstallable; + const tempDir = Deno.makeTempDirSync(); + const ctx = createMockContext(tempDir); + + // Prepare (download + extract) + const pkg = await tool.preparePackage(ctx); + + try { + // Install into real quartoDataDir + await tool.install(pkg, ctx); + + // Verify installed state + assertEquals(await tool.installed(), true); + + const version = await tool.installedVersion(); + assert(version, "installedVersion should return a version string"); + assert(/^\d+\.\d+\.\d+\.\d+$/.test(version!), `version format: ${version}`); + + const exePath = chromeHeadlessShellExecutablePath(); + assert(exePath !== undefined, "executable path should be defined after install"); + assert(existsSync(exePath!), `executable should exist at: ${exePath}`); + + const dir = await tool.installDir(); + assert(dir !== undefined, "installDir should return a path when installed"); + + // Uninstall + await tool.uninstall(ctx); + + // Verify uninstalled state + assertEquals(await tool.installed(), false); + assertEquals(chromeHeadlessShellExecutablePath(), undefined); + } finally { + // Safety net: ensure uninstall happened even if assertions failed + if (await tool.installed()) { + await tool.uninstall(ctx); + } + safeRemoveSync(pkg.filePath, { recursive: true }); + safeRemoveSync(tempDir, { recursive: true }); + } +}, { ignore: runningInCI() }); From 6808369a82157e2d371437385bee5675f266de14 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 12 Feb 2026 14:36:01 +0100 Subject: [PATCH 03/13] Wire chrome-headless-shell into tool registry, browser discovery, and quarto check - Register chrome-headless-shell in kInstallableTools, enabling `quarto install/uninstall chrome-headless-shell` - Fix installableTools() to return registry keys instead of lowercased display names (broken for multi-word tool names) - Add WSL handling: allow chrome-headless-shell install with info note (unlike full chromium which blocks on WSL) - Insert chrome-headless-shell as priority 2 in getBrowserExecutablePath() between system Chrome and legacy puppeteer revisions - Update error message to recommend chrome-headless-shell over chromium - Add chrome-headless-shell detection in `quarto check install` - Fix pre-existing bug: chromium lookup in quarto check used display name comparison that never matched due to casing; now uses registry key lookup Co-Authored-By: Claude Opus 4.6 --- src/command/check/check.ts | 27 ++++++++++++++++--- src/core/puppeteer.ts | 10 ++++++- src/tools/tools.ts | 14 +++++----- .../unit/tools/chrome-headless-shell.test.ts | 17 ++++++++++++ 4 files changed, 57 insertions(+), 11 deletions(-) diff --git a/src/command/check/check.ts b/src/command/check/check.ts index dfba6f03468..ac8f1f3d0b8 100644 --- a/src/command/check/check.ts +++ b/src/command/check/check.ts @@ -22,7 +22,7 @@ import { pandocBinaryPath } from "../../core/resources.ts"; import { lines } from "../../core/text.ts"; import { satisfies } from "semver/mod.ts"; import { dartCommand } from "../../core/dart-sass.ts"; -import { allTools } from "../../tools/tools.ts"; +import { allTools, installableTool } from "../../tools/tools.ts"; import { texLiveContext, tlVersion } from "../render/latexmk/texlive.ts"; import { which } from "../../core/path.ts"; import { dirname } from "../../deno_ral/path.ts"; @@ -32,6 +32,11 @@ import { quartoCacheDir } from "../../core/appdirs.ts"; import { isWindows } from "../../deno_ral/platform.ts"; import { makeStringEnumTypeEnforcer } from "../../typing/dynamic.ts"; import { findChrome } from "../../core/puppeteer.ts"; +import { + chromeHeadlessShellExecutablePath, + chromeHeadlessShellInstallDir, + readInstalledVersion, +} from "../../tools/impl/chrome-headless-shell.ts"; import { executionEngines } from "../../execute/engine.ts"; export function getTargets(): readonly string[] { @@ -437,9 +442,11 @@ async function checkInstall(conf: CheckConfiguration) { } const chromeCb = async () => { const chromeDetected = await findChrome(); - const chromiumQuarto = tools.installed.find((tool) => - tool.name === "chromium" - ); + const chromeHsPath = chromeHeadlessShellExecutablePath(); + const chromiumTool = installableTool("chromium"); + const chromiumQuarto = chromiumTool && await chromiumTool.installed() + ? chromiumTool + : undefined; if (chromeDetected.path !== undefined) { chromeHeadlessOutput.push(`${kIndent}Using: Chrome found on system`); chromeHeadlessOutput.push( @@ -450,6 +457,18 @@ async function checkInstall(conf: CheckConfiguration) { } chromeJson["path"] = chromeDetected.path; chromeJson["source"] = chromeDetected.source; + } else if (chromeHsPath !== undefined) { + const version = readInstalledVersion(chromeHeadlessShellInstallDir()); + chromeJson["source"] = "quarto-chrome-headless-shell"; + chromeHeadlessOutput.push( + `${kIndent}Using: Chrome Headless Shell installed by Quarto`, + ); + chromeHeadlessOutput.push(`${kIndent}Path: ${chromeHsPath}`); + chromeJson["path"] = chromeHsPath; + if (version) { + chromeHeadlessOutput.push(`${kIndent}Version: ${version}`); + chromeJson["version"] = version; + } } else if (chromiumQuarto !== undefined) { chromeJson["source"] = "quarto"; chromeHeadlessOutput.push( diff --git a/src/core/puppeteer.ts b/src/core/puppeteer.ts index a86b694bf7e..5928327546a 100644 --- a/src/core/puppeteer.ts +++ b/src/core/puppeteer.ts @@ -12,6 +12,7 @@ import { UnreachableError } from "./lib/error.ts"; import { quartoDataDir } from "./appdirs.ts"; import { isMac, isWindows } from "../deno_ral/platform.ts"; import puppeteer from "puppeteer"; +import { chromeHeadlessShellExecutablePath } from "../tools/impl/chrome-headless-shell.ts"; // deno-lint-ignore no-explicit-any // let puppeteerImport: any = undefined; @@ -291,10 +292,17 @@ export async function getBrowserExecutablePath() { let executablePath: string | undefined = undefined; + // Priority 1: QUARTO_CHROMIUM env var + system Chrome/Edge if (executablePath === undefined) { executablePath = (await findChrome()).path; } + // Priority 2: Quarto-installed chrome-headless-shell + if (executablePath === undefined) { + executablePath = chromeHeadlessShellExecutablePath(); + } + + // Priority 3: Legacy puppeteer-managed Chromium revisions if (executablePath === undefined && availableRevisions.length > 0) { // get the latest available revision availableRevisions.sort((a: string, b: string) => Number(b) - Number(a)); @@ -306,7 +314,7 @@ export async function getBrowserExecutablePath() { if (executablePath === undefined) { error("Chrome not found"); info( - "\nNo Chrome or Chromium installation was detected.\n\nPlease run 'quarto install chromium' to install Chromium.\n", + "\nNo Chrome or Chromium installation was detected.\n\nPlease run 'quarto install chrome-headless-shell' to install a headless browser.\n", ); throw new Error(); } diff --git a/src/tools/tools.ts b/src/tools/tools.ts index b8c59d6abed..656ae13617f 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -18,6 +18,7 @@ import { } from "./types.ts"; import { tinyTexInstallable } from "./impl/tinytex.ts"; import { chromiumInstallable } from "./impl/chromium.ts"; +import { chromeHeadlessShellInstallable } from "./impl/chrome-headless-shell.ts"; import { verapdfInstallable } from "./impl/verapdf.ts"; import { downloadWithProgress } from "../core/download.ts"; import { Confirm } from "cliffy/prompt/mod.ts"; @@ -32,6 +33,7 @@ const kInstallableTools: { [key: string]: InstallableTool } = { tinytex: tinyTexInstallable, // temporarily disabled until deno 1.28.* gets puppeteer support chromium: chromiumInstallable, + "chrome-headless-shell": chromeHeadlessShellInstallable, verapdf: verapdfInstallable, }; @@ -59,12 +61,7 @@ export async function allTools(): Promise<{ } export function installableTools(): string[] { - const tools: string[] = []; - Object.keys(kInstallableTools).forEach((key) => { - const tool = kInstallableTools[key]; - tools.push(tool.name.toLowerCase()); - }); - return tools; + return Object.keys(kInstallableTools); } export async function printToolInfo(name: string) { @@ -101,6 +98,11 @@ export function checkToolRequirement(name: string) { "- See https://github.com/quarto-dev/quarto-cli/issues/1822 for more context.", ].join("\n")); return false; + } else if (name.toLowerCase() === "chrome-headless-shell" && isWSL()) { + info( + "Note: chrome-headless-shell is a headless-only binary and should work on WSL without additional system dependencies.", + ); + return true; } else { return true; } diff --git a/tests/unit/tools/chrome-headless-shell.test.ts b/tests/unit/tools/chrome-headless-shell.test.ts index e79c2602606..34c56463101 100644 --- a/tests/unit/tools/chrome-headless-shell.test.ts +++ b/tests/unit/tools/chrome-headless-shell.test.ts @@ -12,6 +12,7 @@ import { isWindows } from "../../../src/deno_ral/platform.ts"; import { runningInCI } from "../../../src/core/ci-info.ts"; import { InstallContext } from "../../../src/tools/types.ts"; import { findCftExecutable } from "../../../src/tools/impl/chrome-for-testing.ts"; +import { installableTool, installableTools } from "../../../src/tools/tools.ts"; import { chromeHeadlessShellInstallable, chromeHeadlessShellInstallDir, @@ -238,3 +239,19 @@ unitTest("install lifecycle - prepare, install, verify, uninstall", async () => safeRemoveSync(tempDir, { recursive: true }); } }, { ignore: runningInCI() }); + +// -- Step 8: Tool registry integration -- + +unitTest("tool registry - chrome-headless-shell is listed in installableTools", async () => { + const tools = installableTools(); + assert( + tools.includes("chrome-headless-shell"), + `installableTools() should include "chrome-headless-shell", got: ${tools}`, + ); +}); + +unitTest("tool registry - installableTool looks up chrome-headless-shell", async () => { + const tool = installableTool("chrome-headless-shell"); + assert(tool !== undefined, "installableTool should find chrome-headless-shell"); + assertEquals(tool.name, "Chrome Headless Shell"); +}); From 2254b547792e28faa86a83d9434ce26506feb977 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 12 Feb 2026 14:50:04 +0100 Subject: [PATCH 04/13] Fix chromium branch in quarto check to await async methods The chromium fallback branch in chromeCb() accessed binDir and installedVersion as properties but they are async functions on the InstallableTool interface. This was pre-existing dead code (the name comparison never matched), but now reachable after the registry key lookup fix. Properly await both methods. Co-Authored-By: Claude Opus 4.6 --- src/command/check/check.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/command/check/check.ts b/src/command/check/check.ts index ac8f1f3d0b8..3b79e2a07f2 100644 --- a/src/command/check/check.ts +++ b/src/command/check/check.ts @@ -474,16 +474,20 @@ async function checkInstall(conf: CheckConfiguration) { chromeHeadlessOutput.push( `${kIndent}Using: Chromium installed by Quarto`, ); - if (chromiumQuarto?.binDir) { - chromeHeadlessOutput.push( - `${kIndent}Path: ${chromiumQuarto?.binDir}`, - ); - chromeJson["path"] = chromiumQuarto?.binDir; + if (chromiumQuarto.binDir) { + const binPath = await chromiumQuarto.binDir(); + if (binPath) { + chromeHeadlessOutput.push( + `${kIndent}Path: ${binPath}`, + ); + chromeJson["path"] = binPath; + } } + const chromiumVersion = await chromiumQuarto.installedVersion(); chromeHeadlessOutput.push( - `${kIndent}Version: ${chromiumQuarto.installedVersion}`, + `${kIndent}Version: ${chromiumVersion}`, ); - chromeJson["version"] = chromiumQuarto.installedVersion; + chromeJson["version"] = chromiumVersion; } else { chromeHeadlessOutput.push(`${kIndent}Chrome: (not detected)`); chromeJson["installed"] = false; From d58e156bece65440a54f91e24cf4303f9b109668 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 12 Feb 2026 14:58:54 +0100 Subject: [PATCH 05/13] Guard chromium version output against undefined in quarto check Add if (chromiumVersion) guard before displaying version, consistent with the chrome-headless-shell branch. Prevents showing literal "undefined" if the version file is missing. Co-Authored-By: Claude Opus 4.6 --- src/command/check/check.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/command/check/check.ts b/src/command/check/check.ts index 3b79e2a07f2..74594ebab55 100644 --- a/src/command/check/check.ts +++ b/src/command/check/check.ts @@ -484,10 +484,12 @@ async function checkInstall(conf: CheckConfiguration) { } } const chromiumVersion = await chromiumQuarto.installedVersion(); - chromeHeadlessOutput.push( - `${kIndent}Version: ${chromiumVersion}`, - ); - chromeJson["version"] = chromiumVersion; + if (chromiumVersion) { + chromeHeadlessOutput.push( + `${kIndent}Version: ${chromiumVersion}`, + ); + chromeJson["version"] = chromiumVersion; + } } else { chromeHeadlessOutput.push(`${kIndent}Chrome: (not detected)`); chromeJson["installed"] = false; From 37949f8cabf6c74979ffa9411ace92ee4cb1564f Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 12 Feb 2026 15:40:04 +0100 Subject: [PATCH 06/13] Add smoke tests for Chrome-based diagram rendering and update CI Add smoke tests exercising mermaid and graphviz rendering through chrome-headless-shell (PDF, Typst, multi-diagram). Remove not_os:linux restrictions from existing mermaid SVG tests since Chrome is now available on all CI platforms. Replace browser-actions/setup-chrome with quarto install chrome-headless-shell in CI workflow. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/test-smokes.yml | 7 +++- tests/docs/smoke-all/2025/11/10/13661.qmd | 2 - .../smoke-all/2025/11/14/mermaid-svg-docx.qmd | 2 - .../smoke-all/2025/11/18/mermaid-gfm-svg.qmd | 2 - tests/docs/smoke-all/2026/02/12/dot-pdf.qmd | 20 ++++++++++ .../2026/02/12/mermaid-multi-diagram.qmd | 39 +++++++++++++++++++ .../2026/02/12/mermaid-pdf-default.qmd | 19 +++++++++ .../smoke-all/2026/02/12/mermaid-typst.qmd | 19 +++++++++ 8 files changed, 102 insertions(+), 8 deletions(-) create mode 100644 tests/docs/smoke-all/2026/02/12/dot-pdf.qmd create mode 100644 tests/docs/smoke-all/2026/02/12/mermaid-multi-diagram.qmd create mode 100644 tests/docs/smoke-all/2026/02/12/mermaid-pdf-default.qmd create mode 100644 tests/docs/smoke-all/2026/02/12/mermaid-typst.qmd diff --git a/.github/workflows/test-smokes.yml b/.github/workflows/test-smokes.yml index 5cd77c772fa..068090dbd8e 100644 --- a/.github/workflows/test-smokes.yml +++ b/.github/workflows/test-smokes.yml @@ -212,8 +212,11 @@ jobs: id: cache-typst uses: ./.github/actions/cache-typst - - name: Install Chrome - uses: browser-actions/setup-chrome@latest + - name: Install Chrome Headless Shell + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + quarto install chrome-headless-shell --no-prompt - name: Setup Julia uses: julia-actions/setup-julia@v2 diff --git a/tests/docs/smoke-all/2025/11/10/13661.qmd b/tests/docs/smoke-all/2025/11/10/13661.qmd index efbe8181eef..01f4a86a32d 100644 --- a/tests/docs/smoke-all/2025/11/10/13661.qmd +++ b/tests/docs/smoke-all/2025/11/10/13661.qmd @@ -12,8 +12,6 @@ format: use-rsvg-convert: false _quarto: tests: - run: - not_os: linux latex: printsMessage: level: INFO diff --git a/tests/docs/smoke-all/2025/11/14/mermaid-svg-docx.qmd b/tests/docs/smoke-all/2025/11/14/mermaid-svg-docx.qmd index 93f35226026..0dee48fc9a4 100644 --- a/tests/docs/smoke-all/2025/11/14/mermaid-svg-docx.qmd +++ b/tests/docs/smoke-all/2025/11/14/mermaid-svg-docx.qmd @@ -5,8 +5,6 @@ format: mermaid-format: svg _quarto: tests: - run: - not_os: linux docx: printsMessage: level: INFO diff --git a/tests/docs/smoke-all/2025/11/18/mermaid-gfm-svg.qmd b/tests/docs/smoke-all/2025/11/18/mermaid-gfm-svg.qmd index 7024ccd46df..02935780303 100644 --- a/tests/docs/smoke-all/2025/11/18/mermaid-gfm-svg.qmd +++ b/tests/docs/smoke-all/2025/11/18/mermaid-gfm-svg.qmd @@ -4,8 +4,6 @@ format: gfm mermaid-format: svg _quarto: tests: - run: - not_os: linux gfm: ensureFileRegexMatches: - ['img.*mermaid-figure-.*?\.svg'] # Verify markdown image link to SVG diff --git a/tests/docs/smoke-all/2026/02/12/dot-pdf.qmd b/tests/docs/smoke-all/2026/02/12/dot-pdf.qmd new file mode 100644 index 00000000000..cfa5cb6540a --- /dev/null +++ b/tests/docs/smoke-all/2026/02/12/dot-pdf.qmd @@ -0,0 +1,20 @@ +--- +title: "Graphviz to PDF (#11877)" +format: pdf +_quarto: + tests: + pdf: + noErrors: default +--- + +## Graphviz Diagram + +```{dot} +digraph G { + A -> B + B -> C + B -> D + C -> E + D -> E +} +``` diff --git a/tests/docs/smoke-all/2026/02/12/mermaid-multi-diagram.qmd b/tests/docs/smoke-all/2026/02/12/mermaid-multi-diagram.qmd new file mode 100644 index 00000000000..7e7cc7b02c9 --- /dev/null +++ b/tests/docs/smoke-all/2026/02/12/mermaid-multi-diagram.qmd @@ -0,0 +1,39 @@ +--- +title: "Multiple Mermaid Diagrams to PDF (#11877)" +format: pdf +_quarto: + tests: + pdf: + noErrors: default +--- + +## Flowchart + +```{mermaid} +graph TD + A[Start] --> B{Decision} + B -->|Yes| C[Good] + B -->|No| D[Bad] + C --> E[End] + D --> E +``` + +## Sequence Diagram + +```{mermaid} +sequenceDiagram + Alice->>Bob: Hello + Bob-->>Alice: Hi back + Alice->>Bob: How are you? + Bob-->>Alice: Good thanks +``` + +## Pie Chart + +```{mermaid} +pie title Favorite Languages + "TypeScript" : 40 + "Lua" : 30 + "R" : 20 + "Python" : 10 +``` diff --git a/tests/docs/smoke-all/2026/02/12/mermaid-pdf-default.qmd b/tests/docs/smoke-all/2026/02/12/mermaid-pdf-default.qmd new file mode 100644 index 00000000000..fb95548f539 --- /dev/null +++ b/tests/docs/smoke-all/2026/02/12/mermaid-pdf-default.qmd @@ -0,0 +1,19 @@ +--- +title: "Mermaid to PDF (#11877)" +format: pdf +_quarto: + tests: + pdf: + noErrors: default +--- + +## Mermaid Flowchart + +```{mermaid} +graph TD + A[Start] --> B{Decision} + B -->|Yes| C[Good] + B -->|No| D[Bad] + C --> E[End] + D --> E +``` diff --git a/tests/docs/smoke-all/2026/02/12/mermaid-typst.qmd b/tests/docs/smoke-all/2026/02/12/mermaid-typst.qmd new file mode 100644 index 00000000000..1e4b7cd157c --- /dev/null +++ b/tests/docs/smoke-all/2026/02/12/mermaid-typst.qmd @@ -0,0 +1,19 @@ +--- +title: "Mermaid to Typst (#11877)" +format: typst +_quarto: + tests: + typst: + noErrors: default +--- + +## Mermaid Flowchart + +```{mermaid} +graph TD + A[Start] --> B{Decision} + B -->|Yes| C[Good] + B -->|No| D[Bad] + C --> E[End] + D --> E +``` From f11202849e7f212989979273ac1d3dd56f47efb2 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 12 Feb 2026 16:46:05 +0100 Subject: [PATCH 07/13] Verify diagram screenshots in smoke tests, not just error-free render Check intermediate .tex/.typ files for \includegraphics and image() references to mermaid-figure and dot-figure PNGs. This proves Chrome actually produced screenshot images rather than only checking the render didn't error. Co-Authored-By: Claude Opus 4.6 --- tests/docs/smoke-all/2026/02/12/dot-pdf.qmd | 7 ++++++- tests/docs/smoke-all/2026/02/12/mermaid-multi-diagram.qmd | 7 ++++++- tests/docs/smoke-all/2026/02/12/mermaid-pdf-default.qmd | 7 ++++++- tests/docs/smoke-all/2026/02/12/mermaid-typst.qmd | 7 ++++++- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/tests/docs/smoke-all/2026/02/12/dot-pdf.qmd b/tests/docs/smoke-all/2026/02/12/dot-pdf.qmd index cfa5cb6540a..27e836c9722 100644 --- a/tests/docs/smoke-all/2026/02/12/dot-pdf.qmd +++ b/tests/docs/smoke-all/2026/02/12/dot-pdf.qmd @@ -1,10 +1,15 @@ --- title: "Graphviz to PDF (#11877)" -format: pdf +format: + pdf: + keep-tex: true _quarto: tests: pdf: noErrors: default + ensureLatexFileRegexMatches: + - ['\\includegraphics.*dot-figure-1\.png'] + - [] --- ## Graphviz Diagram diff --git a/tests/docs/smoke-all/2026/02/12/mermaid-multi-diagram.qmd b/tests/docs/smoke-all/2026/02/12/mermaid-multi-diagram.qmd index 7e7cc7b02c9..519aecd5a92 100644 --- a/tests/docs/smoke-all/2026/02/12/mermaid-multi-diagram.qmd +++ b/tests/docs/smoke-all/2026/02/12/mermaid-multi-diagram.qmd @@ -1,10 +1,15 @@ --- title: "Multiple Mermaid Diagrams to PDF (#11877)" -format: pdf +format: + pdf: + keep-tex: true _quarto: tests: pdf: noErrors: default + ensureLatexFileRegexMatches: + - ['\\includegraphics.*mermaid-figure-1\.png', '\\includegraphics.*mermaid-figure-2\.png', '\\includegraphics.*mermaid-figure-3\.png'] + - [] --- ## Flowchart diff --git a/tests/docs/smoke-all/2026/02/12/mermaid-pdf-default.qmd b/tests/docs/smoke-all/2026/02/12/mermaid-pdf-default.qmd index fb95548f539..4c28a585704 100644 --- a/tests/docs/smoke-all/2026/02/12/mermaid-pdf-default.qmd +++ b/tests/docs/smoke-all/2026/02/12/mermaid-pdf-default.qmd @@ -1,10 +1,15 @@ --- title: "Mermaid to PDF (#11877)" -format: pdf +format: + pdf: + keep-tex: true _quarto: tests: pdf: noErrors: default + ensureLatexFileRegexMatches: + - ['\\includegraphics.*mermaid-figure-1\.png'] + - [] --- ## Mermaid Flowchart diff --git a/tests/docs/smoke-all/2026/02/12/mermaid-typst.qmd b/tests/docs/smoke-all/2026/02/12/mermaid-typst.qmd index 1e4b7cd157c..0c4195f6eb3 100644 --- a/tests/docs/smoke-all/2026/02/12/mermaid-typst.qmd +++ b/tests/docs/smoke-all/2026/02/12/mermaid-typst.qmd @@ -1,10 +1,15 @@ --- title: "Mermaid to Typst (#11877)" -format: typst +format: + typst: + keep-typ: true _quarto: tests: typst: noErrors: default + ensureTypstFileRegexMatches: + - ['image\(.*mermaid-figure-1\.png'] + - [] --- ## Mermaid Flowchart From cd52e2124b647bac9698b6b1d2967bc545d7c362 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 12 Feb 2026 18:43:49 +0100 Subject: [PATCH 08/13] Add to changelog --- news/changelog-1.9.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index d7edc12bdb8..e31cc1096a9 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -136,9 +136,10 @@ All changes included in 1.9: - (): New `quarto call build-ts-extension` command builds a TypeScript extension, such as an engine extension, and places the artifacts in the `_extensions` directory. See the [engine extension pre-release documentation](https://prerelease.quarto.org/docs/extensions/engine.html) for details. -### `install verapdf` +### `install` - ([#4426](https://github.com/quarto-dev/quarto-cli/issues/4426)): New `quarto install verapdf` command installs [veraPDF](https://verapdf.org/) for PDF/A and PDF/UA validation. When verapdf is available, PDFs created with the `pdf-standard` option are automatically validated for compliance. Also supports `quarto uninstall verapdf`, `quarto update verapdf`, and `quarto tools`. +- ([#11877](https://github.com/quarto-dev/quarto-cli/issues/11877), [#10961](https://github.com/quarto-dev/quarto-cli/issues/10961), [#6821](https://github.com/quarto-dev/quarto-cli/issues/6821)): New `quarto install chrome-headless-shell` command downloads [Chrome Headless Shell](https://developer.chrome.com/blog/chrome-headless-shell) from Google's Chrome for Testing API. This is the recommended headless browser for diagram rendering (Mermaid, Graphviz) to non-HTML formats. Smaller and lighter than full Chrome, with fewer system dependencies. ### `preview` From c100f3c4be356f86fc1e9bdb5290b90661d17a13 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 12 Feb 2026 19:03:59 +0100 Subject: [PATCH 09/13] Change priority order for chromium to use - Env var - chrome-headless-shell - system - chromium --- src/command/check/check.ts | 29 ++++++++++++++++++----------- src/core/puppeteer.ts | 38 ++++++++++++++++++++------------------ 2 files changed, 38 insertions(+), 29 deletions(-) diff --git a/src/command/check/check.ts b/src/command/check/check.ts index 74594ebab55..5635dca173b 100644 --- a/src/command/check/check.ts +++ b/src/command/check/check.ts @@ -32,6 +32,7 @@ import { quartoCacheDir } from "../../core/appdirs.ts"; import { isWindows } from "../../deno_ral/platform.ts"; import { makeStringEnumTypeEnforcer } from "../../typing/dynamic.ts"; import { findChrome } from "../../core/puppeteer.ts"; +import { safeExistsSync } from "../../core/path.ts"; import { chromeHeadlessShellExecutablePath, chromeHeadlessShellInstallDir, @@ -441,22 +442,18 @@ async function checkInstall(conf: CheckConfiguration) { conf.jsonResult.chrome = chromeJson; } const chromeCb = async () => { - const chromeDetected = await findChrome(); + const envPath = Deno.env.get("QUARTO_CHROMIUM"); const chromeHsPath = chromeHeadlessShellExecutablePath(); + const chromeDetected = await findChrome(); const chromiumTool = installableTool("chromium"); const chromiumQuarto = chromiumTool && await chromiumTool.installed() ? chromiumTool : undefined; - if (chromeDetected.path !== undefined) { - chromeHeadlessOutput.push(`${kIndent}Using: Chrome found on system`); - chromeHeadlessOutput.push( - `${kIndent}Path: ${chromeDetected.path}`, - ); - if (chromeDetected.source) { - chromeHeadlessOutput.push(`${kIndent}Source: ${chromeDetected.source}`); - } - chromeJson["path"] = chromeDetected.path; - chromeJson["source"] = chromeDetected.source; + if (envPath && safeExistsSync(envPath)) { + chromeHeadlessOutput.push(`${kIndent}Using: Chrome from QUARTO_CHROMIUM`); + chromeHeadlessOutput.push(`${kIndent}Path: ${envPath}`); + chromeJson["path"] = envPath; + chromeJson["source"] = "QUARTO_CHROMIUM"; } else if (chromeHsPath !== undefined) { const version = readInstalledVersion(chromeHeadlessShellInstallDir()); chromeJson["source"] = "quarto-chrome-headless-shell"; @@ -469,6 +466,16 @@ async function checkInstall(conf: CheckConfiguration) { chromeHeadlessOutput.push(`${kIndent}Version: ${version}`); chromeJson["version"] = version; } + } else if (chromeDetected.path !== undefined) { + chromeHeadlessOutput.push(`${kIndent}Using: Chrome found on system`); + chromeHeadlessOutput.push( + `${kIndent}Path: ${chromeDetected.path}`, + ); + if (chromeDetected.source) { + chromeHeadlessOutput.push(`${kIndent}Source: ${chromeDetected.source}`); + } + chromeJson["path"] = chromeDetected.path; + chromeJson["source"] = chromeDetected.source; } else if (chromiumQuarto !== undefined) { chromeJson["source"] = "quarto"; chromeHeadlessOutput.push( diff --git a/src/core/puppeteer.ts b/src/core/puppeteer.ts index 5928327546a..ca58222ba37 100644 --- a/src/core/puppeteer.ts +++ b/src/core/puppeteer.ts @@ -213,20 +213,8 @@ interface ChromeInfo { export async function findChrome(): Promise { let path; let source; - // First check env var and use this path if specified - const envPath = Deno.env.get("QUARTO_CHROMIUM"); - if (envPath) { - debug("[CHROMIUM] Using path specified in QUARTO_CHROMIUM"); - if (safeExistsSync(envPath)) { - debug(`[CHROMIUM] Found at ${envPath}, and will be used.`); - return { path: envPath, source: "QUARTO_CHROMIUM" }; - } else { - debug( - `[CHROMIUM] Not found at ${envPath}. Check your environment variable valye. Searching now for another binary.`, - ); - } - } - // Otherwise, try to find the path based on OS. + // Find Chrome/Edge from OS-specific known locations. + // QUARTO_CHROMIUM env var is handled by callers before calling this function. if (isMac) { const programs = [ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", @@ -292,17 +280,31 @@ export async function getBrowserExecutablePath() { let executablePath: string | undefined = undefined; - // Priority 1: QUARTO_CHROMIUM env var + system Chrome/Edge - if (executablePath === undefined) { - executablePath = (await findChrome()).path; + // Priority 1: QUARTO_CHROMIUM env var + const envPath = Deno.env.get("QUARTO_CHROMIUM"); + if (envPath && safeExistsSync(envPath)) { + debug(`[CHROMIUM] Using QUARTO_CHROMIUM: ${envPath}`); + executablePath = envPath; } // Priority 2: Quarto-installed chrome-headless-shell if (executablePath === undefined) { executablePath = chromeHeadlessShellExecutablePath(); + if (executablePath) { + debug(`[CHROMIUM] Using chrome-headless-shell: ${executablePath}`); + } + } + + // Priority 3: System Chrome/Edge + if (executablePath === undefined) { + const chromeInfo = await findChrome(); + if (chromeInfo.path) { + debug(`[CHROMIUM] Using system Chrome from ${chromeInfo.source}: ${chromeInfo.path}`); + executablePath = chromeInfo.path; + } } - // Priority 3: Legacy puppeteer-managed Chromium revisions + // Priority 4: Legacy puppeteer-managed Chromium revisions if (executablePath === undefined && availableRevisions.length > 0) { // get the latest available revision availableRevisions.sort((a: string, b: string) => Number(b) - Number(a)); From efac62a4b97b4b3887d9e58e2e48064154bb3ee9 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 12 Feb 2026 20:18:12 +0100 Subject: [PATCH 10/13] Add one more related issue to changelog This should also close mermaid issues on CI --- news/changelog-1.9.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index e31cc1096a9..139fdabf048 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -139,7 +139,7 @@ All changes included in 1.9: ### `install` - ([#4426](https://github.com/quarto-dev/quarto-cli/issues/4426)): New `quarto install verapdf` command installs [veraPDF](https://verapdf.org/) for PDF/A and PDF/UA validation. When verapdf is available, PDFs created with the `pdf-standard` option are automatically validated for compliance. Also supports `quarto uninstall verapdf`, `quarto update verapdf`, and `quarto tools`. -- ([#11877](https://github.com/quarto-dev/quarto-cli/issues/11877), [#10961](https://github.com/quarto-dev/quarto-cli/issues/10961), [#6821](https://github.com/quarto-dev/quarto-cli/issues/6821)): New `quarto install chrome-headless-shell` command downloads [Chrome Headless Shell](https://developer.chrome.com/blog/chrome-headless-shell) from Google's Chrome for Testing API. This is the recommended headless browser for diagram rendering (Mermaid, Graphviz) to non-HTML formats. Smaller and lighter than full Chrome, with fewer system dependencies. +- ([#11877](https://github.com/quarto-dev/quarto-cli/issues/11877), [#10961](https://github.com/quarto-dev/quarto-cli/issues/10961), [#6821](https://github.com/quarto-dev/quarto-cli/issues/6821), [#13704](https://github.com/quarto-dev/quarto-cli/issues/13704)): New `quarto install chrome-headless-shell` command downloads [Chrome Headless Shell](https://developer.chrome.com/blog/chrome-headless-shell) from Google's Chrome for Testing API. This is the recommended headless browser for diagram rendering (Mermaid, Graphviz) to non-HTML formats. Smaller and lighter than full Chrome, with fewer system dependencies. ### `preview` From fa467da4eab300a9d57238c6aaa83701f0253c5c Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 12 Feb 2026 20:24:54 +0100 Subject: [PATCH 11/13] Correctly handle non existant path in `QUARTO_CHROMIUM` --- src/command/check/check.ts | 6 ++++++ src/core/puppeteer.ts | 10 +++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/command/check/check.ts b/src/command/check/check.ts index 5635dca173b..e1ce24c8349 100644 --- a/src/command/check/check.ts +++ b/src/command/check/check.ts @@ -449,6 +449,12 @@ async function checkInstall(conf: CheckConfiguration) { const chromiumQuarto = chromiumTool && await chromiumTool.installed() ? chromiumTool : undefined; + if (envPath && !safeExistsSync(envPath)) { + chromeHeadlessOutput.push( + `${kIndent}NOTE: QUARTO_CHROMIUM is set to ${envPath} but the path does not exist.`, + ); + chromeJson["QUARTO_CHROMIUM_invalid"] = envPath; + } if (envPath && safeExistsSync(envPath)) { chromeHeadlessOutput.push(`${kIndent}Using: Chrome from QUARTO_CHROMIUM`); chromeHeadlessOutput.push(`${kIndent}Path: ${envPath}`); diff --git a/src/core/puppeteer.ts b/src/core/puppeteer.ts index ca58222ba37..abd327f9253 100644 --- a/src/core/puppeteer.ts +++ b/src/core/puppeteer.ts @@ -282,9 +282,13 @@ export async function getBrowserExecutablePath() { // Priority 1: QUARTO_CHROMIUM env var const envPath = Deno.env.get("QUARTO_CHROMIUM"); - if (envPath && safeExistsSync(envPath)) { - debug(`[CHROMIUM] Using QUARTO_CHROMIUM: ${envPath}`); - executablePath = envPath; + if (envPath) { + if (safeExistsSync(envPath)) { + debug(`[CHROMIUM] Using QUARTO_CHROMIUM: ${envPath}`); + executablePath = envPath; + } else { + debug(`[CHROMIUM] QUARTO_CHROMIUM is set to ${envPath} but the path does not exist. Searching for another browser.`); + } } // Priority 2: Quarto-installed chrome-headless-shell From aa4b99dc410a6017462793c1ff5712381d594183 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 12 Feb 2026 20:40:37 +0100 Subject: [PATCH 12/13] Refactor `chromeCb` in check.ts to separate detection from formatting Extract browser detection logic into `detectChromeForCheck()` helper, replacing the 4-branch if/else chain with a data object and single formatting block. Fixes redundant `safeExistsSync` call and normalizes JSON key from `QUARTO_CHROMIUM_invalid` to `warning`. Co-Authored-By: Claude Opus 4.6 --- src/command/check/check.ts | 150 +++++++++++++++++++++++-------------- 1 file changed, 94 insertions(+), 56 deletions(-) diff --git a/src/command/check/check.ts b/src/command/check/check.ts index e1ce24c8349..cf77e1428e1 100644 --- a/src/command/check/check.ts +++ b/src/command/check/check.ts @@ -442,67 +442,28 @@ async function checkInstall(conf: CheckConfiguration) { conf.jsonResult.chrome = chromeJson; } const chromeCb = async () => { - const envPath = Deno.env.get("QUARTO_CHROMIUM"); - const chromeHsPath = chromeHeadlessShellExecutablePath(); - const chromeDetected = await findChrome(); - const chromiumTool = installableTool("chromium"); - const chromiumQuarto = chromiumTool && await chromiumTool.installed() - ? chromiumTool - : undefined; - if (envPath && !safeExistsSync(envPath)) { - chromeHeadlessOutput.push( - `${kIndent}NOTE: QUARTO_CHROMIUM is set to ${envPath} but the path does not exist.`, - ); - chromeJson["QUARTO_CHROMIUM_invalid"] = envPath; + const check = await detectChromeForCheck(); + + if (check.warning) { + chromeHeadlessOutput.push(`${kIndent}NOTE: ${check.warning}`); + chromeJson["warning"] = check.warning; } - if (envPath && safeExistsSync(envPath)) { - chromeHeadlessOutput.push(`${kIndent}Using: Chrome from QUARTO_CHROMIUM`); - chromeHeadlessOutput.push(`${kIndent}Path: ${envPath}`); - chromeJson["path"] = envPath; - chromeJson["source"] = "QUARTO_CHROMIUM"; - } else if (chromeHsPath !== undefined) { - const version = readInstalledVersion(chromeHeadlessShellInstallDir()); - chromeJson["source"] = "quarto-chrome-headless-shell"; - chromeHeadlessOutput.push( - `${kIndent}Using: Chrome Headless Shell installed by Quarto`, - ); - chromeHeadlessOutput.push(`${kIndent}Path: ${chromeHsPath}`); - chromeJson["path"] = chromeHsPath; + + if (check.detected) { + const { label, path, source, displaySource, version } = check.detected; + chromeHeadlessOutput.push(`${kIndent}Using: ${label}`); + if (path) { + chromeHeadlessOutput.push(`${kIndent}Path: ${path}`); + chromeJson["path"] = path; + } + chromeJson["source"] = source; + if (displaySource) { + chromeHeadlessOutput.push(`${kIndent}Source: ${displaySource}`); + } if (version) { chromeHeadlessOutput.push(`${kIndent}Version: ${version}`); chromeJson["version"] = version; } - } else if (chromeDetected.path !== undefined) { - chromeHeadlessOutput.push(`${kIndent}Using: Chrome found on system`); - chromeHeadlessOutput.push( - `${kIndent}Path: ${chromeDetected.path}`, - ); - if (chromeDetected.source) { - chromeHeadlessOutput.push(`${kIndent}Source: ${chromeDetected.source}`); - } - chromeJson["path"] = chromeDetected.path; - chromeJson["source"] = chromeDetected.source; - } else if (chromiumQuarto !== undefined) { - chromeJson["source"] = "quarto"; - chromeHeadlessOutput.push( - `${kIndent}Using: Chromium installed by Quarto`, - ); - if (chromiumQuarto.binDir) { - const binPath = await chromiumQuarto.binDir(); - if (binPath) { - chromeHeadlessOutput.push( - `${kIndent}Path: ${binPath}`, - ); - chromeJson["path"] = binPath; - } - } - const chromiumVersion = await chromiumQuarto.installedVersion(); - if (chromiumVersion) { - chromeHeadlessOutput.push( - `${kIndent}Version: ${chromiumVersion}`, - ); - chromeJson["version"] = chromiumVersion; - } } else { chromeHeadlessOutput.push(`${kIndent}Chrome: (not detected)`); chromeJson["installed"] = false; @@ -562,3 +523,80 @@ title: "Title" }, markdownRenderCb); } } + +interface ChromeDetectionResult { + label: string; + path?: string; + source: string; + version?: string; + displaySource?: string; +} + +interface ChromeCheckInfo { + warning?: string; + detected?: ChromeDetectionResult; +} + +async function detectChromeForCheck(): Promise { + const result: ChromeCheckInfo = {}; + + // 1. QUARTO_CHROMIUM environment variable + const envPath = Deno.env.get("QUARTO_CHROMIUM"); + if (envPath) { + if (safeExistsSync(envPath)) { + result.detected = { + label: "Chrome from QUARTO_CHROMIUM", + path: envPath, + source: "QUARTO_CHROMIUM", + }; + return result; + } + result.warning = + `QUARTO_CHROMIUM is set to ${envPath} but the path does not exist.`; + } + + // 2. Chrome headless shell installed by Quarto + const chromeHsPath = chromeHeadlessShellExecutablePath(); + if (chromeHsPath !== undefined) { + const version = readInstalledVersion(chromeHeadlessShellInstallDir()); + result.detected = { + label: "Chrome Headless Shell installed by Quarto", + path: chromeHsPath, + source: "quarto-chrome-headless-shell", + version, + }; + return result; + } + + // 3. System Chrome + const chromeDetected = await findChrome(); + if (chromeDetected.path !== undefined) { + result.detected = { + label: "Chrome found on system", + path: chromeDetected.path, + source: chromeDetected.source ?? "system", + displaySource: chromeDetected.source, + }; + return result; + } + + // 4. Legacy chromium installed by Quarto + const chromiumTool = installableTool("chromium"); + if (chromiumTool && await chromiumTool.installed()) { + let path: string | undefined; + if (chromiumTool.binDir) { + path = await chromiumTool.binDir(); + } + const version = await chromiumTool.installedVersion(); + result.detected = { + label: "Chromium installed by Quarto", + path, + source: "quarto", + version, + }; + return result; + } + + // 5. Not found + return result; +} From 5bde81ceaf5d59d2ed78d00bfd40f919eb9ecf77 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 13 Feb 2026 12:31:02 +0100 Subject: [PATCH 13/13] Use registered tool list for update/remove shortcut resolution `quarto update chrome-headless-shell` was treated as an extension name because `resolveCompatibleArgs()` only recognized `tinytex` and `chromium` as tool shortcuts. Replace hardcoded check with `installableTools()` so all registered tools (including `chrome-headless-shell` and `verapdf`) are recognized automatically. Co-Authored-By: Claude Opus 4.6 --- src/command/remove/cmd.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/command/remove/cmd.ts b/src/command/remove/cmd.ts index 56fee292749..b7760c2ddc2 100644 --- a/src/command/remove/cmd.ts +++ b/src/command/remove/cmd.ts @@ -21,6 +21,7 @@ import { removeTool, selectTool, } from "../../tools/tools-console.ts"; +import { installableTools } from "../../tools/tools.ts"; import { notebookContext } from "../../render/notebook/notebook-context.ts"; import { signalCommandFailure } from "../utils.ts"; @@ -172,7 +173,7 @@ export const resolveCompatibleArgs = ( return { action: "extension", }; - } else if (extname === "tinytex" || extname === "chromium") { + } else if (installableTools().includes(extname)) { return { action: "tool", name: args[0],