diff --git a/.github/workflows/test-install.yml b/.github/workflows/test-install.yml index 49d5900c777..968dc117b27 100644 --- a/.github/workflows/test-install.yml +++ b/.github/workflows/test-install.yml @@ -43,11 +43,109 @@ jobs: quarto install tinytex - name: Install Chrome Headless Shell - # arm64 Linux support requires #14334. Remove this condition once merged. - if: runner.arch != 'ARM64' 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: | + set +e + output=$(quarto install 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 "install-successful=true" >> "$GITHUB_OUTPUT" + fi + + - name: Assert deprecation warning was shown + shell: bash + run: | + 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) + shell: bash + run: | + 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 + run: | + output=$(quarto check install 2>&1) + echo "$output" + 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 -Fq 'chrome-headless-shell" to replace'; then + echo "::error::Legacy Chromium still installed — afterInstall should have removed it" + exit 1 + fi + + - name: Update chromium (should redirect) and capture result + id: update-chromium + shell: bash + 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 and succeeded + shell: bash + run: | + 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" + 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" diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index 559449f0c71..2306b5ec79a 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -19,6 +19,15 @@ 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), [#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`. 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` - ([#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. diff --git a/src/command/check/check.ts b/src/command/check/check.ts index 46c6ed15728..9fd47ba9f59 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)`); 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", 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) { 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-for-testing.ts b/src/tools/impl/chrome-for-testing.ts index ce543913937..a9083c9c8bf 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 */ @@ -15,9 +18,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. */ -export type CftPlatform = +/** Platform identifiers for Chrome binary downloads (CfT API + Playwright CDN). */ +export type ChromePlatform = | "linux64" + | "linux-arm64" | "mac-arm64" | "mac-x64" | "win32" @@ -25,18 +29,20 @@ export type CftPlatform = /** Platform detection result. */ export interface PlatformInfo { - platform: CftPlatform; + platform: ChromePlatform; 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). + * 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 = { +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", @@ -47,23 +53,23 @@ export function detectCftPlatform(): PlatformInfo { 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}`, + `Unsupported platform for chrome-headless-shell: ${os} ${arch}`, ); } return { platform, os, arch }; } +/** Check if the current platform requires Playwright CDN (arm64 Linux). */ +export function isPlaywrightCdnPlatform(info?: PlatformInfo): boolean { + const p = info ?? detectChromePlatform(); + return p.platform === "linux-arm64"; +} + /** A single download entry from the CfT API. */ export interface CftDownload { - platform: CftPlatform; + platform: ChromePlatform; url: string; } @@ -122,12 +128,83 @@ 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. * Returns absolute path to the executable, or undefined if not found. */ -export function findCftExecutable( +export function findChromeExecutable( extractedDir: string, binaryName: string, ): string | undefined { @@ -135,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 @@ -160,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, @@ -177,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-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 7ac9c038c89..275435d099d 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 @@ -9,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, @@ -17,53 +17,22 @@ import { RemotePackageInfo, } from "../types.ts"; import { - detectCftPlatform, - downloadAndExtractCft, + detectChromePlatform, + downloadAndExtractChrome, fetchLatestCftRelease, - findCftExecutable, + fetchPlaywrightBrowsersJson, + isPlaywrightCdnPlatform, + playwrightCdnDownloadUrl, } from "./chrome-for-testing.ts"; +import { + chromeHeadlessShellBinaryName, + chromeHeadlessShellInstallDir, + isInstalled, + noteInstalledVersion, + readInstalledVersion, +} from "./chrome-headless-shell-paths.ts"; +import { chromiumInstallable } from "./chromium.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 -- @@ -84,8 +53,22 @@ async function installedVersion(): Promise { } async function latestRelease(): Promise { + const platformInfo = detectChromePlatform(); + + if (isPlaywrightCdnPlatform(platformInfo)) { + // 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(); + const { platform } = platformInfo; const downloads = release.downloads["chrome-headless-shell"]; if (!downloads) { @@ -110,13 +93,15 @@ async function preparePackage(ctx: InstallContext): Promise { const release = await latestRelease(); const workingDir = Deno.makeTempDirSync({ prefix: "quarto-chrome-hs-" }); + const binaryName = chromeHeadlessShellBinaryName(); + try { - await downloadAndExtractCft( + await downloadAndExtractChrome( "Chrome Headless Shell", release.url, workingDir, ctx, - "chrome-headless-shell", + binaryName, ); } catch (e) { safeRemoveSync(workingDir, { recursive: true }); @@ -147,7 +132,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; } 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, diff --git a/src/tools/tools-console.ts b/src/tools/tools-console.ts index 5cd9db25809..1cf492540dd 100644 --- a/src/tools/tools-console.ts +++ b/src/tools/tools-console.ts @@ -149,6 +149,27 @@ 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") { + // 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 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"); + } + 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) { 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": {} } diff --git a/tests/unit/tools/chrome-for-testing.test.ts b/tests/unit/tools/chrome-for-testing.test.ts index b2af0ef1584..b083fd33d90 100644 --- a/tests/unit/tools/chrome-for-testing.test.ts +++ b/tests/unit/tools/chrome-for-testing.test.ts @@ -13,16 +13,19 @@ 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, - findCftExecutable, + fetchPlaywrightBrowsersJson, + 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(); - const validPlatforms = ["linux64", "mac-arm64", "mac-x64", "win32", "win64"]; +// Step 1: detectChromePlatform() +unitTest("detectChromePlatform - returns valid ChromePlatform for current system", async () => { + const result = detectChromePlatform(); + const validPlatforms = ["linux64", "linux-arm64", "mac-arm64", "mac-x64", "win32", "win64"]; assert( validPlatforms.includes(result.platform), `Expected one of ${validPlatforms.join(", ")}, got: ${result.platform}`, @@ -31,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"); @@ -74,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 @@ -87,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), @@ -98,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 @@ -120,19 +123,79 @@ 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 4: downloadAndExtractCft() — integration test, downloads ~50MB +// Step 3b: findChromeExecutable() — Playwright arm64 layout +// Skip on Windows: arm64 layout is Linux-only, no .exe extension. +unitTest("findChromeExecutable - 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 = 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 { + safeRemoveSync(tempDir, { recursive: true }); + } +}); + +// 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"); + 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: 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}`); @@ -157,9 +220,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 259caf685e9..4b9dcc6f5e4 100644 --- a/tests/unit/tools/chrome-headless-shell.test.ts +++ b/tests/unit/tools/chrome-headless-shell.test.ts @@ -11,16 +11,23 @@ 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 { + detectChromePlatform, + fetchPlaywrightBrowsersJson, + findChromeExecutable, + isPlaywrightCdnPlatform, + playwrightCdnDownloadUrl, +} from "../../../src/tools/impl/chrome-for-testing.ts"; import { installableTool, installableTools } from "../../../src/tools/tools.ts"; import { - chromeHeadlessShellInstallable, + chromeHeadlessShellBinaryName, 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 -- @@ -91,11 +98,14 @@ 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 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 }); @@ -106,11 +116,12 @@ 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 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 { @@ -128,11 +139,37 @@ 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() }); +// -- 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(); + 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 { @@ -162,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 = findCftExecutable(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 });