Skip to content

Commit b56903b

Browse files
committed
Add Playwright CDN support for arm64 Linux in chrome-for-testing
1 parent cff3319 commit b56903b

File tree

2 files changed

+141
-8
lines changed

2 files changed

+141
-8
lines changed

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

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { InstallContext } from "../types.ts";
1818
/** CfT platform identifiers matching the Google Chrome for Testing API. */
1919
export type CftPlatform =
2020
| "linux64"
21+
| "linux-arm64"
2122
| "mac-arm64"
2223
| "mac-x64"
2324
| "win32"
@@ -37,6 +38,7 @@ export interface PlatformInfo {
3738
export function detectCftPlatform(): PlatformInfo {
3839
const platformMap: Record<string, CftPlatform> = {
3940
"linux-x86_64": "linux64",
41+
"linux-aarch64": "linux-arm64",
4042
"darwin-aarch64": "mac-arm64",
4143
"darwin-x86_64": "mac-x64",
4244
"windows-x86_64": "win64",
@@ -47,14 +49,8 @@ export function detectCftPlatform(): PlatformInfo {
4749
const platform = platformMap[key];
4850

4951
if (!platform) {
50-
if (os === "linux" && arch === "aarch64") {
51-
throw new Error(
52-
"linux-arm64 is not supported by Chrome for Testing. " +
53-
"Use 'quarto install chromium' for arm64 support.",
54-
);
55-
}
5652
throw new Error(
57-
`Unsupported platform for Chrome for Testing: ${os} ${arch}`,
53+
`Unsupported platform for chrome-headless-shell: ${os} ${arch}`,
5854
);
5955
}
6056

@@ -122,6 +118,79 @@ export async function fetchLatestCftRelease(): Promise<CftStableRelease> {
122118
};
123119
}
124120

121+
/** Parsed entry from Playwright's browsers.json for chromium-headless-shell. */
122+
export interface PlaywrightBrowserEntry {
123+
revision: string;
124+
browserVersion: string;
125+
}
126+
127+
const kPlaywrightBrowsersJsonUrl =
128+
"https://raw.githubusercontent.com/microsoft/playwright/main/packages/playwright-core/browsers.json";
129+
130+
/** Check if the current platform requires Playwright CDN (arm64 Linux). */
131+
export function isPlaywrightCdnPlatform(info?: PlatformInfo): boolean {
132+
const p = info ?? detectCftPlatform();
133+
return p.platform === "linux-arm64";
134+
}
135+
136+
/**
137+
* Fetch Playwright's browsers.json and extract the chromium-headless-shell entry.
138+
* Used as the version/revision source for arm64 Linux where CfT has no builds.
139+
*/
140+
export async function fetchPlaywrightBrowsersJson(): Promise<PlaywrightBrowserEntry> {
141+
let response: Response;
142+
const fallbackHint = "\nIf this persists, install a system Chrome/Chromium instead " +
143+
"(Quarto will detect it automatically).";
144+
try {
145+
response = await fetch(kPlaywrightBrowsersJsonUrl);
146+
} catch (e) {
147+
throw new Error(
148+
`Failed to fetch Playwright browsers.json: ${
149+
e instanceof Error ? e.message : String(e)
150+
}${fallbackHint}`,
151+
);
152+
}
153+
154+
if (!response.ok) {
155+
throw new Error(
156+
`Playwright browsers.json returned ${response.status}: ${response.statusText}${fallbackHint}`,
157+
);
158+
}
159+
160+
// deno-lint-ignore no-explicit-any
161+
let data: any;
162+
try {
163+
data = await response.json();
164+
} catch {
165+
throw new Error("Playwright browsers.json returned invalid JSON");
166+
}
167+
168+
const browsers = data?.browsers;
169+
if (!Array.isArray(browsers)) {
170+
throw new Error("Playwright browsers.json missing 'browsers' array");
171+
}
172+
173+
// deno-lint-ignore no-explicit-any
174+
const entry = browsers.find((b: any) => b.name === "chromium-headless-shell");
175+
if (!entry || !entry.revision || !entry.browserVersion) {
176+
throw new Error(
177+
"Playwright browsers.json has no 'chromium-headless-shell' entry with revision and browserVersion",
178+
);
179+
}
180+
181+
return {
182+
revision: entry.revision,
183+
browserVersion: entry.browserVersion,
184+
};
185+
}
186+
187+
/**
188+
* Construct the Playwright CDN download URL for chrome-headless-shell on linux arm64.
189+
*/
190+
export function playwrightCdnDownloadUrl(revision: string): string {
191+
return `https://cdn.playwright.dev/builds/chromium/${revision}/chromium-headless-shell-linux-arm64.zip`;
192+
}
193+
125194
/**
126195
* Find a named executable inside an extracted CfT directory.
127196
* Handles platform-specific naming (.exe on Windows) and nested directory structures.

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

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
// Step 1: detectCftPlatform()
2323
unitTest("detectCftPlatform - returns valid CftPlatform for current system", async () => {
2424
const result = detectCftPlatform();
25-
const validPlatforms = ["linux64", "mac-arm64", "mac-x64", "win32", "win64"];
25+
const validPlatforms = ["linux64", "linux-arm64", "mac-arm64", "mac-x64", "win32", "win64"];
2626
assert(
2727
validPlatforms.includes(result.platform),
2828
`Expected one of ${validPlatforms.join(", ")}, got: ${result.platform}`,
@@ -172,3 +172,67 @@ unitTest(
172172
ignore: runningInCI(),
173173
},
174174
);
175+
176+
// -- Playwright CDN tests (arm64 Linux support) --
177+
178+
import {
179+
isPlaywrightCdnPlatform,
180+
playwrightCdnDownloadUrl,
181+
fetchPlaywrightBrowsersJson,
182+
} from "../../../src/tools/impl/chrome-for-testing.ts";
183+
184+
// isPlaywrightCdnPlatform — pure logic, runs everywhere
185+
unitTest("isPlaywrightCdnPlatform - returns false on non-arm64 platform", async () => {
186+
if (os === "linux" && arch === "aarch64") return; // Skip on actual arm64
187+
const result = isPlaywrightCdnPlatform();
188+
assertEquals(result, false);
189+
});
190+
191+
// playwrightCdnDownloadUrl — pure function, no HTTP
192+
unitTest("playwrightCdnDownloadUrl - constructs correct arm64 URL", async () => {
193+
const url = playwrightCdnDownloadUrl("1219");
194+
assert(
195+
url.startsWith("https://cdn.playwright.dev/"),
196+
`URL should start with cdn.playwright.dev, got: ${url}`,
197+
);
198+
assert(
199+
url.includes("/builds/chromium/1219/"),
200+
`URL should contain revision path, got: ${url}`,
201+
);
202+
assert(
203+
url.endsWith("chromium-headless-shell-linux-arm64.zip"),
204+
`URL should end with arm64 zip name, got: ${url}`,
205+
);
206+
});
207+
208+
// fetchPlaywrightBrowsersJson — external HTTP, skip on CI
209+
unitTest("fetchPlaywrightBrowsersJson - returns chromium-headless-shell entry", async () => {
210+
const entry = await fetchPlaywrightBrowsersJson();
211+
assert(entry.revision, "revision should be non-empty");
212+
assert(
213+
/^\d+$/.test(entry.revision),
214+
`revision should be numeric, got: ${entry.revision}`,
215+
);
216+
assert(entry.browserVersion, "browserVersion should be non-empty");
217+
assert(
218+
/^\d+\.\d+\.\d+\.\d+$/.test(entry.browserVersion),
219+
`browserVersion format wrong: ${entry.browserVersion}`,
220+
);
221+
}, { ignore: runningInCI() });
222+
223+
// findCftExecutable with Playwright arm64 layout (chrome-linux/headless_shell)
224+
unitTest("findCftExecutable - finds binary in Playwright arm64 layout", async () => {
225+
if (isWindows) return; // arm64 layout is Linux-only, no .exe
226+
const tempDir = Deno.makeTempDirSync();
227+
try {
228+
const subdir = join(tempDir, "chrome-linux");
229+
Deno.mkdirSync(subdir);
230+
Deno.writeTextFileSync(join(subdir, "headless_shell"), "fake binary");
231+
232+
const found = findCftExecutable(tempDir, "headless_shell");
233+
assert(found !== undefined, "should find headless_shell in chrome-linux/");
234+
assert(found!.endsWith("headless_shell"), `should end with headless_shell, got: ${found}`);
235+
} finally {
236+
safeRemoveSync(tempDir, { recursive: true });
237+
}
238+
});

0 commit comments

Comments
 (0)