Skip to content

Commit f7f96cc

Browse files
authored
[backport] Fix chrome-headless-shell install on Linux arm64 (#14861)
Playwright's CDN moved its arm64 build from a revision-keyed path to a browserVersion-keyed one, which redirects to the same storage.googleapis.com/chrome-for-testing-public bucket used by every other platform. The old URL now 404s, and the redirect also changed the archive's internal layout from Playwright's own chrome-linux/headless_shell to the standard CfT chrome-headless-shell-linux-arm64/chrome-headless-shell layout. - Build the new browserVersion-keyed download URL for arm64 - Return the CfT binary name unconditionally - On arm64 Linux, fall back to probing the legacy headless_shell layout so installs made before the CDN migration stay detected instead of reporting "Chrome not found" Fixes #14857
1 parent e61c9a9 commit f7f96cc

6 files changed

Lines changed: 210 additions & 57 deletions

File tree

‎news/changelog-1.10.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## In this release
44

55
- ([#14741](https://github.com/quarto-dev/quarto-cli/issues/14741)): Don't wrap the `longtable` environment of a cross-referenceable table in a `{ ... }` group. Pandoc emits that group to scope its `\def\LTcaptype{none}`, which Quarto removes when adding its own `\caption`; keeping the now-pointless group broke packages that move the environment out of the text flow, such as `endfloat` with `\DeclareDelayedFloatFlavor*{longtable}{table}`.
6+
- ([#14857](https://github.com/quarto-dev/quarto-cli/issues/14857)): Fix `quarto install chrome-headless-shell` (and `quarto install chromium`) failing with a 404 on Linux arm64 due to a stale Playwright CDN URL.
67

78
## In previous releases
89

‎src/tools/impl/chrome-for-testing.ts‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -130,19 +130,18 @@ export async function fetchLatestCftRelease(): Promise<CftStableRelease> {
130130

131131
/** Parsed entry from Playwright's browsers.json for chromium-headless-shell. */
132132
export interface PlaywrightBrowserEntry {
133-
revision: string;
134133
browserVersion: string;
135134
}
136135

137136
// Source: https://github.com/microsoft/playwright/blob/main/packages/playwright-core/browsers.json
138-
// This file lists the browser revisions Playwright pins per release, including
137+
// This file lists the browser versions Playwright pins per release, including
139138
// chromium-headless-shell builds for linux arm64 that CfT does not provide.
140139
const kPlaywrightBrowsersJsonUrl =
141140
"https://raw.githubusercontent.com/microsoft/playwright/main/packages/playwright-core/browsers.json";
142141

143142
/**
144143
* Fetch Playwright's browsers.json and extract the chromium-headless-shell entry.
145-
* Used as the version/revision source for arm64 Linux where CfT has no builds.
144+
* Used as the version source for arm64 Linux where CfT has no builds.
146145
*/
147146
export async function fetchPlaywrightBrowsersJson(): Promise<PlaywrightBrowserEntry> {
148147
let response: Response;
@@ -179,24 +178,26 @@ export async function fetchPlaywrightBrowsersJson(): Promise<PlaywrightBrowserEn
179178

180179
// deno-lint-ignore no-explicit-any
181180
const entry = browsers.find((b: any) => b.name === "chromium-headless-shell");
182-
if (!entry || !entry.revision || !entry.browserVersion) {
181+
if (!entry || !entry.browserVersion) {
183182
throw new Error(
184-
"Playwright browsers.json has no 'chromium-headless-shell' entry with revision and browserVersion",
183+
"Playwright browsers.json has no 'chromium-headless-shell' entry with browserVersion",
185184
);
186185
}
187186

188187
return {
189-
revision: entry.revision,
190188
browserVersion: entry.browserVersion,
191189
};
192190
}
193191

194192
/**
195193
* Construct the Playwright CDN download URL for chrome-headless-shell on linux arm64.
196-
* Uses the primary CDN mirror (cdn.playwright.dev).
194+
* Uses the primary CDN mirror (cdn.playwright.dev). Matches Playwright's own
195+
* registry (packages/playwright-core/src/server/registry/index.ts, cftUrl),
196+
* which keys builds by browserVersion under the "cft" path, not the legacy
197+
* revision-keyed "chromium" path.
197198
*/
198-
export function playwrightCdnDownloadUrl(revision: string): string {
199-
return `https://cdn.playwright.dev/builds/chromium/${revision}/chromium-headless-shell-linux-arm64.zip`;
199+
export function playwrightCdnDownloadUrl(browserVersion: string): string {
200+
return `https://cdn.playwright.dev/builds/cft/${browserVersion}/linux-arm64/chrome-headless-shell-linux-arm64.zip`;
200201
}
201202

202203
/**

‎src/tools/impl/chrome-headless-shell-paths.ts‎

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,22 +18,63 @@ import {
1818

1919
const kVersionFileName = "version";
2020

21+
/**
22+
* Binary name used by the Playwright-hosted arm64 archives that predate
23+
* Playwright's move to the browserVersion-keyed builds/cft/ CDN path. Those
24+
* archives shipped Playwright's own package layout — chrome-linux/headless_shell
25+
* — instead of the CfT layout every platform (arm64 included) gets today.
26+
* Installs made in that window are still on disk and must stay detectable.
27+
*/
28+
const kLegacyPlaywrightBinaryName = "headless_shell";
29+
30+
/**
31+
* Whether the legacy Playwright arm64 layout is worth probing for on this host.
32+
* isPlaywrightCdnPlatform() throws on unsupported os/arch combinations, in which
33+
* case no Quarto-installed arm64 binary can exist here anyway.
34+
*/
35+
function hostMayHaveLegacyPlaywrightLayout(): boolean {
36+
try {
37+
return isPlaywrightCdnPlatform();
38+
} catch {
39+
return false;
40+
}
41+
}
42+
2143
/** Return the chrome-headless-shell install directory under quartoDataDir. */
2244
export function chromeHeadlessShellInstallDir(): string {
2345
return quartoDataDir("chrome-headless-shell");
2446
}
2547

2648
/**
27-
* The executable name for chrome-headless-shell on the current platform.
28-
* CfT builds use "chrome-headless-shell", Playwright arm64 builds use "headless_shell".
29-
* Returns the CfT name if platform detection fails (unsupported platform).
49+
* The executable name for chrome-headless-shell.
50+
* The Playwright CDN arm64 mirror redirects to the same chrome-for-testing-public
51+
* bucket used for every other platform, so it shares the "chrome-headless-shell"
52+
* binary name too.
3053
*/
3154
export function chromeHeadlessShellBinaryName(): string {
32-
try {
33-
return isPlaywrightCdnPlatform() ? "headless_shell" : "chrome-headless-shell";
34-
} catch {
35-
return "chrome-headless-shell";
55+
return "chrome-headless-shell";
56+
}
57+
58+
/**
59+
* Locate the chrome-headless-shell binary inside an install directory.
60+
* Prefers the current CfT layout; on arm64 Linux also accepts the legacy
61+
* Playwright layout left behind by pre-CDN-migration installs.
62+
*
63+
* allowLegacyPlaywrightLayout defaults to host detection and exists as an
64+
* explicit parameter so the arm64 branch is reachable from tests on any host.
65+
*/
66+
export function findChromeHeadlessShellExecutable(
67+
dir: string,
68+
allowLegacyPlaywrightLayout: boolean = hostMayHaveLegacyPlaywrightLayout(),
69+
): string | undefined {
70+
const found = findChromeExecutable(dir, chromeHeadlessShellBinaryName());
71+
if (found !== undefined) {
72+
return found;
73+
}
74+
if (allowLegacyPlaywrightLayout) {
75+
return findChromeExecutable(dir, kLegacyPlaywrightBinaryName);
3676
}
77+
return undefined;
3778
}
3879

3980
/**
@@ -45,7 +86,7 @@ export function chromeHeadlessShellExecutablePath(): string | undefined {
4586
if (!existsSync(dir)) {
4687
return undefined;
4788
}
48-
return findChromeExecutable(dir, chromeHeadlessShellBinaryName());
89+
return findChromeHeadlessShellExecutable(dir);
4990
}
5091

5192
/** Record the installed version as a plain text file. */
@@ -64,7 +105,11 @@ export function readInstalledVersion(dir: string): string | undefined {
64105
}
65106

66107
/** Check if chrome-headless-shell is installed in the given directory. */
67-
export function isInstalled(dir: string): boolean {
108+
export function isInstalled(
109+
dir: string,
110+
allowLegacyPlaywrightLayout?: boolean,
111+
): boolean {
68112
return existsSync(join(dir, kVersionFileName)) &&
69-
findChromeExecutable(dir, chromeHeadlessShellBinaryName()) !== undefined;
113+
findChromeHeadlessShellExecutable(dir, allowLegacyPlaywrightLayout) !==
114+
undefined;
70115
}

‎src/tools/impl/chrome-headless-shell.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ async function latestRelease(): Promise<RemotePackageInfo> {
5858
if (isPlaywrightCdnPlatform(platformInfo)) {
5959
// arm64 Linux: use Playwright CDN
6060
const entry = await fetchPlaywrightBrowsersJson();
61-
const url = playwrightCdnDownloadUrl(entry.revision);
61+
const url = playwrightCdnDownloadUrl(entry.browserVersion);
6262
return {
6363
url,
6464
version: entry.browserVersion,

‎tests/unit/tools/chrome-for-testing.test.ts‎

Lines changed: 4 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -130,35 +130,11 @@ unitTest("findChromeExecutable - finds binary in nested structure", async () =>
130130
}
131131
});
132132

133-
// Step 3b: findChromeExecutable() — Playwright arm64 layout
134-
// Skip on Windows: arm64 layout is Linux-only, no .exe extension.
135-
unitTest("findChromeExecutable - finds binary in Playwright arm64 layout", async () => {
136-
if (isWindows) return; // arm64 layout is Linux-only
137-
const tempDir = Deno.makeTempDirSync();
138-
try {
139-
// Playwright arm64 extracts to chrome-linux/headless_shell
140-
const subdir = join(tempDir, "chrome-linux");
141-
Deno.mkdirSync(subdir);
142-
Deno.writeTextFileSync(join(subdir, "headless_shell"), "fake binary");
143-
144-
const found = findChromeExecutable(tempDir, "headless_shell");
145-
assert(found !== undefined, "should find headless_shell in chrome-linux/");
146-
assert(found!.endsWith("headless_shell"), `should end with headless_shell, got: ${found}`);
147-
} finally {
148-
safeRemoveSync(tempDir, { recursive: true });
149-
}
150-
});
151-
152133
// Playwright CDN tests
153134
// Skipped on CI: makes external HTTP calls to GitHub/Playwright CDN.
154135
// Same pattern as CfT API tests above — run locally to catch API contract changes.
155136
unitTest("fetchPlaywrightBrowsersJson - returns chromium-headless-shell entry", async () => {
156137
const entry = await fetchPlaywrightBrowsersJson();
157-
assert(entry.revision, "revision should be non-empty");
158-
assert(
159-
/^\d+$/.test(entry.revision),
160-
`revision should be numeric, got: ${entry.revision}`,
161-
);
162138
assert(entry.browserVersion, "browserVersion should be non-empty");
163139
assert(
164140
/^\d+\.\d+\.\d+\.\d+$/.test(entry.browserVersion),
@@ -167,17 +143,17 @@ unitTest("fetchPlaywrightBrowsersJson - returns chromium-headless-shell entry",
167143
}, { ignore: runningInCI() });
168144

169145
unitTest("playwrightCdnDownloadUrl - constructs correct arm64 URL", async () => {
170-
const url = playwrightCdnDownloadUrl("1219");
146+
const url = playwrightCdnDownloadUrl("121.0.6167.85");
171147
assert(
172148
url.startsWith("https://cdn.playwright.dev/"),
173149
`URL should start with cdn.playwright.dev, got: ${url}`,
174150
);
175151
assert(
176-
url.includes("/builds/chromium/1219/"),
177-
`URL should contain revision path, got: ${url}`,
152+
url.includes("/builds/cft/121.0.6167.85/linux-arm64/"),
153+
`URL should contain browserVersion path, got: ${url}`,
178154
);
179155
assert(
180-
url.endsWith("chromium-headless-shell-linux-arm64.zip"),
156+
url.endsWith("chrome-headless-shell-linux-arm64.zip"),
181157
`URL should end with arm64 zip name, got: ${url}`,
182158
);
183159
});

0 commit comments

Comments
 (0)