From 58488f2f99681c92cc41f9070d9ec9ce06e9c04a Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 7 Apr 2026 14:16:47 +0200 Subject: [PATCH 01/27] Add Playwright CDN support for arm64 Linux chrome-headless-shell CfT API has no linux-arm64 builds. Add functions to fetch Playwright's browsers.json and construct download URLs from their CDN, which hosts arm64 chromium-headless-shell builds. Also update detectCftPlatform() to return a valid PlatformInfo for arm64 instead of throwing. --- src/tools/impl/chrome-for-testing.ts | 92 +++++++++++++++++++-- tests/unit/tools/chrome-for-testing.test.ts | 63 +++++++++++++- 2 files changed, 147 insertions(+), 8 deletions(-) diff --git a/src/tools/impl/chrome-for-testing.ts b/src/tools/impl/chrome-for-testing.ts index ce543913937..bc0f5a2d59d 100644 --- a/src/tools/impl/chrome-for-testing.ts +++ b/src/tools/impl/chrome-for-testing.ts @@ -15,9 +15,10 @@ 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. */ +/** Platform identifiers for Chrome binary downloads (CfT API + Playwright CDN). */ export type CftPlatform = | "linux64" + | "linux-arm64" | "mac-arm64" | "mac-x64" | "win32" @@ -31,8 +32,9 @@ export interface PlatformInfo { } /** - * Map os + arch to a CfT platform string. - * Throws on unsupported platforms (e.g., linux aarch64 — to be handled by Playwright CDN). + * Map os + arch to a platform string. + * For linux arm64, returns a PlatformInfo with platform "linux-arm64" — callers + * should use isPlaywrightCdnPlatform() to route to the Playwright CDN path. */ export function detectCftPlatform(): PlatformInfo { const platformMap: Record = { @@ -48,10 +50,9 @@ export function detectCftPlatform(): PlatformInfo { 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.", - ); + // linux arm64 is supported via Playwright CDN, not CfT. + // Return a PlatformInfo that callers can check to use the Playwright path. + return { platform: "linux-arm64" as CftPlatform, os, arch }; } throw new Error( `Unsupported platform for Chrome for Testing: ${os} ${arch}`, @@ -61,6 +62,12 @@ export function detectCftPlatform(): PlatformInfo { return { platform, os, arch }; } +/** Check if the current platform requires Playwright CDN (arm64 Linux). */ +export function isPlaywrightCdnPlatform(info?: PlatformInfo): boolean { + const p = info ?? detectCftPlatform(); + return p.os === "linux" && p.arch === "aarch64"; +} + /** A single download entry from the CfT API. */ export interface CftDownload { platform: CftPlatform; @@ -122,6 +129,77 @@ export async function fetchLatestCftRelease(): Promise { }; } +/** Parsed entry from Playwright's browsers.json for chromium-headless-shell. */ +export interface PlaywrightBrowserEntry { + revision: string; + browserVersion: string; +} + +// Source: https://github.com/microsoft/playwright/blob/main/packages/playwright-core/browsers.json +// This file lists the browser revisions Playwright pins per release, including +// chromium-headless-shell builds for linux arm64 that CfT does not provide. +const kPlaywrightBrowsersJsonUrl = + "https://raw.githubusercontent.com/microsoft/playwright/main/packages/playwright-core/browsers.json"; + +/** + * Fetch Playwright's browsers.json and extract the chromium-headless-shell entry. + * Used as the version/revision source for arm64 Linux where CfT has no builds. + */ +export async function fetchPlaywrightBrowsersJson(): Promise { + let response: Response; + const fallbackHint = "\nIf this persists, install a system Chrome/Chromium instead " + + "(Quarto will detect it automatically)."; + try { + response = await fetch(kPlaywrightBrowsersJsonUrl); + } catch (e) { + throw new Error( + `Failed to fetch Playwright browsers.json: ${ + e instanceof Error ? e.message : String(e) + }${fallbackHint}`, + ); + } + + if (!response.ok) { + throw new Error( + `Playwright browsers.json returned ${response.status}: ${response.statusText}${fallbackHint}`, + ); + } + + // deno-lint-ignore no-explicit-any + let data: any; + try { + data = await response.json(); + } catch { + throw new Error("Playwright browsers.json returned invalid JSON"); + } + + const browsers = data?.browsers; + if (!Array.isArray(browsers)) { + throw new Error("Playwright browsers.json missing 'browsers' array"); + } + + // deno-lint-ignore no-explicit-any + const entry = browsers.find((b: any) => b.name === "chromium-headless-shell"); + if (!entry || !entry.revision || !entry.browserVersion) { + throw new Error( + "Playwright browsers.json has no 'chromium-headless-shell' entry with revision and browserVersion", + ); + } + + return { + revision: entry.revision, + browserVersion: entry.browserVersion, + }; +} + +/** + * Construct the Playwright CDN download URL for chrome-headless-shell on linux arm64. + * Uses the primary CDN mirror (cdn.playwright.dev). + */ +export function playwrightCdnDownloadUrl(revision: string): string { + return `https://cdn.playwright.dev/builds/chromium/${revision}/chromium-headless-shell-linux-arm64.zip`; +} + /** * Find a named executable inside an extracted CfT directory. * Handles platform-specific naming (.exe on Windows) and nested directory structures. diff --git a/tests/unit/tools/chrome-for-testing.test.ts b/tests/unit/tools/chrome-for-testing.test.ts index b2af0ef1584..365b845ae45 100644 --- a/tests/unit/tools/chrome-for-testing.test.ts +++ b/tests/unit/tools/chrome-for-testing.test.ts @@ -16,13 +16,16 @@ import { detectCftPlatform, downloadAndExtractCft, fetchLatestCftRelease, + fetchPlaywrightBrowsersJson, findCftExecutable, + isPlaywrightCdnPlatform, + playwrightCdnDownloadUrl, } 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"]; + const validPlatforms = ["linux64", "linux-arm64", "mac-arm64", "mac-x64", "win32", "win64"]; assert( validPlatforms.includes(result.platform), `Expected one of ${validPlatforms.join(", ")}, got: ${result.platform}`, @@ -127,6 +130,64 @@ unitTest("findCftExecutable - finds binary in nested structure", async () => { } }); +// Step 3b: findCftExecutable() — Playwright arm64 layout +// Skip on Windows: arm64 layout is Linux-only, no .exe extension. +unitTest("findCftExecutable - finds binary in Playwright arm64 layout", async () => { + if (isWindows) return; // arm64 layout is Linux-only + const tempDir = Deno.makeTempDirSync(); + try { + // Playwright arm64 extracts to chrome-linux/headless_shell + const subdir = join(tempDir, "chrome-linux"); + Deno.mkdirSync(subdir); + Deno.writeTextFileSync(join(subdir, "headless_shell"), "fake binary"); + + const found = findCftExecutable(tempDir, "headless_shell"); + assert(found !== undefined, "should find headless_shell in chrome-linux/"); + assert(found!.endsWith("headless_shell"), `should end with headless_shell, got: ${found}`); + } finally { + safeRemoveSync(tempDir, { recursive: true }); + } +}); + +// Playwright CDN tests — skip on CI (external HTTP) +unitTest("fetchPlaywrightBrowsersJson - returns chromium-headless-shell entry", async () => { + const entry = await fetchPlaywrightBrowsersJson(); + assert(entry.revision, "revision should be non-empty"); + assert( + /^\d+$/.test(entry.revision), + `revision should be numeric, got: ${entry.revision}`, + ); + assert(entry.browserVersion, "browserVersion should be non-empty"); + assert( + /^\d+\.\d+\.\d+\.\d+$/.test(entry.browserVersion), + `browserVersion format wrong: ${entry.browserVersion}`, + ); +}, { ignore: runningInCI() }); + +unitTest("playwrightCdnDownloadUrl - constructs correct arm64 URL", async () => { + const url = playwrightCdnDownloadUrl("1219"); + assert( + url.startsWith("https://cdn.playwright.dev/"), + `URL should start with cdn.playwright.dev, got: ${url}`, + ); + assert( + url.includes("/builds/chromium/1219/"), + `URL should contain revision path, got: ${url}`, + ); + assert( + url.endsWith("chromium-headless-shell-linux-arm64.zip"), + `URL should end with arm64 zip name, got: ${url}`, + ); +}); + +unitTest("isPlaywrightCdnPlatform - returns false on non-arm64 platform", async () => { + // On CI (which is not arm64 Linux), this should return false. + // We can't test the true case on non-arm64 machines without mocking. + if (os === "linux" && arch === "aarch64") return; // Skip on actual arm64 + const result = isPlaywrightCdnPlatform(); + assertEquals(result, false); +}); + // Step 4: downloadAndExtractCft() — integration test, downloads ~50MB unitTest( "downloadAndExtractCft - downloads and extracts chrome-headless-shell", From 33766924d30eaf71f8305ea145b17e19e322a387 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 7 Apr 2026 14:44:12 +0200 Subject: [PATCH 02/27] Wire arm64 Playwright CDN into chrome-headless-shell install flow - Route latestRelease() through Playwright CDN on arm64 Linux - Use "headless_shell" binary name for Playwright CDN builds - Check both binary names in chromeHeadlessShellExecutablePath() and isInstalled() - Add Playwright CDN integration test (skipped on CI) --- src/tools/impl/chrome-headless-shell.ts | 29 +++++++++++++++++-- .../unit/tools/chrome-headless-shell.test.ts | 27 ++++++++++++++++- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/tools/impl/chrome-headless-shell.ts b/src/tools/impl/chrome-headless-shell.ts index 7ac9c038c89..c2fd1999354 100644 --- a/src/tools/impl/chrome-headless-shell.ts +++ b/src/tools/impl/chrome-headless-shell.ts @@ -20,7 +20,10 @@ import { detectCftPlatform, downloadAndExtractCft, fetchLatestCftRelease, + fetchPlaywrightBrowsersJson, findCftExecutable, + isPlaywrightCdnPlatform, + playwrightCdnDownloadUrl, } from "./chrome-for-testing.ts"; const kVersionFileName = "version"; @@ -41,7 +44,9 @@ export function chromeHeadlessShellExecutablePath(): string | undefined { if (!existsSync(dir)) { return undefined; } - return findCftExecutable(dir, "chrome-headless-shell"); + // Try CfT name first, then Playwright arm64 name + return findCftExecutable(dir, "chrome-headless-shell") + ?? findCftExecutable(dir, "headless_shell"); } /** Record the installed version as a plain text file. */ @@ -62,7 +67,8 @@ export function readInstalledVersion(dir: string): string | 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; + (findCftExecutable(dir, "chrome-headless-shell") !== undefined || + findCftExecutable(dir, "headless_shell") !== undefined); } // -- InstallableTool methods -- @@ -84,6 +90,18 @@ async function installedVersion(): Promise { } async function latestRelease(): Promise { + if (isPlaywrightCdnPlatform()) { + // arm64 Linux: use Playwright CDN + const entry = await fetchPlaywrightBrowsersJson(); + const url = playwrightCdnDownloadUrl(entry.revision); + return { + url, + version: entry.browserVersion, + assets: [{ name: "chrome-headless-shell", url }], + }; + } + + // All other platforms: use CfT API const release = await fetchLatestCftRelease(); const { platform } = detectCftPlatform(); @@ -110,13 +128,18 @@ async function preparePackage(ctx: InstallContext): Promise { const release = await latestRelease(); const workingDir = Deno.makeTempDirSync({ prefix: "quarto-chrome-hs-" }); + // arm64 Playwright builds use "headless_shell" as the binary name + const binaryName = isPlaywrightCdnPlatform() + ? "headless_shell" + : "chrome-headless-shell"; + try { await downloadAndExtractCft( "Chrome Headless Shell", release.url, workingDir, ctx, - "chrome-headless-shell", + binaryName, ); } catch (e) { safeRemoveSync(workingDir, { recursive: true }); diff --git a/tests/unit/tools/chrome-headless-shell.test.ts b/tests/unit/tools/chrome-headless-shell.test.ts index 259caf685e9..af8e8ab25dd 100644 --- a/tests/unit/tools/chrome-headless-shell.test.ts +++ b/tests/unit/tools/chrome-headless-shell.test.ts @@ -11,7 +11,13 @@ 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 { detectCftPlatform, findCftExecutable } from "../../../src/tools/impl/chrome-for-testing.ts"; +import { + detectCftPlatform, + fetchPlaywrightBrowsersJson, + findCftExecutable, + isPlaywrightCdnPlatform, + playwrightCdnDownloadUrl, +} from "../../../src/tools/impl/chrome-for-testing.ts"; import { installableTool, installableTools } from "../../../src/tools/tools.ts"; import { chromeHeadlessShellInstallable, @@ -133,6 +139,25 @@ unitTest("latestRelease - returns valid RemotePackageInfo", async () => { assertEquals(release.assets[0].name, "chrome-headless-shell"); }, { ignore: runningInCI() }); +// -- Playwright CDN integration (arm64 Linux only, skip on CI) -- + +unitTest("Playwright CDN - browsers.json and URL construction", async () => { + const entry = await fetchPlaywrightBrowsersJson(); + const url = playwrightCdnDownloadUrl(entry.revision); + assert( + /^\d+\.\d+\.\d+\.\d+$/.test(entry.browserVersion), + `browserVersion format wrong: ${entry.browserVersion}`, + ); + assert( + url.includes(entry.revision), + `URL should contain revision ${entry.revision}`, + ); + assert( + url.includes("linux-arm64"), + "URL should be for linux-arm64", + ); +}, { ignore: runningInCI() }); + // -- Step 5: preparePackage() (downloads ~50MB, skip on CI) -- function createMockContext(workingDir: string): InstallContext { From a6e8e84c30643023baa85f443f2c9fb93ffcd946 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 7 Apr 2026 15:44:24 +0200 Subject: [PATCH 03/27] Deprecate 'quarto install chromium', redirect to chrome-headless-shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Print deprecation warning and transparently redirect to chrome-headless-shell. No new prompts — CI/automation that uses 'quarto install chromium' continues to work without changes. 'quarto update chromium' also uninstalls legacy chromium if present before installing chrome-headless-shell. --- src/tools/tools-console.ts | 25 +++++++++++++++++++++++++ src/tools/tools.ts | 10 ++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/tools/tools-console.ts b/src/tools/tools-console.ts index 5cd9db25809..b1f6d260eba 100644 --- a/src/tools/tools-console.ts +++ b/src/tools/tools-console.ts @@ -149,6 +149,31 @@ export async function updateOrInstallTool( prompt?: boolean, updatePath?: boolean, ) { + // Deprecation: redirect chromium → chrome-headless-shell + if (tool.toLowerCase() === "chromium") { + warning( + "'chromium' is deprecated. Installing 'chrome-headless-shell' instead.\n" + + "Please update your scripts to use 'quarto install chrome-headless-shell'.", + ); + if (action === "update") { + // Uninstall legacy chromium if present + const legacyTool = installableTool("chromium"); + if (legacyTool && await legacyTool.installed()) { + await uninstallTool("chromium"); + } + // Check if chrome-headless-shell is already present + const chsSummary = await toolSummary("chrome-headless-shell"); + const redirectAction = chsSummary?.installed ? "update" : "install"; + return updateOrInstallTool( + "chrome-headless-shell", + redirectAction, + prompt, + updatePath, + ); + } + return updateOrInstallTool("chrome-headless-shell", "install", prompt, updatePath); + } + const summary = await toolSummary(tool); if (action === "update") { diff --git a/src/tools/tools.ts b/src/tools/tools.ts index 0ef3ad02534..7a69aadaac3 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -114,6 +114,16 @@ export function checkToolRequirement(name: string) { export async function installTool(name: string, updatePath?: boolean) { name = name || ""; + + // Deprecation: redirect chromium → chrome-headless-shell + if (name.toLowerCase() === "chromium") { + warning( + "'chromium' is deprecated. Installing 'chrome-headless-shell' instead.\n" + + "Please update your scripts to use 'quarto install chrome-headless-shell'.", + ); + return installTool("chrome-headless-shell", updatePath); + } + // Run the install const tool = installableTool(name); if (tool) { From 270ee2d00caaf81d8b92fa70be255f1e4ffc56d3 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 7 Apr 2026 15:49:11 +0200 Subject: [PATCH 04/27] Switch binder post-build script from chromium to chrome-headless-shell Binder environments now install chrome-headless-shell instead of the legacy chromium tool for mermaid/graphviz rendering support. --- src/command/use/commands/binder/binder.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/command/use/commands/binder/binder.ts b/src/command/use/commands/binder/binder.ts index a3d3df64452..8bcf98ac103 100644 --- a/src/command/use/commands/binder/binder.ts +++ b/src/command/use/commands/binder/binder.ts @@ -223,12 +223,14 @@ const createPostBuild = ( postBuildScript.push(msg("Installed TinyTex")); } - // Maybe install Chromium + // Maybe install Chrome Headless Shell for mermaid/graphviz rendering. + // Note: quartoConfig.chromium comes from QuartoTool type which uses "chromium" + // as a generic signal meaning "needs a headless browser", not the install command. if (quartoConfig.chromium) { - postBuildScript.push(msg("Installing Chromium")); - postBuildScript.push("# install chromium"); - postBuildScript.push("quarto install chromium --no-prompt"); - postBuildScript.push(msg("Installed Chromium")); + postBuildScript.push(msg("Installing Chrome Headless Shell")); + postBuildScript.push("# install chrome-headless-shell"); + postBuildScript.push("quarto install chrome-headless-shell --no-prompt"); + postBuildScript.push(msg("Installed Chrome Headless Shell")); } if (vscodeConfig.version) { From 47dff24c8f49876d62bf28eba038aa6c7cd7875a Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 7 Apr 2026 15:55:40 +0200 Subject: [PATCH 05/27] Bump @playwright/test to 1.59.1 Updates bundled Chromium used in Playwright integration tests. --- tests/integration/playwright/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/playwright/package.json b/tests/integration/playwright/package.json index 13168eb07a6..1b895092593 100644 --- a/tests/integration/playwright/package.json +++ b/tests/integration/playwright/package.json @@ -1,6 +1,6 @@ { "devDependencies": { - "@playwright/test": "^1.31.0" + "@playwright/test": "^1.59.1" }, "scripts": {} } From 3b6cf84148c4175a64ecc59b498f3e1574ced88b Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 7 Apr 2026 16:21:28 +0200 Subject: [PATCH 06/27] Add Playwright CDN link to chrome-for-testing.ts file header The file now handles both CfT API and Playwright CDN downloads. --- src/tools/impl/chrome-for-testing.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/tools/impl/chrome-for-testing.ts b/src/tools/impl/chrome-for-testing.ts index bc0f5a2d59d..4b0140cc8f3 100644 --- a/src/tools/impl/chrome-for-testing.ts +++ b/src/tools/impl/chrome-for-testing.ts @@ -1,9 +1,12 @@ /* * chrome-for-testing.ts * - * Utilities for downloading binaries from the Chrome for Testing (CfT) API. + * Utilities for downloading binaries from the Chrome for Testing (CfT) API + * and the Playwright CDN (for arm64 Linux where CfT has no builds). + * * https://github.com/GoogleChromeLabs/chrome-for-testing * https://googlechromelabs.github.io/chrome-for-testing/ + * https://playwright.dev/docs/browsers#hermetic-install * * Copyright (C) 2026 Posit Software, PBC */ From 1666c2f089454c8378700f4012c5cf066433c275 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 7 Apr 2026 16:57:27 +0200 Subject: [PATCH 07/27] Rename Cft-prefixed identifiers to Chrome-prefixed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit detectCftPlatform → detectChromePlatform, findCftExecutable → findChromeExecutable, downloadAndExtractCft → downloadAndExtractChrome, CftPlatform → ChromePlatform. These functions now handle both CfT API and Playwright CDN platforms. CfT-specific types (CftDownload, CftStableRelease, fetchLatestCftRelease) keep their names since they are genuinely CfT-only. Also refactor detectChromePlatform() to include linux-arm64 in the platform map instead of special-casing it, and restore proactive legacy chromium uninstall on the update redirect path. --- src/tools/impl/chrome-for-testing.ts | 30 +++++------ src/tools/impl/chrome-headless-shell.ts | 21 ++++---- src/tools/tools-console.ts | 16 +++--- tests/unit/tools/chrome-for-testing.test.ts | 50 +++++++++---------- .../unit/tools/chrome-headless-shell.test.ts | 10 ++-- 5 files changed, 60 insertions(+), 67 deletions(-) diff --git a/src/tools/impl/chrome-for-testing.ts b/src/tools/impl/chrome-for-testing.ts index 4b0140cc8f3..d3d8b69be90 100644 --- a/src/tools/impl/chrome-for-testing.ts +++ b/src/tools/impl/chrome-for-testing.ts @@ -19,7 +19,7 @@ import { unzip } from "../../core/zip.ts"; import { InstallContext } from "../types.ts"; /** Platform identifiers for Chrome binary downloads (CfT API + Playwright CDN). */ -export type CftPlatform = +export type ChromePlatform = | "linux64" | "linux-arm64" | "mac-arm64" @@ -29,7 +29,7 @@ export type CftPlatform = /** Platform detection result. */ export interface PlatformInfo { - platform: CftPlatform; + platform: ChromePlatform; os: string; arch: string; } @@ -39,9 +39,10 @@ export interface PlatformInfo { * For linux arm64, returns a PlatformInfo with platform "linux-arm64" — callers * should use isPlaywrightCdnPlatform() to route to the Playwright CDN path. */ -export function detectCftPlatform(): PlatformInfo { - const platformMap: Record = { +export function detectChromePlatform(): PlatformInfo { + const platformMap: Record = { "linux-x86_64": "linux64", + "linux-aarch64": "linux-arm64", "darwin-aarch64": "mac-arm64", "darwin-x86_64": "mac-x64", "windows-x86_64": "win64", @@ -52,11 +53,6 @@ export function detectCftPlatform(): PlatformInfo { const platform = platformMap[key]; if (!platform) { - if (os === "linux" && arch === "aarch64") { - // linux arm64 is supported via Playwright CDN, not CfT. - // Return a PlatformInfo that callers can check to use the Playwright path. - return { platform: "linux-arm64" as CftPlatform, os, arch }; - } throw new Error( `Unsupported platform for Chrome for Testing: ${os} ${arch}`, ); @@ -67,13 +63,13 @@ export function detectCftPlatform(): PlatformInfo { /** Check if the current platform requires Playwright CDN (arm64 Linux). */ export function isPlaywrightCdnPlatform(info?: PlatformInfo): boolean { - const p = info ?? detectCftPlatform(); + const p = info ?? detectChromePlatform(); return p.os === "linux" && p.arch === "aarch64"; } /** A single download entry from the CfT API. */ export interface CftDownload { - platform: CftPlatform; + platform: ChromePlatform; url: string; } @@ -208,7 +204,7 @@ export function playwrightCdnDownloadUrl(revision: string): string { * 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( +export function findChromeExecutable( extractedDir: string, binaryName: string, ): string | undefined { @@ -216,13 +212,13 @@ export function findCftExecutable( // CfT zips extract to {binaryName}-{platform}/{target} try { - const { platform } = detectCftPlatform(); + const { platform } = detectChromePlatform(); const knownPath = join(extractedDir, `${binaryName}-${platform}`, target); if (existsSync(knownPath)) { return knownPath; } } catch (e) { - debug(`findCftExecutable: platform detection failed, falling back to walk: ${e}`); + debug(`findChromeExecutable: platform detection failed, falling back to walk: ${e}`); } // Fallback: bounded walk for unexpected directory structures @@ -241,7 +237,7 @@ export function findCftExecutable( * When binaryName is provided, sets executable permission only on that binary. * Returns the target directory path. */ -export async function downloadAndExtractCft( +export async function downloadAndExtractChrome( label: string, url: string, targetDir: string, @@ -258,11 +254,11 @@ export async function downloadAndExtractCft( } if (binaryName) { - const executable = findCftExecutable(targetDir, binaryName); + const executable = findChromeExecutable(targetDir, binaryName); if (executable) { safeChmodSync(executable, 0o755); } else { - debug(`downloadAndExtractCft: expected binary '${binaryName}' not found in ${targetDir}`); + debug(`downloadAndExtractChrome: expected binary '${binaryName}' not found in ${targetDir}`); } } diff --git a/src/tools/impl/chrome-headless-shell.ts b/src/tools/impl/chrome-headless-shell.ts index c2fd1999354..0c2f89692b9 100644 --- a/src/tools/impl/chrome-headless-shell.ts +++ b/src/tools/impl/chrome-headless-shell.ts @@ -1,7 +1,8 @@ /* * chrome-headless-shell.ts * - * InstallableTool implementation for Chrome Headless Shell via Chrome for Testing (CfT). + * InstallableTool implementation for Chrome Headless Shell via Chrome for Testing (CfT) + * and Playwright CDN (arm64 Linux). * Provides quarto install/uninstall chrome-headless-shell functionality. * * Copyright (C) 2026 Posit Software, PBC @@ -17,11 +18,11 @@ import { RemotePackageInfo, } from "../types.ts"; import { - detectCftPlatform, - downloadAndExtractCft, + detectChromePlatform, + downloadAndExtractChrome, fetchLatestCftRelease, fetchPlaywrightBrowsersJson, - findCftExecutable, + findChromeExecutable, isPlaywrightCdnPlatform, playwrightCdnDownloadUrl, } from "./chrome-for-testing.ts"; @@ -45,8 +46,8 @@ export function chromeHeadlessShellExecutablePath(): string | undefined { return undefined; } // Try CfT name first, then Playwright arm64 name - return findCftExecutable(dir, "chrome-headless-shell") - ?? findCftExecutable(dir, "headless_shell"); + return findChromeExecutable(dir, "chrome-headless-shell") + ?? findChromeExecutable(dir, "headless_shell"); } /** Record the installed version as a plain text file. */ @@ -67,8 +68,8 @@ export function readInstalledVersion(dir: string): string | 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 || - findCftExecutable(dir, "headless_shell") !== undefined); + (findChromeExecutable(dir, "chrome-headless-shell") !== undefined || + findChromeExecutable(dir, "headless_shell") !== undefined); } // -- InstallableTool methods -- @@ -103,7 +104,7 @@ async function latestRelease(): Promise { // All other platforms: use CfT API const release = await fetchLatestCftRelease(); - const { platform } = detectCftPlatform(); + const { platform } = detectChromePlatform(); const downloads = release.downloads["chrome-headless-shell"]; if (!downloads) { @@ -134,7 +135,7 @@ async function preparePackage(ctx: InstallContext): Promise { : "chrome-headless-shell"; try { - await downloadAndExtractCft( + await downloadAndExtractChrome( "Chrome Headless Shell", release.url, workingDir, diff --git a/src/tools/tools-console.ts b/src/tools/tools-console.ts index b1f6d260eba..75afc6677c2 100644 --- a/src/tools/tools-console.ts +++ b/src/tools/tools-console.ts @@ -156,20 +156,16 @@ export async function updateOrInstallTool( "Please update your scripts to use 'quarto install chrome-headless-shell'.", ); if (action === "update") { - // Uninstall legacy chromium if present + // Check if chrome-headless-shell is already present to pick the right action + const chsSummary = await toolSummary("chrome-headless-shell"); + const redirectAction = chsSummary?.installed ? "update" : "install"; + // Uninstall legacy chromium after redirect succeeds via installTool/updateTool + // (those functions call Deno.exit, so we clean up before delegating) const legacyTool = installableTool("chromium"); if (legacyTool && await legacyTool.installed()) { await uninstallTool("chromium"); } - // Check if chrome-headless-shell is already present - const chsSummary = await toolSummary("chrome-headless-shell"); - const redirectAction = chsSummary?.installed ? "update" : "install"; - return updateOrInstallTool( - "chrome-headless-shell", - redirectAction, - prompt, - updatePath, - ); + return updateOrInstallTool("chrome-headless-shell", redirectAction, prompt, updatePath); } return updateOrInstallTool("chrome-headless-shell", "install", prompt, updatePath); } diff --git a/tests/unit/tools/chrome-for-testing.test.ts b/tests/unit/tools/chrome-for-testing.test.ts index 365b845ae45..85b97644239 100644 --- a/tests/unit/tools/chrome-for-testing.test.ts +++ b/tests/unit/tools/chrome-for-testing.test.ts @@ -13,18 +13,18 @@ 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, + detectChromePlatform, + downloadAndExtractChrome, fetchLatestCftRelease, fetchPlaywrightBrowsersJson, - findCftExecutable, + findChromeExecutable, isPlaywrightCdnPlatform, playwrightCdnDownloadUrl, } from "../../../src/tools/impl/chrome-for-testing.ts"; -// Step 1: detectCftPlatform() -unitTest("detectCftPlatform - returns valid CftPlatform for current system", async () => { - const result = detectCftPlatform(); +// Step 1: detectChromePlatform() +unitTest("detectChromePlatform - returns valid CftPlatform for current system", async () => { + const result = detectChromePlatform(); const validPlatforms = ["linux64", "linux-arm64", "mac-arm64", "mac-x64", "win32", "win64"]; assert( validPlatforms.includes(result.platform), @@ -34,11 +34,11 @@ unitTest("detectCftPlatform - returns valid CftPlatform for current system", asy assert(result.arch.length > 0, "arch should be non-empty"); }); -unitTest("detectCftPlatform - returns win64 on Windows x86_64", async () => { +unitTest("detectChromePlatform - returns win64 on Windows x86_64", async () => { if (os !== "windows" || arch !== "x86_64") { return; // Skip on non-Windows } - const result = detectCftPlatform(); + const result = detectChromePlatform(); assertEquals(result.platform, "win64"); assertEquals(result.os, "windows"); assertEquals(result.arch, "x86_64"); @@ -77,11 +77,11 @@ unitTest("fetchLatestCftRelease - download URLs are valid", async () => { } }, { ignore: runningInCI() }); -// Step 3: findCftExecutable() -unitTest("findCftExecutable - finds binary in CfT directory structure", async () => { +// Step 3: findChromeExecutable() +unitTest("findChromeExecutable - finds binary in CfT directory structure", async () => { const tempDir = Deno.makeTempDirSync(); try { - const { platform } = detectCftPlatform(); + const { platform } = detectChromePlatform(); const subdir = join(tempDir, `chrome-headless-shell-${platform}`); Deno.mkdirSync(subdir); const binaryName = isWindows @@ -90,7 +90,7 @@ unitTest("findCftExecutable - finds binary in CfT directory structure", async () const binaryPath = join(subdir, binaryName); Deno.writeTextFileSync(binaryPath, "fake binary"); - const found = findCftExecutable(tempDir, "chrome-headless-shell"); + const found = findChromeExecutable(tempDir, "chrome-headless-shell"); assert(found !== undefined, "should find the binary"); assert( found!.endsWith(binaryName), @@ -101,20 +101,20 @@ unitTest("findCftExecutable - finds binary in CfT directory structure", async () } }); -unitTest("findCftExecutable - returns undefined for empty directory", async () => { +unitTest("findChromeExecutable - returns undefined for empty directory", async () => { const tempDir = Deno.makeTempDirSync(); try { - const found = findCftExecutable(tempDir, "chrome-headless-shell"); + const found = findChromeExecutable(tempDir, "chrome-headless-shell"); assertEquals(found, undefined); } finally { safeRemoveSync(tempDir, { recursive: true }); } }); -unitTest("findCftExecutable - finds binary in nested structure", async () => { +unitTest("findChromeExecutable - finds binary in nested structure", async () => { const tempDir = Deno.makeTempDirSync(); try { - const { platform } = detectCftPlatform(); + const { platform } = detectChromePlatform(); const nested = join(tempDir, `chrome-headless-shell-${platform}`, "subfolder"); Deno.mkdirSync(nested, { recursive: true }); const binaryName = isWindows @@ -123,16 +123,16 @@ unitTest("findCftExecutable - finds binary in nested structure", async () => { const binaryPath = join(nested, binaryName); Deno.writeTextFileSync(binaryPath, "fake binary"); - const found = findCftExecutable(tempDir, "chrome-headless-shell"); + const found = findChromeExecutable(tempDir, "chrome-headless-shell"); assert(found !== undefined, "should find the binary in nested dir"); } finally { safeRemoveSync(tempDir, { recursive: true }); } }); -// Step 3b: findCftExecutable() — Playwright arm64 layout +// Step 3b: findChromeExecutable() — Playwright arm64 layout // Skip on Windows: arm64 layout is Linux-only, no .exe extension. -unitTest("findCftExecutable - finds binary in Playwright arm64 layout", async () => { +unitTest("findChromeExecutable - finds binary in Playwright arm64 layout", async () => { if (isWindows) return; // arm64 layout is Linux-only const tempDir = Deno.makeTempDirSync(); try { @@ -141,7 +141,7 @@ unitTest("findCftExecutable - finds binary in Playwright arm64 layout", async () Deno.mkdirSync(subdir); Deno.writeTextFileSync(join(subdir, "headless_shell"), "fake binary"); - const found = findCftExecutable(tempDir, "headless_shell"); + const found = findChromeExecutable(tempDir, "headless_shell"); assert(found !== undefined, "should find headless_shell in chrome-linux/"); assert(found!.endsWith("headless_shell"), `should end with headless_shell, got: ${found}`); } finally { @@ -188,12 +188,12 @@ unitTest("isPlaywrightCdnPlatform - returns false on non-arm64 platform", async assertEquals(result, false); }); -// Step 4: downloadAndExtractCft() — integration test, downloads ~50MB +// Step 4: downloadAndExtractChrome() — integration test, downloads ~50MB unitTest( - "downloadAndExtractCft - downloads and extracts chrome-headless-shell", + "downloadAndExtractChrome - downloads and extracts chrome-headless-shell", async () => { const release = await fetchLatestCftRelease(); - const { platform } = detectCftPlatform(); + const { platform } = detectChromePlatform(); const downloads = release.downloads["chrome-headless-shell"]!; const dl = downloads.find((d) => d.platform === platform); assert(dl, `No download found for platform ${platform}`); @@ -218,9 +218,9 @@ unitTest( flags: {}, }; - await downloadAndExtractCft("Chrome Headless Shell", dl!.url, targetDir, mockContext, "chrome-headless-shell"); + await downloadAndExtractChrome("Chrome Headless Shell", dl!.url, targetDir, mockContext, "chrome-headless-shell"); - const found = findCftExecutable(targetDir, "chrome-headless-shell"); + const found = findChromeExecutable(targetDir, "chrome-headless-shell"); assert( found !== undefined, "should find chrome-headless-shell after extraction", diff --git a/tests/unit/tools/chrome-headless-shell.test.ts b/tests/unit/tools/chrome-headless-shell.test.ts index af8e8ab25dd..0b5d15d6a2a 100644 --- a/tests/unit/tools/chrome-headless-shell.test.ts +++ b/tests/unit/tools/chrome-headless-shell.test.ts @@ -12,9 +12,9 @@ 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, + detectChromePlatform, fetchPlaywrightBrowsersJson, - findCftExecutable, + findChromeExecutable, isPlaywrightCdnPlatform, playwrightCdnDownloadUrl, } from "../../../src/tools/impl/chrome-for-testing.ts"; @@ -97,7 +97,7 @@ unitTest("isInstalled - returns false when only version file exists", async () = unitTest("isInstalled - returns false when only binary exists (no version file)", async () => { const tempDir = Deno.makeTempDirSync(); try { - const { platform } = detectCftPlatform(); + const { platform } = detectChromePlatform(); const subdir = join(tempDir, `chrome-headless-shell-${platform}`); Deno.mkdirSync(subdir); const binaryName = isWindows ? "chrome-headless-shell.exe" : "chrome-headless-shell"; @@ -112,7 +112,7 @@ unitTest("isInstalled - returns true when version file and binary exist", async const tempDir = Deno.makeTempDirSync(); try { noteInstalledVersion(tempDir, "145.0.0.0"); - const { platform } = detectCftPlatform(); + const { platform } = detectChromePlatform(); const subdir = join(tempDir, `chrome-headless-shell-${platform}`); Deno.mkdirSync(subdir); const binaryName = isWindows ? "chrome-headless-shell.exe" : "chrome-headless-shell"; @@ -187,7 +187,7 @@ unitTest("preparePackage - downloads and extracts chrome-headless-shell", async 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"); + const binary = findChromeExecutable(pkg.filePath, "chrome-headless-shell"); assert(binary !== undefined, "binary should exist in extracted dir"); } finally { safeRemoveSync(pkg.filePath, { recursive: true }); From 4e04e2509b4427b2a89c2a2109268f5ebd1c503f Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 7 Apr 2026 18:08:58 +0200 Subject: [PATCH 08/27] Minor cleanup from code review - isPlaywrightCdnPlatform: check platform === "linux-arm64" instead of re-checking raw os/arch values (single source of truth) - detectChromePlatform error message: say "chrome-headless-shell" not "Chrome for Testing" since the function covers both CfT and Playwright - Fix misleading comment in tools-console.ts deprecation redirect --- src/tools/impl/chrome-for-testing.ts | 4 ++-- src/tools/tools-console.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/tools/impl/chrome-for-testing.ts b/src/tools/impl/chrome-for-testing.ts index d3d8b69be90..a9083c9c8bf 100644 --- a/src/tools/impl/chrome-for-testing.ts +++ b/src/tools/impl/chrome-for-testing.ts @@ -54,7 +54,7 @@ export function detectChromePlatform(): PlatformInfo { if (!platform) { throw new Error( - `Unsupported platform for Chrome for Testing: ${os} ${arch}`, + `Unsupported platform for chrome-headless-shell: ${os} ${arch}`, ); } @@ -64,7 +64,7 @@ export function detectChromePlatform(): PlatformInfo { /** Check if the current platform requires Playwright CDN (arm64 Linux). */ export function isPlaywrightCdnPlatform(info?: PlatformInfo): boolean { const p = info ?? detectChromePlatform(); - return p.os === "linux" && p.arch === "aarch64"; + return p.platform === "linux-arm64"; } /** A single download entry from the CfT API. */ diff --git a/src/tools/tools-console.ts b/src/tools/tools-console.ts index 75afc6677c2..1cf492540dd 100644 --- a/src/tools/tools-console.ts +++ b/src/tools/tools-console.ts @@ -159,8 +159,8 @@ export async function updateOrInstallTool( // Check if chrome-headless-shell is already present to pick the right action const chsSummary = await toolSummary("chrome-headless-shell"); const redirectAction = chsSummary?.installed ? "update" : "install"; - // Uninstall legacy chromium after redirect succeeds via installTool/updateTool - // (those functions call Deno.exit, so we clean up before delegating) + // Uninstall legacy chromium before delegating to chrome-headless-shell. + // We can't do this after because installTool/updateTool call Deno.exit. const legacyTool = installableTool("chromium"); if (legacyTool && await legacyTool.installed()) { await uninstallTool("chromium"); From d0aba1c3160a12d7aa3ee5f96b28bc36e68c7973 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 7 Apr 2026 18:39:17 +0200 Subject: [PATCH 09/27] Cache platform detection in latestRelease() Call detectChromePlatform() once and pass the result to both isPlaywrightCdnPlatform() and the CfT platform destructure, instead of computing it twice. --- src/tools/impl/chrome-headless-shell.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/tools/impl/chrome-headless-shell.ts b/src/tools/impl/chrome-headless-shell.ts index 0c2f89692b9..e318578b4c4 100644 --- a/src/tools/impl/chrome-headless-shell.ts +++ b/src/tools/impl/chrome-headless-shell.ts @@ -91,7 +91,9 @@ async function installedVersion(): Promise { } async function latestRelease(): Promise { - if (isPlaywrightCdnPlatform()) { + const platformInfo = detectChromePlatform(); + + if (isPlaywrightCdnPlatform(platformInfo)) { // arm64 Linux: use Playwright CDN const entry = await fetchPlaywrightBrowsersJson(); const url = playwrightCdnDownloadUrl(entry.revision); @@ -104,7 +106,7 @@ async function latestRelease(): Promise { // All other platforms: use CfT API const release = await fetchLatestCftRelease(); - const { platform } = detectChromePlatform(); + const { platform } = platformInfo; const downloads = release.downloads["chrome-headless-shell"]; if (!downloads) { From e439d2190df78358c10eee7db717153715269bd4 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 7 Apr 2026 18:45:16 +0200 Subject: [PATCH 10/27] Extract chromeHeadlessShellBinaryName() helper Single source of truth for the platform-dependent binary name (chrome-headless-shell on CfT platforms, headless_shell on Playwright arm64). Used by chromeHeadlessShellExecutablePath, isInstalled, and preparePackage instead of duplicating the check or trying both names. --- src/tools/impl/chrome-headless-shell.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/tools/impl/chrome-headless-shell.ts b/src/tools/impl/chrome-headless-shell.ts index e318578b4c4..a67b60301b1 100644 --- a/src/tools/impl/chrome-headless-shell.ts +++ b/src/tools/impl/chrome-headless-shell.ts @@ -36,6 +36,14 @@ export function chromeHeadlessShellInstallDir(): string { return quartoDataDir("chrome-headless-shell"); } +/** + * The executable name for chrome-headless-shell on the current platform. + * CfT builds use "chrome-headless-shell", Playwright arm64 builds use "headless_shell". + */ +export function chromeHeadlessShellBinaryName(): string { + return isPlaywrightCdnPlatform() ? "headless_shell" : "chrome-headless-shell"; +} + /** * Find the chrome-headless-shell executable in the install directory. * Returns the absolute path if installed, undefined otherwise. @@ -45,9 +53,7 @@ export function chromeHeadlessShellExecutablePath(): string | undefined { if (!existsSync(dir)) { return undefined; } - // Try CfT name first, then Playwright arm64 name - return findChromeExecutable(dir, "chrome-headless-shell") - ?? findChromeExecutable(dir, "headless_shell"); + return findChromeExecutable(dir, chromeHeadlessShellBinaryName()); } /** Record the installed version as a plain text file. */ @@ -68,8 +74,7 @@ export function readInstalledVersion(dir: string): string | undefined { /** Check if chrome-headless-shell is installed in the given directory. */ export function isInstalled(dir: string): boolean { return existsSync(join(dir, kVersionFileName)) && - (findChromeExecutable(dir, "chrome-headless-shell") !== undefined || - findChromeExecutable(dir, "headless_shell") !== undefined); + findChromeExecutable(dir, chromeHeadlessShellBinaryName()) !== undefined; } // -- InstallableTool methods -- @@ -131,10 +136,7 @@ async function preparePackage(ctx: InstallContext): Promise { const release = await latestRelease(); const workingDir = Deno.makeTempDirSync({ prefix: "quarto-chrome-hs-" }); - // arm64 Playwright builds use "headless_shell" as the binary name - const binaryName = isPlaywrightCdnPlatform() - ? "headless_shell" - : "chrome-headless-shell"; + const binaryName = chromeHeadlessShellBinaryName(); try { await downloadAndExtractChrome( From 7a926fa364c2d7ba0b01ddd0d77a105dff1b8974 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 7 Apr 2026 19:20:35 +0200 Subject: [PATCH 11/27] Handle unsupported platform gracefully in chromeHeadlessShellBinaryName On unsupported platforms, detectChromePlatform() throws. The previous code was tolerant because findChromeExecutable caught this internally and fell back to a directory walk. Now that chromeHeadlessShellBinaryName calls isPlaywrightCdnPlatform directly, catch the exception and default to the CfT binary name to preserve non-throwing probe behavior. --- src/tools/impl/chrome-headless-shell.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/tools/impl/chrome-headless-shell.ts b/src/tools/impl/chrome-headless-shell.ts index a67b60301b1..90f13a752f5 100644 --- a/src/tools/impl/chrome-headless-shell.ts +++ b/src/tools/impl/chrome-headless-shell.ts @@ -39,9 +39,14 @@ export function chromeHeadlessShellInstallDir(): string { /** * The executable name for chrome-headless-shell on the current platform. * CfT builds use "chrome-headless-shell", Playwright arm64 builds use "headless_shell". + * Returns the CfT name if platform detection fails (unsupported platform). */ export function chromeHeadlessShellBinaryName(): string { - return isPlaywrightCdnPlatform() ? "headless_shell" : "chrome-headless-shell"; + try { + return isPlaywrightCdnPlatform() ? "headless_shell" : "chrome-headless-shell"; + } catch { + return "chrome-headless-shell"; + } } /** From feb967ee000364af817d7b24aad04e8a66536553 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 7 Apr 2026 19:28:33 +0200 Subject: [PATCH 12/27] Clarify why Playwright CDN tests are skipped on CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External HTTP tests (CfT API and Playwright CDN) are skipped on CI to avoid flaky failures from network issues. They run locally to catch API contract changes. Removed misleading "arm64 only" from comment — these tests validate platform-independent code. --- tests/unit/tools/chrome-for-testing.test.ts | 6 ++++-- tests/unit/tools/chrome-headless-shell.test.ts | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/unit/tools/chrome-for-testing.test.ts b/tests/unit/tools/chrome-for-testing.test.ts index 85b97644239..b083fd33d90 100644 --- a/tests/unit/tools/chrome-for-testing.test.ts +++ b/tests/unit/tools/chrome-for-testing.test.ts @@ -23,7 +23,7 @@ import { } from "../../../src/tools/impl/chrome-for-testing.ts"; // Step 1: detectChromePlatform() -unitTest("detectChromePlatform - returns valid CftPlatform for current system", async () => { +unitTest("detectChromePlatform - returns valid ChromePlatform for current system", async () => { const result = detectChromePlatform(); const validPlatforms = ["linux64", "linux-arm64", "mac-arm64", "mac-x64", "win32", "win64"]; assert( @@ -149,7 +149,9 @@ unitTest("findChromeExecutable - finds binary in Playwright arm64 layout", async } }); -// Playwright CDN tests — skip on CI (external HTTP) +// Playwright CDN tests +// Skipped on CI: makes external HTTP calls to GitHub/Playwright CDN. +// Same pattern as CfT API tests above — run locally to catch API contract changes. unitTest("fetchPlaywrightBrowsersJson - returns chromium-headless-shell entry", async () => { const entry = await fetchPlaywrightBrowsersJson(); assert(entry.revision, "revision should be non-empty"); diff --git a/tests/unit/tools/chrome-headless-shell.test.ts b/tests/unit/tools/chrome-headless-shell.test.ts index 0b5d15d6a2a..d6df2fa089d 100644 --- a/tests/unit/tools/chrome-headless-shell.test.ts +++ b/tests/unit/tools/chrome-headless-shell.test.ts @@ -139,7 +139,9 @@ unitTest("latestRelease - returns valid RemotePackageInfo", async () => { assertEquals(release.assets[0].name, "chrome-headless-shell"); }, { ignore: runningInCI() }); -// -- Playwright CDN integration (arm64 Linux only, skip on CI) -- +// -- Playwright CDN integration -- +// Skipped on CI: makes external HTTP calls to GitHub/Playwright CDN. +// Same pattern as CfT API tests above — run locally to catch API contract changes. unitTest("Playwright CDN - browsers.json and URL construction", async () => { const entry = await fetchPlaywrightBrowsersJson(); From 2649e7144ef7b630b85e4b3f9239e06feb0a3848 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 8 Apr 2026 12:53:15 +0200 Subject: [PATCH 13/27] Add changelog entries for chromium deprecation and arm64 support --- news/changelog-1.10.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index 6f85f67d76f..93069756a9d 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -18,6 +18,11 @@ All changes included in 1.10: - ([#14281](https://github.com/quarto-dev/quarto-cli/issues/14281)): Avoid creating a duplicate `.quarto_ipynb` file on preview startup for single-file Jupyter documents. +### `install` + +- ([#11877](https://github.com/quarto-dev/quarto-cli/issues/11877)): Add arm64 Linux support for `quarto install chrome-headless-shell` using Playwright CDN as download source, since Chrome for Testing has no arm64 Linux builds. +- ([#11877](https://github.com/quarto-dev/quarto-cli/issues/11877)): Deprecate `quarto install chromium` — the command now transparently redirects to `chrome-headless-shell`. Use `chrome-headless-shell` instead, which always installs the latest stable Chrome (the legacy `chromium` installer pins an outdated Puppeteer revision that cannot receive security updates). + ### `quarto create` - ([#14250](https://github.com/quarto-dev/quarto-cli/issues/14250)): Fix `quarto create` producing read-only files when Quarto is installed via system packages (e.g., `.deb`). Files copied from installed resources now have user-write permission ensured. From e514a5fb0a2701f43d74e6bec5c865a4a73aff04 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 8 Apr 2026 13:22:50 +0200 Subject: [PATCH 14/27] Add #9710 reference to changelog entry --- news/changelog-1.10.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index 93069756a9d..cfee96ec651 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -20,7 +20,7 @@ All changes included in 1.10: ### `install` -- ([#11877](https://github.com/quarto-dev/quarto-cli/issues/11877)): Add arm64 Linux support for `quarto install chrome-headless-shell` using Playwright CDN as download source, since Chrome for Testing has no arm64 Linux builds. +- ([#11877](https://github.com/quarto-dev/quarto-cli/issues/11877), [#9710](https://github.com/quarto-dev/quarto-cli/issues/9710)): Add arm64 Linux support for `quarto install chrome-headless-shell` using Playwright CDN as download source, since Chrome for Testing has no arm64 Linux builds. - ([#11877](https://github.com/quarto-dev/quarto-cli/issues/11877)): Deprecate `quarto install chromium` — the command now transparently redirects to `chrome-headless-shell`. Use `chrome-headless-shell` instead, which always installs the latest stable Chrome (the legacy `chromium` installer pins an outdated Puppeteer revision that cannot receive security updates). ### `quarto create` From 964e487e214ec370c0530c26bbfd5521b17a071d Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 8 Apr 2026 15:19:29 +0200 Subject: [PATCH 15/27] Mark Chromium as deprecated in tool registry --- src/tools/impl/chromium.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/impl/chromium.ts b/src/tools/impl/chromium.ts index 1ffd3047a8b..e3d805d4623 100644 --- a/src/tools/impl/chromium.ts +++ b/src/tools/impl/chromium.ts @@ -27,7 +27,7 @@ async function installDir() { } export const chromiumInstallable: InstallableTool = { - name: "Chromium", + name: "Chromium (deprecated)", prereqs: [], installed, installedVersion, From ed1047f64e0f59677487bfeeaf6f6b6a5e042716 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 8 Apr 2026 15:19:42 +0200 Subject: [PATCH 16/27] Remove chromium examples from install/update/uninstall help text --- src/command/install/cmd.ts | 4 ---- src/command/uninstall/cmd.ts | 4 ---- src/command/update/cmd.ts | 4 ---- 3 files changed, 12 deletions(-) diff --git a/src/command/install/cmd.ts b/src/command/install/cmd.ts index a22b3e0fb60..f2f5bd5d881 100644 --- a/src/command/install/cmd.ts +++ b/src/command/install/cmd.ts @@ -46,10 +46,6 @@ export const installCommand = new Command() "Install Chrome Headless Shell", "quarto install chrome-headless-shell", ) - .example( - "Install Chromium (legacy)", - "quarto install chromium", - ) .example( "Choose tool to install", "quarto install", diff --git a/src/command/uninstall/cmd.ts b/src/command/uninstall/cmd.ts index a90828ceb0d..1b171282772 100644 --- a/src/command/uninstall/cmd.ts +++ b/src/command/uninstall/cmd.ts @@ -37,10 +37,6 @@ export const uninstallCommand = new Command() "Uninstall Chrome Headless Shell", "quarto uninstall chrome-headless-shell", ) - .example( - "Uninstall Chromium (legacy)", - "quarto uninstall chromium", - ) .action( async ( options: { prompt?: boolean; updatePath?: boolean }, diff --git a/src/command/update/cmd.ts b/src/command/update/cmd.ts index b86bff5c54f..f89c71567a0 100644 --- a/src/command/update/cmd.ts +++ b/src/command/update/cmd.ts @@ -51,10 +51,6 @@ export const updateCommand = new Command() "Update Chrome Headless Shell", "quarto update tool chrome-headless-shell", ) - .example( - "Update Chromium (legacy)", - "quarto update tool chromium", - ) .example( "Choose tool to update", "quarto update tool", From 1775c1ef4794702c3eeba14e5cf337ed5b372605 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 8 Apr 2026 15:25:05 +0200 Subject: [PATCH 17/27] Auto-remove legacy chromium when chrome-headless-shell is installed --- src/tools/impl/chrome-headless-shell.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/tools/impl/chrome-headless-shell.ts b/src/tools/impl/chrome-headless-shell.ts index 90f13a752f5..0aaef4efc05 100644 --- a/src/tools/impl/chrome-headless-shell.ts +++ b/src/tools/impl/chrome-headless-shell.ts @@ -26,6 +26,7 @@ import { isPlaywrightCdnPlatform, playwrightCdnDownloadUrl, } from "./chrome-for-testing.ts"; +import { chromiumInstallable } from "./chromium.ts"; const kVersionFileName = "version"; @@ -180,7 +181,12 @@ async function install(pkg: PackageInfo, _ctx: InstallContext): Promise { noteInstalledVersion(installDir, pkg.version); } -async function afterInstall(_ctx: InstallContext): Promise { +async function afterInstall(ctx: InstallContext): Promise { + // Clean up legacy chromium installed by 'quarto install chromium' + if (await chromiumInstallable.installed()) { + ctx.info("Removing legacy Chromium installation..."); + await chromiumInstallable.uninstall(ctx); + } return false; } From a7befa6a852c4ebee45fdd89ea97519712242c62 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 8 Apr 2026 15:32:37 +0200 Subject: [PATCH 18/27] Revert "Auto-remove legacy chromium when chrome-headless-shell is installed" This reverts commit 1775c1ef4794702c3eeba14e5cf337ed5b372605. --- src/tools/impl/chrome-headless-shell.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/tools/impl/chrome-headless-shell.ts b/src/tools/impl/chrome-headless-shell.ts index 0aaef4efc05..90f13a752f5 100644 --- a/src/tools/impl/chrome-headless-shell.ts +++ b/src/tools/impl/chrome-headless-shell.ts @@ -26,7 +26,6 @@ import { isPlaywrightCdnPlatform, playwrightCdnDownloadUrl, } from "./chrome-for-testing.ts"; -import { chromiumInstallable } from "./chromium.ts"; const kVersionFileName = "version"; @@ -181,12 +180,7 @@ async function install(pkg: PackageInfo, _ctx: InstallContext): Promise { noteInstalledVersion(installDir, pkg.version); } -async function afterInstall(ctx: InstallContext): Promise { - // Clean up legacy chromium installed by 'quarto install chromium' - if (await chromiumInstallable.installed()) { - ctx.info("Removing legacy Chromium installation..."); - await chromiumInstallable.uninstall(ctx); - } +async function afterInstall(_ctx: InstallContext): Promise { return false; } From 62cdc0cbcf235bee81249b48ba26e0ad0be415ce Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 8 Apr 2026 15:37:09 +0200 Subject: [PATCH 19/27] Extract chrome-headless-shell path utilities to break circular dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move path/version utility functions from chrome-headless-shell.ts into chrome-headless-shell-paths.ts so puppeteer.ts can import them without creating a cycle (chrome-headless-shell → chromium → puppeteer → chrome-headless-shell). The original module re-exports everything for backward compatibility. --- src/core/puppeteer.ts | 2 +- src/tools/impl/chrome-headless-shell-paths.ts | 70 +++++++++++++++ src/tools/impl/chrome-headless-shell.ts | 89 +++++++------------ 3 files changed, 102 insertions(+), 59 deletions(-) create mode 100644 src/tools/impl/chrome-headless-shell-paths.ts diff --git a/src/core/puppeteer.ts b/src/core/puppeteer.ts index 18d73dfec67..5f054a3a402 100644 --- a/src/core/puppeteer.ts +++ b/src/core/puppeteer.ts @@ -16,7 +16,7 @@ import { chromeHeadlessShellExecutablePath, chromeHeadlessShellInstallDir, readInstalledVersion, -} from "../tools/impl/chrome-headless-shell.ts"; +} from "../tools/impl/chrome-headless-shell-paths.ts"; // deno-lint-ignore no-explicit-any // let puppeteerImport: any = undefined; diff --git a/src/tools/impl/chrome-headless-shell-paths.ts b/src/tools/impl/chrome-headless-shell-paths.ts new file mode 100644 index 00000000000..1a4b79588dd --- /dev/null +++ b/src/tools/impl/chrome-headless-shell-paths.ts @@ -0,0 +1,70 @@ +/* + * chrome-headless-shell-paths.ts + * + * Path and version utilities for chrome-headless-shell. + * Extracted from chrome-headless-shell.ts so that puppeteer.ts can import + * these without creating a circular dependency. + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { join } from "../../deno_ral/path.ts"; +import { existsSync } from "../../deno_ral/fs.ts"; +import { quartoDataDir } from "../../core/appdirs.ts"; +import { + findChromeExecutable, + isPlaywrightCdnPlatform, +} from "./chrome-for-testing.ts"; + +const kVersionFileName = "version"; + +/** Return the chrome-headless-shell install directory under quartoDataDir. */ +export function chromeHeadlessShellInstallDir(): string { + return quartoDataDir("chrome-headless-shell"); +} + +/** + * The executable name for chrome-headless-shell on the current platform. + * CfT builds use "chrome-headless-shell", Playwright arm64 builds use "headless_shell". + * Returns the CfT name if platform detection fails (unsupported platform). + */ +export function chromeHeadlessShellBinaryName(): string { + try { + return isPlaywrightCdnPlatform() ? "headless_shell" : "chrome-headless-shell"; + } catch { + return "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 findChromeExecutable(dir, chromeHeadlessShellBinaryName()); +} + +/** 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)) && + findChromeExecutable(dir, chromeHeadlessShellBinaryName()) !== undefined; +} diff --git a/src/tools/impl/chrome-headless-shell.ts b/src/tools/impl/chrome-headless-shell.ts index 90f13a752f5..471ebe45413 100644 --- a/src/tools/impl/chrome-headless-shell.ts +++ b/src/tools/impl/chrome-headless-shell.ts @@ -10,7 +10,6 @@ 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, @@ -22,65 +21,27 @@ import { downloadAndExtractChrome, fetchLatestCftRelease, fetchPlaywrightBrowsersJson, - findChromeExecutable, isPlaywrightCdnPlatform, playwrightCdnDownloadUrl, } 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"); -} - -/** - * The executable name for chrome-headless-shell on the current platform. - * CfT builds use "chrome-headless-shell", Playwright arm64 builds use "headless_shell". - * Returns the CfT name if platform detection fails (unsupported platform). - */ -export function chromeHeadlessShellBinaryName(): string { - try { - return isPlaywrightCdnPlatform() ? "headless_shell" : "chrome-headless-shell"; - } catch { - return "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 findChromeExecutable(dir, chromeHeadlessShellBinaryName()); -} - -/** 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)) && - findChromeExecutable(dir, chromeHeadlessShellBinaryName()) !== undefined; -} +import { + chromeHeadlessShellBinaryName, + chromeHeadlessShellInstallDir, + isInstalled, + noteInstalledVersion, + readInstalledVersion, +} from "./chrome-headless-shell-paths.ts"; +import { chromiumInstallable } from "./chromium.ts"; + +// Re-export path utilities for external consumers +export { + chromeHeadlessShellBinaryName, + chromeHeadlessShellExecutablePath, + chromeHeadlessShellInstallDir, + isInstalled, + noteInstalledVersion, + readInstalledVersion, +} from "./chrome-headless-shell-paths.ts"; // -- InstallableTool methods -- @@ -180,7 +141,19 @@ async function install(pkg: PackageInfo, _ctx: InstallContext): Promise { noteInstalledVersion(installDir, pkg.version); } -async function afterInstall(_ctx: InstallContext): Promise { +async function afterInstall(ctx: InstallContext): Promise { + // Clean up legacy chromium installed by 'quarto install chromium' + try { + if (await chromiumInstallable.installed()) { + ctx.info("Removing legacy Chromium installation..."); + await chromiumInstallable.uninstall(ctx); + } + } catch { + ctx.info( + "Note: Could not remove legacy Chromium. " + + "You can remove it manually with 'quarto uninstall chromium'.", + ); + } return false; } From d9d799e8e2dd6201027c814691b07954e27a4aa0 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 8 Apr 2026 17:06:43 +0200 Subject: [PATCH 20/27] Add CI workflow for tool install and chromium deprecation tests Port test-install.yml from v1.9 backport, adapted for v1.10 redirect behavior. Tests chrome-headless-shell install on arm64 Linux and macOS, and verifies the chromium deprecation redirect across all platforms. Also adds tools-table warning in quarto check install when legacy Chromium (deprecated) is detected. --- .github/workflows/test-install.yml | 131 +++++++++++++++++++++++++++++ src/command/check/check.ts | 7 ++ 2 files changed, 138 insertions(+) create mode 100644 .github/workflows/test-install.yml diff --git a/.github/workflows/test-install.yml b/.github/workflows/test-install.yml new file mode 100644 index 00000000000..fda9bc5a6e3 --- /dev/null +++ b/.github/workflows/test-install.yml @@ -0,0 +1,131 @@ +# Integration test for `quarto install` on platforms not covered by smoke tests. +# Smoke tests (test-smokes.yml) cover x86_64 Linux and Windows. +# This workflow fills the gap for arm64 Linux and macOS. +name: Test Tool Install +on: + workflow_dispatch: + push: + branches: + - main + - "v1.*" + paths: + - "src/tools/**" + - ".github/workflows/test-install.yml" + pull_request: + paths: + - "src/tools/**" + - ".github/workflows/test-install.yml" + schedule: + # Weekly Monday 9am UTC — detect upstream CDN/API breakage + - cron: "0 9 * * 1" + +permissions: + contents: read + +jobs: + test-install: + name: Install tools (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04-arm, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout Repo + uses: actions/checkout@v6 + + - uses: ./.github/workflows/actions/quarto-dev + + - name: Install TinyTeX + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + quarto install tinytex + + - name: Install Chrome Headless Shell + run: | + quarto install chrome-headless-shell --no-prompt + + - name: Verify tools with quarto check + run: | + quarto check install + + test-chromium-deprecation: + name: Chromium deprecation redirect (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout Repo + uses: actions/checkout@v6 + + - uses: ./.github/workflows/actions/quarto-dev + + - name: Make quarto available in bash (Windows) + if: runner.os == 'Windows' + shell: bash + run: | + quarto_cmd=$(command -v quarto.cmd) + dir=$(dirname "$quarto_cmd") + printf '#!/bin/bash\nexec "%s" "$@"\n' "$quarto_cmd" > "$dir/quarto" + chmod +x "$dir/quarto" + + - name: Install chromium (should redirect to chrome-headless-shell) + id: install-chromium + shell: bash + run: | + output=$(quarto install chromium --no-prompt 2>&1) || true + echo "$output" + if echo "$output" | grep -q "deprecated"; then + echo "deprecation-warning=true" >> "$GITHUB_OUTPUT" + fi + if echo "$output" | grep -q "Installation successful"; then + echo "install-successful=true" >> "$GITHUB_OUTPUT" + fi + + - name: Assert deprecation warning was shown + if: steps.install-chromium.outputs.deprecation-warning != 'true' + shell: bash + run: | + echo "::error::Deprecation warning missing from 'quarto install chromium' output" + exit 1 + + - name: Assert installation succeeded (via redirect) + if: steps.install-chromium.outputs.install-successful != 'true' + shell: bash + run: | + echo "::error::Installation did not succeed — redirect to chrome-headless-shell may have failed" + exit 1 + + - name: Verify quarto check shows Chrome Headless Shell + shell: bash + run: | + output=$(quarto check install 2>&1) || true + echo "$output" + if ! echo "$output" | grep -q "Chrome Headless Shell"; then + echo "::error::Chrome Headless Shell not detected by quarto check after redirect install" + exit 1 + fi + if echo "$output" | grep -q "outdated"; then + echo "::error::Unexpected 'outdated' warning — legacy chromium should have been auto-removed" + exit 1 + fi + + - name: Update chromium (should redirect) and capture result + id: update-chromium + shell: bash + run: | + output=$(quarto update tool chromium --no-prompt 2>&1) || true + echo "$output" + if echo "$output" | grep -q "deprecated"; then + echo "deprecation-warning=true" >> "$GITHUB_OUTPUT" + fi + + - name: Assert update deprecation warning was shown + if: steps.update-chromium.outputs.deprecation-warning != 'true' + shell: bash + run: | + echo "::error::Deprecation warning missing from 'quarto update tool chromium' output" + exit 1 diff --git a/src/command/check/check.ts b/src/command/check/check.ts index 46c6ed15728..1c84a508d0e 100644 --- a/src/command/check/check.ts +++ b/src/command/check/check.ts @@ -364,6 +364,11 @@ async function checkInstall(conf: CheckConfiguration) { toolsJson[tool.name] = { version, }; + if (tool.name === "Chromium (deprecated)") { + toolsOutput.push( + `${kIndent} (Run "quarto install chrome-headless-shell" to replace)`, + ); + } } for (const tool of tools.notInstalled) { toolsOutput.push(`${kIndent}${tool.name}: (not installed)`); @@ -557,6 +562,8 @@ async function detectChromeForCheck(): Promise { source: "quarto", version, }; + result.warning = + 'Chromium installed by Quarto is outdated. Run "quarto install chrome-headless-shell" to get the latest Chrome.'; } return result; From e113ceb52b492b38de4aa90b9480c7d31a4a98da Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 8 Apr 2026 17:23:10 +0200 Subject: [PATCH 21/27] Tighten CI workflow assertions and fix unit tests for arm64 Port improvements from v1.9 backport PR: - Use exit code instead of grep for install success detection - Use grep -Fq with specific strings for deprecation checks - Fix unit tests to use chromeHeadlessShellBinaryName() for arm64 Playwright CDN binary layout compatibility - Add tools-table warning in quarto check for Chromium (deprecated) --- .github/workflows/test-install.yml | 21 +++++++++++------- .../unit/tools/chrome-headless-shell.test.ts | 22 ++++++++++++++----- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/.github/workflows/test-install.yml b/.github/workflows/test-install.yml index fda9bc5a6e3..b26c4f09475 100644 --- a/.github/workflows/test-install.yml +++ b/.github/workflows/test-install.yml @@ -76,12 +76,15 @@ jobs: id: install-chromium shell: bash run: | - output=$(quarto install chromium --no-prompt 2>&1) || true + set +e + output=$(quarto install chromium --no-prompt 2>&1) + exit_code=$? + set -e echo "$output" - if echo "$output" | grep -q "deprecated"; then + if echo "$output" | grep -Fq "is deprecated"; then echo "deprecation-warning=true" >> "$GITHUB_OUTPUT" fi - if echo "$output" | grep -q "Installation successful"; then + if [ "$exit_code" -eq 0 ]; then echo "install-successful=true" >> "$GITHUB_OUTPUT" fi @@ -102,13 +105,13 @@ jobs: - name: Verify quarto check shows Chrome Headless Shell shell: bash run: | - output=$(quarto check install 2>&1) || true + output=$(quarto check install 2>&1) echo "$output" - if ! echo "$output" | grep -q "Chrome Headless Shell"; then + if ! echo "$output" | grep -Fq "Chrome Headless Shell"; then echo "::error::Chrome Headless Shell not detected by quarto check after redirect install" exit 1 fi - if echo "$output" | grep -q "outdated"; then + if echo "$output" | grep -Fq "outdated"; then echo "::error::Unexpected 'outdated' warning — legacy chromium should have been auto-removed" exit 1 fi @@ -117,9 +120,11 @@ jobs: id: update-chromium shell: bash run: | - output=$(quarto update tool chromium --no-prompt 2>&1) || true + set +e + output=$(quarto update tool chromium --no-prompt 2>&1) + set -e echo "$output" - if echo "$output" | grep -q "deprecated"; then + if echo "$output" | grep -Fq "is deprecated"; then echo "deprecation-warning=true" >> "$GITHUB_OUTPUT" fi diff --git a/tests/unit/tools/chrome-headless-shell.test.ts b/tests/unit/tools/chrome-headless-shell.test.ts index d6df2fa089d..23243ed5060 100644 --- a/tests/unit/tools/chrome-headless-shell.test.ts +++ b/tests/unit/tools/chrome-headless-shell.test.ts @@ -20,6 +20,7 @@ import { } from "../../../src/tools/impl/chrome-for-testing.ts"; import { installableTool, installableTools } from "../../../src/tools/tools.ts"; import { + chromeHeadlessShellBinaryName, chromeHeadlessShellInstallable, chromeHeadlessShellInstallDir, chromeHeadlessShellExecutablePath, @@ -98,10 +99,13 @@ unitTest("isInstalled - returns false when only binary exists (no version file)" const tempDir = Deno.makeTempDirSync(); try { const { platform } = detectChromePlatform(); + const binName = chromeHeadlessShellBinaryName(); + // CfT layout: chrome-headless-shell-{platform}/binary + // Playwright arm64 layout: chrome-linux/binary (found via walkSync fallback) const subdir = join(tempDir, `chrome-headless-shell-${platform}`); Deno.mkdirSync(subdir); - const binaryName = isWindows ? "chrome-headless-shell.exe" : "chrome-headless-shell"; - Deno.writeTextFileSync(join(subdir, binaryName), "fake"); + const target = isWindows ? `${binName}.exe` : binName; + Deno.writeTextFileSync(join(subdir, target), "fake"); assertEquals(isInstalled(tempDir), false); } finally { safeRemoveSync(tempDir, { recursive: true }); @@ -113,10 +117,11 @@ unitTest("isInstalled - returns true when version file and binary exist", async try { noteInstalledVersion(tempDir, "145.0.0.0"); const { platform } = detectChromePlatform(); + const binName = chromeHeadlessShellBinaryName(); const subdir = join(tempDir, `chrome-headless-shell-${platform}`); Deno.mkdirSync(subdir); - const binaryName = isWindows ? "chrome-headless-shell.exe" : "chrome-headless-shell"; - Deno.writeTextFileSync(join(subdir, binaryName), "fake"); + const target = isWindows ? `${binName}.exe` : binName; + Deno.writeTextFileSync(join(subdir, target), "fake"); assertEquals(isInstalled(tempDir), true); } finally { @@ -134,7 +139,12 @@ unitTest("latestRelease - returns valid RemotePackageInfo", async () => { `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"); + // CfT URLs contain the version; Playwright CDN URLs contain a revision number instead + if (!isPlaywrightCdnPlatform()) { + assert(release.url.includes(release.version), "CfT URL should contain version"); + } else { + assert(release.url.includes("cdn.playwright.dev"), "arm64 URL should use Playwright CDN"); + } assert(release.assets.length > 0, "should have at least one asset"); assertEquals(release.assets[0].name, "chrome-headless-shell"); }, { ignore: runningInCI() }); @@ -189,7 +199,7 @@ unitTest("preparePackage - downloads and extracts chrome-headless-shell", async try { assert(pkg.version, "version should be non-empty"); assert(pkg.filePath, "filePath should be non-empty"); - const binary = findChromeExecutable(pkg.filePath, "chrome-headless-shell"); + const binary = findChromeExecutable(pkg.filePath, chromeHeadlessShellBinaryName()); assert(binary !== undefined, "binary should exist in extracted dir"); } finally { safeRemoveSync(pkg.filePath, { recursive: true }); From 7c8cdb8a6fbe952c41df421a56f86305455903b2 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 8 Apr 2026 17:26:00 +0200 Subject: [PATCH 22/27] Make CI assert steps always run instead of skip-on-success --- .github/workflows/test-install.yml | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test-install.yml b/.github/workflows/test-install.yml index b26c4f09475..dfb108ef7b8 100644 --- a/.github/workflows/test-install.yml +++ b/.github/workflows/test-install.yml @@ -89,18 +89,22 @@ jobs: fi - name: Assert deprecation warning was shown - if: steps.install-chromium.outputs.deprecation-warning != 'true' shell: bash run: | - echo "::error::Deprecation warning missing from 'quarto install chromium' output" - exit 1 + if [ "${{ steps.install-chromium.outputs.deprecation-warning }}" != "true" ]; then + echo "::error::Deprecation warning missing from 'quarto install chromium' output" + exit 1 + fi + echo "Install deprecation warning found" - name: Assert installation succeeded (via redirect) - if: steps.install-chromium.outputs.install-successful != 'true' shell: bash run: | - echo "::error::Installation did not succeed — redirect to chrome-headless-shell may have failed" - exit 1 + if [ "${{ steps.install-chromium.outputs.install-successful }}" != "true" ]; then + echo "::error::Installation did not succeed — redirect to chrome-headless-shell may have failed" + exit 1 + fi + echo "Installation succeeded via redirect" - name: Verify quarto check shows Chrome Headless Shell shell: bash @@ -129,8 +133,10 @@ jobs: fi - name: Assert update deprecation warning was shown - if: steps.update-chromium.outputs.deprecation-warning != 'true' shell: bash run: | - echo "::error::Deprecation warning missing from 'quarto update tool chromium' output" - exit 1 + if [ "${{ steps.update-chromium.outputs.deprecation-warning }}" != "true" ]; then + echo "::error::Deprecation warning missing from 'quarto update tool chromium' output" + exit 1 + fi + echo "Update deprecation warning found" From 8bb5a461511e27553d093c0552f6c44a0dc72459 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 8 Apr 2026 18:03:27 +0200 Subject: [PATCH 23/27] Tighten CI assertions: check update exit code, verify legacy chromium absence --- .github/workflows/test-install.yml | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test-install.yml b/.github/workflows/test-install.yml index dfb108ef7b8..c7271c97334 100644 --- a/.github/workflows/test-install.yml +++ b/.github/workflows/test-install.yml @@ -115,8 +115,8 @@ jobs: echo "::error::Chrome Headless Shell not detected by quarto check after redirect install" exit 1 fi - if echo "$output" | grep -Fq "outdated"; then - echo "::error::Unexpected 'outdated' warning — legacy chromium should have been auto-removed" + if echo "$output" | grep -Fq "Chromium (deprecated)"; then + echo "::error::Legacy Chromium still present — afterInstall should have removed it" exit 1 fi @@ -126,13 +126,17 @@ jobs: run: | set +e output=$(quarto update tool chromium --no-prompt 2>&1) + exit_code=$? set -e echo "$output" if echo "$output" | grep -Fq "is deprecated"; then echo "deprecation-warning=true" >> "$GITHUB_OUTPUT" fi + if [ "$exit_code" -eq 0 ]; then + echo "update-successful=true" >> "$GITHUB_OUTPUT" + fi - - name: Assert update deprecation warning was shown + - name: Assert update deprecation warning was shown and succeeded shell: bash run: | if [ "${{ steps.update-chromium.outputs.deprecation-warning }}" != "true" ]; then @@ -140,3 +144,8 @@ jobs: exit 1 fi echo "Update deprecation warning found" + if [ "${{ steps.update-chromium.outputs.update-successful }}" != "true" ]; then + echo "::error::Update command failed — redirect to chrome-headless-shell may have failed" + exit 1 + fi + echo "Update succeeded via redirect" From 10c3800217dbe86f05f0d5d2fe759745d23e4c1f Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 8 Apr 2026 18:11:38 +0200 Subject: [PATCH 24/27] Fix legacy Chromium assertion to match installed-only warning text --- .github/workflows/test-install.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-install.yml b/.github/workflows/test-install.yml index c7271c97334..968dc117b27 100644 --- a/.github/workflows/test-install.yml +++ b/.github/workflows/test-install.yml @@ -115,8 +115,8 @@ jobs: echo "::error::Chrome Headless Shell not detected by quarto check after redirect install" exit 1 fi - if echo "$output" | grep -Fq "Chromium (deprecated)"; then - echo "::error::Legacy Chromium still present — afterInstall should have removed it" + if echo "$output" | grep -Fq 'chrome-headless-shell" to replace'; then + echo "::error::Legacy Chromium still installed — afterInstall should have removed it" exit 1 fi From 91ae92b91a8ac4fbbe4260774f357c78d7a3664d Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 8 Apr 2026 18:18:32 +0200 Subject: [PATCH 25/27] Remove duplicate deprecation warning from Chrome Headless check section The tools-table warning already tells users to install chrome-headless-shell when legacy chromium is detected. No need for a second NOTE in the Chrome Headless section. --- src/command/check/check.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/command/check/check.ts b/src/command/check/check.ts index 1c84a508d0e..9fd47ba9f59 100644 --- a/src/command/check/check.ts +++ b/src/command/check/check.ts @@ -562,8 +562,6 @@ async function detectChromeForCheck(): Promise { source: "quarto", version, }; - result.warning = - 'Chromium installed by Quarto is outdated. Run "quarto install chrome-headless-shell" to get the latest Chrome.'; } return result; From eb5b31367c697711397aecfe59f331e8e15099e1 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 8 Apr 2026 18:21:44 +0200 Subject: [PATCH 26/27] Remove unnecessary re-exports from chrome-headless-shell.ts Tests now import path utilities directly from chrome-headless-shell-paths.ts. No other consumer needs the re-export. --- src/tools/impl/chrome-headless-shell.ts | 9 --------- tests/unit/tools/chrome-headless-shell.test.ts | 4 ++-- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/src/tools/impl/chrome-headless-shell.ts b/src/tools/impl/chrome-headless-shell.ts index 471ebe45413..275435d099d 100644 --- a/src/tools/impl/chrome-headless-shell.ts +++ b/src/tools/impl/chrome-headless-shell.ts @@ -33,15 +33,6 @@ import { } from "./chrome-headless-shell-paths.ts"; import { chromiumInstallable } from "./chromium.ts"; -// Re-export path utilities for external consumers -export { - chromeHeadlessShellBinaryName, - chromeHeadlessShellExecutablePath, - chromeHeadlessShellInstallDir, - isInstalled, - noteInstalledVersion, - readInstalledVersion, -} from "./chrome-headless-shell-paths.ts"; // -- InstallableTool methods -- diff --git a/tests/unit/tools/chrome-headless-shell.test.ts b/tests/unit/tools/chrome-headless-shell.test.ts index 23243ed5060..4b9dcc6f5e4 100644 --- a/tests/unit/tools/chrome-headless-shell.test.ts +++ b/tests/unit/tools/chrome-headless-shell.test.ts @@ -21,13 +21,13 @@ import { import { installableTool, installableTools } from "../../../src/tools/tools.ts"; import { chromeHeadlessShellBinaryName, - chromeHeadlessShellInstallable, chromeHeadlessShellInstallDir, chromeHeadlessShellExecutablePath, isInstalled, noteInstalledVersion, readInstalledVersion, -} from "../../../src/tools/impl/chrome-headless-shell.ts"; +} from "../../../src/tools/impl/chrome-headless-shell-paths.ts"; +import { chromeHeadlessShellInstallable } from "../../../src/tools/impl/chrome-headless-shell.ts"; // -- Step 1: Install directory + executable path -- From dc4d90790395b740af2666cd7fae671f4c6906f0 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 8 Apr 2026 18:26:25 +0200 Subject: [PATCH 27/27] Update changelog for afterInstall cleanup and quarto check warning --- news/changelog-1.10.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index b51bcf79dc5..2306b5ec79a 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -22,7 +22,11 @@ All changes included in 1.10: ### `install` - ([#11877](https://github.com/quarto-dev/quarto-cli/issues/11877), [#9710](https://github.com/quarto-dev/quarto-cli/issues/9710)): Add arm64 Linux support for `quarto install chrome-headless-shell` using Playwright CDN as download source, since Chrome for Testing has no arm64 Linux builds. -- ([#11877](https://github.com/quarto-dev/quarto-cli/issues/11877)): Deprecate `quarto install chromium` — the command now transparently redirects to `chrome-headless-shell`. Use `chrome-headless-shell` instead, which always installs the latest stable Chrome (the legacy `chromium` installer pins an outdated Puppeteer revision that cannot receive security updates). +- ([#11877](https://github.com/quarto-dev/quarto-cli/issues/11877)): Deprecate `quarto install chromium` — the command now transparently redirects to `chrome-headless-shell`. Installing `chrome-headless-shell` automatically removes any legacy Chromium installation. Use `chrome-headless-shell` instead, which always installs the latest stable Chrome (the legacy `chromium` installer pins an outdated Puppeteer revision that cannot receive security updates). + +### `check` + +- ([#11877](https://github.com/quarto-dev/quarto-cli/issues/11877)): `quarto check install` now shows a deprecation warning when legacy Chromium (installed via `quarto install chromium`) is detected, directing users to install `chrome-headless-shell` as a replacement. ### `quarto create`