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/news/changelog-1.9.md b/news/changelog-1.9.md index d7edc12bdb8..139fdabf048 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), [#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` diff --git a/src/command/check/check.ts b/src/command/check/check.ts index dfba6f03468..cf77e1428e1 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,12 @@ 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, + readInstalledVersion, +} from "../../tools/impl/chrome-headless-shell.ts"; import { executionEngines } from "../../execute/engine.ts"; export function getTargets(): readonly string[] { @@ -436,35 +442,28 @@ async function checkInstall(conf: CheckConfiguration) { conf.jsonResult.chrome = chromeJson; } const chromeCb = async () => { - const chromeDetected = await findChrome(); - const chromiumQuarto = tools.installed.find((tool) => - tool.name === "chromium" - ); - 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}`); + const check = await detectChromeForCheck(); + + if (check.warning) { + chromeHeadlessOutput.push(`${kIndent}NOTE: ${check.warning}`); + chromeJson["warning"] = check.warning; + } + + 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["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) { - chromeHeadlessOutput.push( - `${kIndent}Path: ${chromiumQuarto?.binDir}`, - ); - chromeJson["path"] = chromiumQuarto?.binDir; + chromeJson["source"] = source; + if (displaySource) { + chromeHeadlessOutput.push(`${kIndent}Source: ${displaySource}`); + } + if (version) { + chromeHeadlessOutput.push(`${kIndent}Version: ${version}`); + chromeJson["version"] = version; } - chromeHeadlessOutput.push( - `${kIndent}Version: ${chromiumQuarto.installedVersion}`, - ); - chromeJson["version"] = chromiumQuarto.installedVersion; } else { chromeHeadlessOutput.push(`${kIndent}Chrome: (not detected)`); chromeJson["installed"] = false; @@ -524,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; +} 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], diff --git a/src/core/puppeteer.ts b/src/core/puppeteer.ts index a86b694bf7e..abd327f9253 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; @@ -212,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", @@ -291,10 +280,35 @@ export async function getBrowserExecutablePath() { let executablePath: string | undefined = undefined; + // Priority 1: QUARTO_CHROMIUM env var + const envPath = Deno.env.get("QUARTO_CHROMIUM"); + 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 if (executablePath === undefined) { - executablePath = (await findChrome()).path; + 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 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)); @@ -306,7 +320,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/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/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/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/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..27e836c9722 --- /dev/null +++ b/tests/docs/smoke-all/2026/02/12/dot-pdf.qmd @@ -0,0 +1,25 @@ +--- +title: "Graphviz to PDF (#11877)" +format: + pdf: + keep-tex: true +_quarto: + tests: + pdf: + noErrors: default + ensureLatexFileRegexMatches: + - ['\\includegraphics.*dot-figure-1\.png'] + - [] +--- + +## 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..519aecd5a92 --- /dev/null +++ b/tests/docs/smoke-all/2026/02/12/mermaid-multi-diagram.qmd @@ -0,0 +1,44 @@ +--- +title: "Multiple Mermaid Diagrams to PDF (#11877)" +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 + +```{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..4c28a585704 --- /dev/null +++ b/tests/docs/smoke-all/2026/02/12/mermaid-pdf-default.qmd @@ -0,0 +1,24 @@ +--- +title: "Mermaid to PDF (#11877)" +format: + pdf: + keep-tex: true +_quarto: + tests: + pdf: + noErrors: default + ensureLatexFileRegexMatches: + - ['\\includegraphics.*mermaid-figure-1\.png'] + - [] +--- + +## 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..0c4195f6eb3 --- /dev/null +++ b/tests/docs/smoke-all/2026/02/12/mermaid-typst.qmd @@ -0,0 +1,24 @@ +--- +title: "Mermaid to Typst (#11877)" +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + noErrors: default + ensureTypstFileRegexMatches: + - ['image\(.*mermaid-figure-1\.png'] + - [] +--- + +## 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/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(), + }, +); 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..34c56463101 --- /dev/null +++ b/tests/unit/tools/chrome-headless-shell.test.ts @@ -0,0 +1,257 @@ +/* + * 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 { installableTool, installableTools } from "../../../src/tools/tools.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() }); + +// -- 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"); +});