diff --git a/docs/dealing-with-browsers.md b/docs/dealing-with-browsers.md index 0907fe5f2..b96c9ba66 100644 --- a/docs/dealing-with-browsers.md +++ b/docs/dealing-with-browsers.md @@ -56,6 +56,7 @@ export default { chrome: "https://mirror.example/chrome-for-testing", chromium: "https://mirror.example/chromium-browser-snapshots", firefox: "https://mirror.example/firefox", + geckodriver: "https://mirror.example/geckodriver", }, browsers: { @@ -75,6 +76,7 @@ CI can supply or override individual mirrors with uppercase environment variable export TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_CHROME=https://mirror.example/chrome-for-testing export TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_CHROMIUM=https://mirror.example/chromium-browser-snapshots export TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_FIREFOX=https://mirror.example/firefox +export TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_GECKODRIVER=https://mirror.example/geckodriver ``` The uppercase variables take precedence over config values and compatibility variables with lowercase `testplane_` or `hermione_` prefixes. Leave an unused variable unset. An empty value is invalid. @@ -91,6 +93,7 @@ A mirror must preserve the archive layout expected by `@puppeteer/browsers` for - Chrome, Chrome Headless Shell, and ChromeDriver archives use Chrome for Testing paths such as `//chrome-.zip`, `//chrome-headless-shell-.zip`, and `//chromedriver-.zip`. - The Chromium mirror serves snapshot archives under paths such as `//.zip`. - The Firefox mirror serves `firefox_versions.json` at its root. Release archives use paths such as `//en-US/`. +- The GeckoDriver mirror serves `Cargo.toml` at its root for latest-version discovery. Archives use paths such as `v/geckodriver-v-linux64.tar.gz` for Linux x64, with upstream platform names and ZIP archives for Windows. A mirror containing only Linux x64 archives cannot serve other platforms. Unix hosts need `tar` on `PATH` to extract mirrored GeckoDriver archives. For Chrome, an explicit `browserVersion: "latest"` follows `@puppeteer/browsers` and selects Canary (`LATEST_RELEASE_CANARY`), not the latest Stable release. Use `"stable"` (`LATEST_RELEASE_STABLE`) to check the current Stable release. If `browserVersion` is omitted, Testplane can reuse an already installed Chrome version without checking whether a newer Stable release exists; it queries Stable only when no suitable local browser is found. @@ -102,7 +105,7 @@ The mirror keys cover different download sources: - Chrome versions earlier than 113 are installed from Chromium snapshots and therefore use the `chromium` mirror. - ChromeDriver versions earlier than 115 continue to use the legacy `chromedriver.storage.googleapis.com` source. -- GeckoDriver is not downloaded from the Firefox mirror. It continues to use its Mozilla/GitHub upstream source. +- GeckoDriver uses the separate `geckodriver` mirror for both version discovery and archive downloads. Without it, GeckoDriver keeps its Mozilla/GitHub upstream source, independently of the Firefox mirror. When a mirror covers a requested metadata file or archive, Testplane does not fall back to the public upstream. A missing platform archive, unavailable metadata file, or network failure stops installation with an error. diff --git a/src/browser-installer/firefox/browser.ts b/src/browser-installer/firefox/browser.ts index 06ebae56d..537d96f57 100644 --- a/src/browser-installer/firefox/browser.ts +++ b/src/browser-installer/firefox/browser.ts @@ -97,7 +97,7 @@ export const installFirefox = async ( ): Promise => { const [browserPath] = await Promise.all([ installFirefoxBrowser(version, { force, browserDownloadMirrors }), - needWebDriver && installLatestGeckoDriver(version, { force }), + needWebDriver && installLatestGeckoDriver(version, { force, browserDownloadMirrors }), needUbuntuPackages && installUbuntuPackageDependencies(), ]); diff --git a/src/browser-installer/firefox/driver.ts b/src/browser-installer/firefox/driver.ts index 0dcbbc2a5..1e6cb68f9 100644 --- a/src/browser-installer/firefox/driver.ts +++ b/src/browser-installer/firefox/driver.ts @@ -1,24 +1,104 @@ import { download as downloadGeckoDriver } from "geckodriver"; +import fs from "fs-extra"; +import path from "path"; +import { execFile } from "child_process"; +import { promisify } from "util"; +import { Readable } from "stream"; +import { pipeline } from "stream/promises"; +import { BrowserPlatform } from "@puppeteer/browsers"; import { GECKODRIVER_CARGO_TOML } from "../constants"; import registry from "../registry"; -import { DriverName, browserInstallerDebug, getBrowserPlatform, getGeckoDriverDir, retryFetch } from "../utils"; +import { + DriverName, + browserInstallerDebug, + getBrowserPlatform, + getGeckoDriverDir, + retryFetch, + unzipFile, +} from "../utils"; +import type { BrowserDownloadMirrors } from "../../config/types"; +import { getBrowserDownloadMirror, getBrowserDownloadMirrorFileUrl } from "../mirrors"; -const getLatestGeckoDriverVersion = async (): Promise => { - const cargoVersionsToml = await retryFetch(GECKODRIVER_CARGO_TOML).then(res => res.text()); - const version = cargoVersionsToml.split("\n").find(line => line.startsWith("version = ")); +const execFileAsync = promisify(execFile); +const GECKODRIVER_PLATFORMS = { + [BrowserPlatform.LINUX]: "linux64", + [BrowserPlatform.LINUX_ARM]: "linux-aarch64", + [BrowserPlatform.MAC]: "macos", + [BrowserPlatform.MAC_ARM]: "macos-aarch64", + [BrowserPlatform.WIN32]: "win32", + [BrowserPlatform.WIN64]: "win64", +}; + +const getLatestGeckoDriverVersion = async (mirror?: string): Promise => { + const url = mirror ? getBrowserDownloadMirrorFileUrl(mirror, "Cargo.toml") : GECKODRIVER_CARGO_TOML; + const response = await retryFetch(url); + + if (!response.ok) { + throw new Error(`Couldn't resolve latest geckodriver version from ${url}: ${response.status}`); + } + + const cargoVersionsToml = await response.text(); + const versionLine = cargoVersionsToml.split("\n").find(line => /^version\s*=/.test(line)); + const version = versionLine?.match(/^version\s*=\s*["'](\d+\.\d+\.\d+)["']/); if (!version) { throw new Error("Couldn't resolve latest geckodriver version while downloading geckodriver"); } - const latestGeckoVersion = version.split(" = ").pop()!.slice(1, -1); + const latestGeckoVersion = version[1]; browserInstallerDebug(`resolved latest geckodriver version: ${latestGeckoVersion}`); return latestGeckoVersion; }; -export const installLatestGeckoDriver = async (firefoxVersion: string, { force = false } = {}): Promise => { +const downloadMirroredGeckoDriver = async ( + version: string, + platform: BrowserPlatform, + mirror: string, +): Promise => { + const isWindows = platform === BrowserPlatform.WIN32 || platform === BrowserPlatform.WIN64; + const filename = `geckodriver-v${version}-${GECKODRIVER_PLATFORMS[platform]}${isWindows ? ".zip" : ".tar.gz"}`; + const url = getBrowserDownloadMirrorFileUrl(mirror, `v${version}/${filename}`); + + browserInstallerDebug(`downloading ${DriverName.GECKODRIVER}@${version} from mirror ${mirror}`); + + const response = await retryFetch(url); + + if (!response.ok || !response.body) { + throw new Error(`Unable to download geckodriver from ${url}: ${response.status}`); + } + + const driverDir = getGeckoDriverDir(version); + await fs.ensureDir(driverDir); + const downloadDir = await fs.mkdtemp(path.join(driverDir, "download-")); + + try { + const archivePath = path.join(downloadDir, filename); + await pipeline(Readable.fromWeb(response.body as never), fs.createWriteStream(archivePath)); + + if (isWindows) { + await unzipFile(archivePath, driverDir); + } else { + await execFileAsync("tar", ["-xzf", archivePath, "-C", driverDir]); + } + + const binaryPath = path.join(driverDir, isWindows ? "geckodriver.exe" : "geckodriver"); + await fs.chmod(binaryPath, 0o755); + + return binaryPath; + } finally { + await fs.remove(downloadDir); + } +}; + +export const installLatestGeckoDriver = async ( + firefoxVersion: string, + { + force = false, + browserDownloadMirrors, + }: { force?: boolean; browserDownloadMirrors?: BrowserDownloadMirrors } = {}, +): Promise => { const platform = getBrowserPlatform(); const existingLocallyDriverVersion = registry.getMatchedDriverVersion( DriverName.GECKODRIVER, @@ -34,9 +114,13 @@ export const installLatestGeckoDriver = async (firefoxVersion: string, { force = return registry.getBinaryPath(DriverName.GECKODRIVER, platform, existingLocallyDriverVersion); } - const latestVersion = await getLatestGeckoDriverVersion(); + const mirror = getBrowserDownloadMirror(DriverName.GECKODRIVER, browserDownloadMirrors); + const latestVersion = await getLatestGeckoDriverVersion(mirror); - const installFn = (): Promise => downloadGeckoDriver(latestVersion, getGeckoDriverDir(latestVersion)); + const installFn = (): Promise => + mirror + ? downloadMirroredGeckoDriver(latestVersion, platform, mirror) + : downloadGeckoDriver(latestVersion, getGeckoDriverDir(latestVersion)); return registry.installBinary(DriverName.GECKODRIVER, platform, latestVersion, installFn); }; diff --git a/src/browser-installer/firefox/index.ts b/src/browser-installer/firefox/index.ts index 5dd9b11be..2f96e64db 100644 --- a/src/browser-installer/firefox/index.ts +++ b/src/browser-installer/firefox/index.ts @@ -8,15 +8,19 @@ import { pipeLogsWithPrefix } from "../../dev-server/utils"; import { DRIVER_WAIT_INTERVAL, DRIVER_WAIT_TIMEOUT } from "../constants"; import { getUbuntuLinkerEnv, isUbuntu } from "../ubuntu-packages"; import RuntimeConfig from "../../config/runtime-config"; +import type { BrowserDownloadMirrors } from "../../config/types"; export { installFirefox, resolveLatestFirefoxVersion, installLatestGeckoDriver }; export const runGeckoDriver = async ( firefoxVersion: string, - { debug = false } = {}, + { + debug = false, + browserDownloadMirrors, + }: { debug?: boolean; browserDownloadMirrors?: BrowserDownloadMirrors } = {}, ): Promise<{ gridUrl: string; process: ChildProcess; port: number; kill: () => void }> => { const [geckoDriverPath, randomPort, geckoDriverEnv] = await Promise.all([ - installLatestGeckoDriver(firefoxVersion), + installLatestGeckoDriver(firefoxVersion, { browserDownloadMirrors }), getPort(), isUbuntu() .then(isUbuntu => (isUbuntu ? getUbuntuLinkerEnv() : null)) diff --git a/src/browser-installer/mirrors.ts b/src/browser-installer/mirrors.ts index dad2bb97e..f95400ab5 100644 --- a/src/browser-installer/mirrors.ts +++ b/src/browser-installer/mirrors.ts @@ -2,7 +2,7 @@ import { URL } from "node:url"; import type { BrowserDownloadMirrors } from "../config/types"; import { BrowserName } from "../browser/types"; -import type { SupportedBrowser } from "./utils"; +import { DriverName, type SupportedBrowser } from "./utils"; export const normalizeBrowserDownloadMirror = (mirror: unknown, optionName: string): string => { if (typeof mirror !== "string") { @@ -43,7 +43,7 @@ export const normalizeBrowserDownloadMirror = (mirror: unknown, optionName: stri }; export const getBrowserDownloadMirror = ( - browserName: SupportedBrowser, + browserName: SupportedBrowser | typeof DriverName.GECKODRIVER, mirrors?: BrowserDownloadMirrors, ): string | undefined => { let mirrorName: keyof BrowserDownloadMirrors; @@ -59,6 +59,9 @@ export const getBrowserDownloadMirror = ( case BrowserName.FIREFOX: mirrorName = "firefox"; break; + case DriverName.GECKODRIVER: + mirrorName = "geckodriver"; + break; default: return undefined; } diff --git a/src/browser-installer/run.ts b/src/browser-installer/run.ts index 8b9ba6784..ff349e851 100644 --- a/src/browser-installer/run.ts +++ b/src/browser-installer/run.ts @@ -28,7 +28,9 @@ export const runBrowserDriver = async ( module.runChromeDriver(browserVersion, { debug, browserDownloadMirrors }), ); case BrowserName.FIREFOX: - return import("./firefox").then(module => module.runGeckoDriver(browserVersion, { debug })); + return import("./firefox").then(module => + module.runGeckoDriver(browserVersion, { debug, browserDownloadMirrors }), + ); case BrowserName.EDGE: return import("./edge").then(module => module.runEdgeDriver(browserVersion, { debug })); case BrowserName.SAFARI: diff --git a/src/config/defaults.js b/src/config/defaults.js index 5bc8c8316..f05d28760 100644 --- a/src/config/defaults.js +++ b/src/config/defaults.js @@ -10,6 +10,7 @@ module.exports = { chrome: null, chromium: null, firefox: null, + geckodriver: null, }, gridUrl: LOCAL_GRID_URL, browserWSEndpoint: null, diff --git a/src/config/options.js b/src/config/options.js index 35476472f..4b099ec5f 100644 --- a/src/config/options.js +++ b/src/config/options.js @@ -30,6 +30,7 @@ const rootSection = section( chrome: browserDownloadMirrorOption("chrome"), chromium: browserDownloadMirrorOption("chromium"), firefox: browserDownloadMirrorOption("firefox"), + geckodriver: browserDownloadMirrorOption("geckodriver"), }), prepareEnvironment: options.optionalFunction("prepareEnvironment"), diff --git a/src/config/types.ts b/src/config/types.ts index 471335ad1..46b2caf37 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -369,6 +369,7 @@ export interface BrowserDownloadMirrors { chrome: string | null; chromium: string | null; firefox: string | null; + geckodriver: string | null; } export type BrowserDownloadMirrorsInput = Partial; diff --git a/src/constants/config.ts b/src/constants/config.ts index e51608129..f85ecae75 100644 --- a/src/constants/config.ts +++ b/src/constants/config.ts @@ -6,6 +6,7 @@ export const BROWSER_DOWNLOAD_MIRRORS_ENV_VARS = { chrome: "TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_CHROME", chromium: "TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_CHROMIUM", firefox: "TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_FIREFOX", + geckodriver: "TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_GECKODRIVER", } as const; export const WEBDRIVER_PROTOCOL = "webdriver"; diff --git a/test/src/browser-installer/browser-version-contract.ts b/test/src/browser-installer/browser-version-contract.ts index cd29fdb9e..e6f2384bd 100644 --- a/test/src/browser-installer/browser-version-contract.ts +++ b/test/src/browser-installer/browser-version-contract.ts @@ -149,7 +149,7 @@ describe("browser-installer numeric browser version contract", () => { const requestOffset = requests.length; const installedPath = await createInstallBrowser(cacheDir)(BrowserName.CHROME, version, { force: true, - browserDownloadMirrors: { chrome: mirror, chromium: null, firefox: null }, + browserDownloadMirrors: { chrome: mirror, chromium: null, firefox: null, geckodriver: null }, }); if (!installedPath) { diff --git a/test/src/browser-installer/chrome/browser.ts b/test/src/browser-installer/chrome/browser.ts index f98bbf82b..f5c94c0ab 100644 --- a/test/src/browser-installer/chrome/browser.ts +++ b/test/src/browser-installer/chrome/browser.ts @@ -12,6 +12,7 @@ describe("browser-installer/chrome/browser", () => { chrome: "https://mirror.example/chrome", chromium: "https://mirror.example/chromium", firefox: null, + geckodriver: null, }; let installChrome: typeof InstallChromeType; diff --git a/test/src/browser-installer/chrome/driver.ts b/test/src/browser-installer/chrome/driver.ts index 77f6ebf20..88eb959e4 100644 --- a/test/src/browser-installer/chrome/driver.ts +++ b/test/src/browser-installer/chrome/driver.ts @@ -9,6 +9,7 @@ describe("browser-installer/chrome/driver", () => { chrome: "https://mirror.example/chrome", chromium: null, firefox: null, + geckodriver: null, }; let installChromeDriver: typeof InstallChromeDriverType; diff --git a/test/src/browser-installer/chrome/index.ts b/test/src/browser-installer/chrome/index.ts index 116926bbd..843350f12 100644 --- a/test/src/browser-installer/chrome/index.ts +++ b/test/src/browser-installer/chrome/index.ts @@ -9,6 +9,7 @@ describe("browser-installer/chrome", () => { chrome: "https://mirror.example/chrome", chromium: null, firefox: null, + geckodriver: null, }; let runChromeDriver: typeof RunChromeDriverType; @@ -74,7 +75,7 @@ describe("browser-installer/chrome", () => { it("should pass the mirror map when Chrome mirror is not configured", async () => { sandbox.stub(process, "once"); - const emptyBrowserDownloadMirrors = { chrome: null, chromium: null, firefox: null }; + const emptyBrowserDownloadMirrors = { chrome: null, chromium: null, firefox: null, geckodriver: null }; await runChromeDriver("130", { browserDownloadMirrors: emptyBrowserDownloadMirrors, diff --git a/test/src/browser-installer/chromium/browser.ts b/test/src/browser-installer/chromium/browser.ts index 31f93bd39..7fa19677a 100644 --- a/test/src/browser-installer/chromium/browser.ts +++ b/test/src/browser-installer/chromium/browser.ts @@ -9,6 +9,7 @@ describe("browser-installer/chromium/browser", () => { chrome: null, chromium: "https://mirror.example/chromium", firefox: null, + geckodriver: null, }; let installChromium: typeof InstallChromiumType; diff --git a/test/src/browser-installer/firefox/browser.ts b/test/src/browser-installer/firefox/browser.ts index fa584ff49..a558350ec 100644 --- a/test/src/browser-installer/firefox/browser.ts +++ b/test/src/browser-installer/firefox/browser.ts @@ -12,6 +12,7 @@ describe("browser-installer/firefox/browser", () => { chrome: null, chromium: null, firefox: "https://mirror.example/firefox", + geckodriver: "https://mirror.example/geckodriver", }; let installFirefox: typeof InstallFirefoxType; @@ -176,7 +177,16 @@ describe("browser-installer/firefox/browser", () => { it("should try to install geckodriver if 'needWebDriver' is set", async () => { await installFirefox("115", { needWebDriver: true }); - assert.calledOnceWith(installLatestGeckoDriverStub, "115", { force: false }); + assert.calledOnceWith(installLatestGeckoDriverStub, "115", { + force: false, + browserDownloadMirrors: undefined, + }); + }); + + it("should pass mirrors to GeckoDriver installation", async () => { + await installFirefox("115", { needWebDriver: true, browserDownloadMirrors }); + + assert.calledOnceWithExactly(installLatestGeckoDriverStub, "115", { force: false, browserDownloadMirrors }); }); it("should try to install ubuntu dependencies if 'needWebDriver' is set", async () => { diff --git a/test/src/browser-installer/firefox/driver.ts b/test/src/browser-installer/firefox/driver.ts index 787c260f6..16ae23766 100644 --- a/test/src/browser-installer/firefox/driver.ts +++ b/test/src/browser-installer/firefox/driver.ts @@ -1,10 +1,17 @@ import proxyquire from "proxyquire"; import sinon, { type SinonStub } from "sinon"; +import { BrowserPlatform } from "@puppeteer/browsers"; import type { installLatestGeckoDriver as InstallLatestGeckoDriverType } from "../../../../src/browser-installer/firefox/driver"; import { DriverName } from "../../../../src/browser-installer/utils"; describe("browser-installer/firefox/driver", () => { const sandbox = sinon.createSandbox(); + const browserDownloadMirrors = { + chrome: null, + chromium: null, + firefox: "https://mirror.example/firefox", + geckodriver: "https://mirror.example/cache/geckodriver", + }; let installLatestGeckoDriver: typeof InstallLatestGeckoDriverType; @@ -13,6 +20,12 @@ describe("browser-installer/firefox/driver", () => { let getBinaryPathStub: SinonStub; let getMatchedDriverVersionStub: SinonStub; let installBinaryStub: SinonStub; + let getBrowserPlatformStub: SinonStub; + let pipelineStub: SinonStub; + let execFileStub: SinonStub; + let unzipFileStub: SinonStub; + let removeStub: SinonStub; + let chmodStub: SinonStub; beforeEach(() => { downloadGeckoDriverStub = sandbox.stub().resolves("/binary/path"); @@ -20,12 +33,30 @@ describe("browser-installer/firefox/driver", () => { getBinaryPathStub = sandbox.stub().resolves("/binary/path"); getMatchedDriverVersionStub = sandbox.stub().returns(null); installBinaryStub = sandbox.stub(); + getBrowserPlatformStub = sandbox.stub().returns(BrowserPlatform.LINUX); + pipelineStub = sandbox.stub().resolves(); + execFileStub = sandbox.stub().callsFake((_command, _args, callback) => callback(null, "", "")); + unzipFileStub = sandbox.stub().resolves(); + removeStub = sandbox.stub().resolves(); + chmodStub = sandbox.stub().resolves(); installLatestGeckoDriver = proxyquire("../../../../src/browser-installer/firefox/driver", { geckodriver: { download: downloadGeckoDriverStub }, + "stream/promises": { pipeline: pipelineStub }, + child_process: { execFile: execFileStub }, // eslint-disable-line camelcase + "fs-extra": { + ensureDir: sandbox.stub().resolves(), + mkdtemp: sandbox.stub().resolves("/download/dir"), + createWriteStream: sandbox.stub().returns({}), + chmod: chmodStub, + remove: removeStub, + }, "../utils": { ...require("../../../../src/browser-installer/utils"), retryFetch: retryFetchStub, + getBrowserPlatform: getBrowserPlatformStub, + getGeckoDriverDir: sandbox.stub().returns("/driver/dir"), + unzipFile: unzipFileStub, }, "../registry": { default: { @@ -53,6 +84,7 @@ describe("browser-installer/firefox/driver", () => { it("should not try to resolve driver path locally with 'force' flag", async () => { getMatchedDriverVersionStub.withArgs(DriverName.GECKODRIVER, sinon.match.string, "115").returns("115.0"); retryFetchStub.withArgs("https://raw.githubusercontent.com/mozilla/geckodriver/release/Cargo.toml").resolves({ + ok: true, text: () => Promise.resolve("version = '0.35.0'"), }); installBinaryStub @@ -68,6 +100,7 @@ describe("browser-installer/firefox/driver", () => { it("should download driver if it is not downloaded", async () => { getMatchedDriverVersionStub.withArgs(DriverName.GECKODRIVER, sinon.match.string, "115").returns(null); retryFetchStub.withArgs("https://raw.githubusercontent.com/mozilla/geckodriver/release/Cargo.toml").resolves({ + ok: true, text: () => Promise.resolve("version = '0.35.0'"), }); installBinaryStub @@ -78,4 +111,138 @@ describe("browser-installer/firefox/driver", () => { assert.equal(driverPath, "/new/downloaded/driver/path"); }); + + it("should keep the upstream downloader when only a Firefox mirror is set", async () => { + retryFetchStub.resolves(new Response('version = "0.35.0"')); + installBinaryStub.callsFake((_name, _platform, _version, installFn) => installFn()); + + await installLatestGeckoDriver("115", { + browserDownloadMirrors: { ...browserDownloadMirrors, geckodriver: null }, + }); + + assert.calledOnceWithExactly( + retryFetchStub, + "https://raw.githubusercontent.com/mozilla/geckodriver/release/Cargo.toml", + ); + assert.calledOnceWithExactly(downloadGeckoDriverStub, "0.35.0", "/driver/dir"); + }); + + it("should reuse an installed driver without accessing the mirror", async () => { + getMatchedDriverVersionStub.returns("0.35.0"); + + await installLatestGeckoDriver("115", { browserDownloadMirrors }); + + assert.notCalled(retryFetchStub); + assert.notCalled(downloadGeckoDriverStub); + }); + + [ + [BrowserPlatform.LINUX, "linux64", ".tar.gz"], + [BrowserPlatform.LINUX_ARM, "linux-aarch64", ".tar.gz"], + [BrowserPlatform.MAC, "macos", ".tar.gz"], + [BrowserPlatform.MAC_ARM, "macos-aarch64", ".tar.gz"], + [BrowserPlatform.WIN32, "win32", ".zip"], + [BrowserPlatform.WIN64, "win64", ".zip"], + ].forEach(([platform, archivePlatform, extension]) => { + it(`should download metadata and archive from the same mirror for ${platform}`, async () => { + getBrowserPlatformStub.returns(platform); + retryFetchStub.resolves(new Response("archive")); + retryFetchStub + .withArgs(`${browserDownloadMirrors.geckodriver}/Cargo.toml`) + .resolves( + new Response('[package]\r\nversion = "0.37.1"\r\n[dependencies.helper]\r\nversion = "1.2.3"\r\n'), + ); + installBinaryStub.callsFake((_name, _platform, _version, installFn) => installFn()); + + const driverPath = await installLatestGeckoDriver("115", { browserDownloadMirrors }); + + const filename = `geckodriver-v0.37.1-${archivePlatform}${extension}`; + assert.calledWithExactly(retryFetchStub, `${browserDownloadMirrors.geckodriver}/v0.37.1/${filename}`); + assert.callCount(retryFetchStub, 2); + assert.calledOnce(pipelineStub); + assert.calledOnceWithExactly(removeStub, "/download/dir"); + assert.calledOnceWithExactly(chmodStub, driverPath, 0o755); + assert.notCalled(downloadGeckoDriverStub); + + if (extension === ".zip") { + assert.equal(driverPath, "/driver/dir/geckodriver.exe"); + assert.calledOnceWithExactly(unzipFileStub, `/download/dir/${filename}`, "/driver/dir"); + assert.notCalled(execFileStub); + } else { + assert.equal(driverPath, "/driver/dir/geckodriver"); + assert.calledOnceWith(execFileStub, "tar", ["-xzf", `/download/dir/${filename}`, "-C", "/driver/dir"]); + assert.notCalled(unzipFileStub); + } + }); + }); + + [ + "", + 'version = ""', + 'version = "../other"', + 'version = "not-a-version"', + '[package]\nversion = "invalid"\n[dependencies.helper]\nversion = "0.37.1"', + ].forEach(toml => { + it(`should reject malformed mirror metadata ${JSON.stringify(toml)} without upstream fallback`, async () => { + retryFetchStub.resolves(new Response(toml)); + + await assert.isRejected( + installLatestGeckoDriver("115", { browserDownloadMirrors }), + "Couldn't resolve latest geckodriver version", + ); + + assert.calledOnceWithExactly(retryFetchStub, `${browserDownloadMirrors.geckodriver}/Cargo.toml`); + assert.notCalled(installBinaryStub); + assert.notCalled(downloadGeckoDriverStub); + }); + }); + + it("should reject an HTTP error in metadata even if its body contains a version", async () => { + retryFetchStub.resolves(new Response('version = "0.37.1"', { status: 404 })); + + await assert.isRejected(installLatestGeckoDriver("115", { browserDownloadMirrors }), "404"); + + assert.notCalled(installBinaryStub); + }); + + it("should preserve network errors without trying the upstream", async () => { + const error = new Error("network unavailable"); + retryFetchStub.rejects(error); + + assert.strictEqual( + await installLatestGeckoDriver("115", { browserDownloadMirrors }).catch(error => error), + error, + ); + assert.calledOnceWithExactly(retryFetchStub, `${browserDownloadMirrors.geckodriver}/Cargo.toml`); + assert.notCalled(downloadGeckoDriverStub); + }); + + it("should reject missing mirror archives without trying the upstream", async () => { + retryFetchStub.resolves(new Response("not found", { status: 404 })); + retryFetchStub + .withArgs(`${browserDownloadMirrors.geckodriver}/Cargo.toml`) + .resolves(new Response('version = "0.37.1"')); + installBinaryStub.callsFake((_name, _platform, _version, installFn) => installFn()); + + await assert.isRejected( + installLatestGeckoDriver("115", { browserDownloadMirrors }), + "Unable to download geckodriver", + ); + + assert.callCount(retryFetchStub, 2); + assert.notCalled(pipelineStub); + assert.notCalled(downloadGeckoDriverStub); + }); + + it("should remove temporary archives when extraction fails", async () => { + retryFetchStub.callsFake(() => Promise.resolve(new Response('version = "0.37.1"'))); + installBinaryStub.callsFake((_name, _platform, _version, installFn) => installFn()); + execFileStub.callsFake((_command, _args, callback) => callback(new Error("bad archive"))); + + await assert.isRejected(installLatestGeckoDriver("115", { browserDownloadMirrors }), "bad archive"); + + assert.calledOnceWithExactly(removeStub, "/download/dir"); + assert.notCalled(chmodStub); + assert.notCalled(downloadGeckoDriverStub); + }); }); diff --git a/test/src/browser-installer/firefox/index.ts b/test/src/browser-installer/firefox/index.ts index ad85b9a16..e956bad02 100644 --- a/test/src/browser-installer/firefox/index.ts +++ b/test/src/browser-installer/firefox/index.ts @@ -75,6 +75,19 @@ describe("browser-installer/firefox", () => { assert.calledOnceWith(waitPortStub, { port: 10050, output: "silent", timeout: 10000, interval: 25 }); }); + it("should pass mirrors to GeckoDriver installation before launch", async () => { + const browserDownloadMirrors = { + chrome: null, + chromium: null, + firefox: null, + geckodriver: "https://mirror.example/geckodriver", + }; + + await runGeckoDriver("130", { browserDownloadMirrors }); + + assert.calledOnceWithExactly(installLatestGeckoDriverStub, "130", { browserDownloadMirrors }); + }); + it("should be executed in right order", async () => { await runGeckoDriver("130"); diff --git a/test/src/browser-installer/geckodriver-mirror-contract.ts b/test/src/browser-installer/geckodriver-mirror-contract.ts new file mode 100644 index 000000000..21e8b1b0a --- /dev/null +++ b/test/src/browser-installer/geckodriver-mirror-contract.ts @@ -0,0 +1,135 @@ +import { BrowserPlatform } from "@puppeteer/browsers"; +import { execFile } from "child_process"; +import fs from "fs-extra"; +import http from "http"; +import os from "os"; +import path from "path"; +import { promisify } from "util"; +import type { AddressInfo } from "net"; +import proxyquire from "proxyquire"; +import sinon from "sinon"; +import type { installLatestGeckoDriver as InstallLatestGeckoDriver } from "../../../src/browser-installer/firefox/driver"; + +const execFileAsync = promisify(execFile); + +describe("browser-installer GeckoDriver mirror contract", () => { + const sandbox = sinon.createSandbox(); + let tempDir: string; + let server: http.Server; + let mirror: string; + let archive: Buffer; + let metadataStatus: number; + let archiveStatus: number; + let installLatestGeckoDriver: typeof InstallLatestGeckoDriver; + let upstreamDownloadStub: sinon.SinonStub; + const requests: string[] = []; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "testplane-geckodriver-mirror-")); + await fs.writeFile(path.join(tempDir, "geckodriver"), "mirrored driver"); + const archivePath = path.join(tempDir, "driver.tar.gz"); + await execFileAsync("tar", ["-czf", archivePath, "-C", tempDir, "geckodriver"]); + archive = await fs.readFile(archivePath); + metadataStatus = 200; + archiveStatus = 200; + requests.length = 0; + + server = http.createServer((request, response) => { + requests.push(request.url!); + + if (request.url === "/nested/geckodriver/Cargo.toml") { + response.writeHead(metadataStatus); + response.end('[package]\nversion = "0.37.1"\n'); + } else if (request.url === "/nested/geckodriver/v0.37.1/geckodriver-v0.37.1-linux64.tar.gz") { + response.writeHead(archiveStatus); + response.end(archive); + } else { + response.writeHead(404); + response.end(); + } + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + mirror = `http://127.0.0.1:${(server.address() as AddressInfo).port}/nested/geckodriver`; + const originalFetch = globalThis.fetch; + sandbox.stub(globalThis, "fetch").callsFake((input, init) => { + if (!String(input).startsWith(`${mirror}/`)) { + throw new Error(`Unexpected upstream request: ${input}`); + } + + return originalFetch(input, init); + }); + upstreamDownloadStub = sandbox.stub().rejects(new Error("Unexpected upstream download")); + installLatestGeckoDriver = proxyquire("../../../src/browser-installer/firefox/driver", { + geckodriver: { download: upstreamDownloadStub }, + "../utils": { + getBrowserPlatform: () => BrowserPlatform.LINUX, + getGeckoDriverDir: () => path.join(tempDir, "installed"), + }, + "../registry": { + default: { + getMatchedDriverVersion: () => null, + installBinary: ( + _name: string, + _platform: string, + _version: string, + installFn: () => Promise, + ) => installFn(), + }, + }, + }).installLatestGeckoDriver; + }); + + afterEach(async () => { + sandbox.restore(); + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + await fs.remove(tempDir); + }); + + const install = (): Promise => + installLatestGeckoDriver("130", { + browserDownloadMirrors: { chrome: null, chromium: null, firefox: null, geckodriver: mirror }, + }); + + it("should fetch mirrored Cargo.toml, extract the Linux x64 archive and make the binary executable", async () => { + const binaryPath = await install(); + + assert.equal(await fs.readFile(binaryPath, "utf8"), "mirrored driver"); + if (process.platform !== "win32") { + assert.equal((await fs.stat(binaryPath)).mode % 0o1000, 0o755); + } + assert.deepEqual(await fs.readdir(path.dirname(binaryPath)), ["geckodriver"]); + assert.deepEqual(requests, [ + "/nested/geckodriver/Cargo.toml", + "/nested/geckodriver/v0.37.1/geckodriver-v0.37.1-linux64.tar.gz", + ]); + assert.notCalled(upstreamDownloadStub); + }); + + it("should stop at a metadata HTTP failure without requesting an archive or upstream", async () => { + metadataStatus = 404; + + await assert.isRejected(install(), "404"); + + assert.deepEqual(requests, ["/nested/geckodriver/Cargo.toml"]); + assert.notCalled(upstreamDownloadStub); + }); + + it("should not fall back to upstream when the archive is missing", async () => { + archiveStatus = 404; + + await assert.isRejected(install(), "Unable to download geckodriver"); + + assert.lengthOf(requests, 2); + assert.notCalled(upstreamDownloadStub); + }); + + it("should clean temporary files and reject a corrupt archive", async () => { + archive = Buffer.from("not a gzip archive"); + + await assert.isRejected(install()); + + assert.isEmpty(await fs.readdir(path.join(tempDir, "installed"))); + assert.notCalled(upstreamDownloadStub); + }); +}); diff --git a/test/src/browser-installer/install.ts b/test/src/browser-installer/install.ts index 98b3bbe94..b4457d262 100644 --- a/test/src/browser-installer/install.ts +++ b/test/src/browser-installer/install.ts @@ -12,6 +12,7 @@ describe("browser-installer/install", () => { chrome: "https://mirror.example/chrome", chromium: "https://mirror.example/chromium", firefox: "https://mirror.example/firefox", + geckodriver: null, }; let installBrowser: typeof InstallBrowser; diff --git a/test/src/browser-installer/mirror-download-contract.ts b/test/src/browser-installer/mirror-download-contract.ts index ce11a4913..72279a4e6 100644 --- a/test/src/browser-installer/mirror-download-contract.ts +++ b/test/src/browser-installer/mirror-download-contract.ts @@ -206,6 +206,7 @@ describe("browser-installer mirror download contract", () => { chrome: mirror, chromium: null, firefox: null, + geckodriver: null, }, }).catch(error => error as Error); const downloadResults = await Promise.allSettled(downloadPromises); diff --git a/test/src/browser-installer/mirrors.ts b/test/src/browser-installer/mirrors.ts index b5ebdb6cc..a370b728b 100644 --- a/test/src/browser-installer/mirrors.ts +++ b/test/src/browser-installer/mirrors.ts @@ -1,4 +1,5 @@ import { BrowserName } from "../../../src/browser/types"; +import { DriverName } from "../../../src/browser-installer/utils"; import { getBrowserDownloadMirror, getBrowserDownloadMirrorFileUrl } from "../../../src/browser-installer/mirrors"; describe("browser-installer/mirrors", () => { @@ -6,6 +7,7 @@ describe("browser-installer/mirrors", () => { chrome: "https://mirror.example/chrome/", chromium: "https://mirror.example/chromium/", firefox: "https://mirror.example/firefox/", + geckodriver: "https://mirror.example/geckodriver/", }; it("should resolve mirror for each downloadable browser", () => { @@ -16,6 +18,7 @@ describe("browser-installer/mirrors", () => { ); assert.equal(getBrowserDownloadMirror(BrowserName.CHROMIUM, mirrors), "https://mirror.example/chromium"); assert.equal(getBrowserDownloadMirror(BrowserName.FIREFOX, mirrors), "https://mirror.example/firefox"); + assert.equal(getBrowserDownloadMirror(DriverName.GECKODRIVER, mirrors), "https://mirror.example/geckodriver"); }); it("should not resolve mirrors for browsers that Testplane does not download", () => { diff --git a/test/src/browser-installer/run.ts b/test/src/browser-installer/run.ts index a995906d8..e8a3a3ee5 100644 --- a/test/src/browser-installer/run.ts +++ b/test/src/browser-installer/run.ts @@ -9,6 +9,7 @@ describe("browser-installer/run", () => { chrome: "https://mirror.example/chrome", chromium: null, firefox: null, + geckodriver: null, }; let runBrowserDriver: typeof RunBrowserDriver; @@ -102,4 +103,15 @@ describe("browser-installer/run", () => { }); assert.callOrder(installBrowserStub, runGeckoDriverStub); }); + + it("should preserve mirrors through Firefox installation and driver launch", async () => { + await runBrowserDriver(BrowserName.FIREFOX, "130", { browserDownloadMirrors }); + + assert.calledOnceWith(installBrowserStub, BrowserName.FIREFOX, "130", { + shouldInstallWebDriver: true, + shouldInstallUbuntuPackages: true, + browserDownloadMirrors, + }); + assert.calledOnceWithExactly(runGeckoDriverStub, "130", { debug: false, browserDownloadMirrors }); + }); }); diff --git a/test/src/browser-pool/webdriver-pool.ts b/test/src/browser-pool/webdriver-pool.ts index ff900a98d..1a3842c53 100644 --- a/test/src/browser-pool/webdriver-pool.ts +++ b/test/src/browser-pool/webdriver-pool.ts @@ -8,6 +8,7 @@ describe("browser-pool/webdriver-pool", () => { chrome: "https://mirror.example/chrome", chromium: null, firefox: null, + geckodriver: null, }; let wdPool: WdPoolType; diff --git a/test/src/browser/new-browser.ts b/test/src/browser/new-browser.ts index 0ec2deb8a..a40261440 100644 --- a/test/src/browser/new-browser.ts +++ b/test/src/browser/new-browser.ts @@ -426,6 +426,7 @@ describe("NewBrowser", () => { chrome: "https://mirror.example/chrome", chromium: null, firefox: null, + geckodriver: null, }; const wdPool = mkWdPool_({ gridUrl: "http://localhost:12345/" }); const config = Object.assign( @@ -465,6 +466,7 @@ describe("NewBrowser", () => { chrome: "https://mirror.example/chrome", chromium: null, firefox: null, + geckodriver: null, }; const wdProcess = { gridUrl: "http://localhost:12345/", diff --git a/test/src/browser/standalone/launchBrowser.js b/test/src/browser/standalone/launchBrowser.js index 437e6cb20..090c140af 100644 --- a/test/src/browser/standalone/launchBrowser.js +++ b/test/src/browser/standalone/launchBrowser.js @@ -6,12 +6,15 @@ const MIRROR_ENV_NAMES = [ "TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_CHROME", "TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_CHROMIUM", "TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_FIREFOX", + "TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_GECKODRIVER", "testplane_browser_download_mirrors_chrome", "testplane_browser_download_mirrors_chromium", "testplane_browser_download_mirrors_firefox", + "testplane_browser_download_mirrors_geckodriver", "hermione_browser_download_mirrors_chrome", "hermione_browser_download_mirrors_chromium", "hermione_browser_download_mirrors_firefox", + "hermione_browser_download_mirrors_geckodriver", ]; describe("browser/standalone/launchBrowser", () => { @@ -84,31 +87,44 @@ describe("browser/standalone/launchBrowser", () => { await launchBrowser({ browserDownloadMirrors: { chrome: " https://mirror.example/options/chrome/// ", + geckodriver: " https://mirror.example/options/geckodriver/// ", }, }); assert.equal(receivedConfig.browserDownloadMirrors.chrome, "https://mirror.example/options/chrome"); + assert.equal(receivedConfig.browserDownloadMirrors.geckodriver, "https://mirror.example/options/geckodriver"); assert.notProperty(receivedConfig.forBrowser("chrome"), "browserDownloadMirrors"); }); it("should read a browser download mirror from the environment", async () => { process.env.TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_CHROME = "https://mirror.example/environment/chrome"; + process.env.TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_GECKODRIVER = "https://mirror.example/environment/geckodriver"; await launchBrowser(); assert.equal(receivedConfig.browserDownloadMirrors.chrome, "https://mirror.example/environment/chrome"); + assert.equal( + receivedConfig.browserDownloadMirrors.geckodriver, + "https://mirror.example/environment/geckodriver", + ); }); it("should prefer an environment mirror over standalone options", async () => { process.env.TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_CHROME = "https://mirror.example/environment/chrome"; + process.env.TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_GECKODRIVER = "https://mirror.example/environment/geckodriver"; await launchBrowser({ browserDownloadMirrors: { chrome: "https://mirror.example/options/chrome", + geckodriver: "https://mirror.example/options/geckodriver", }, }); assert.equal(receivedConfig.browserDownloadMirrors.chrome, "https://mirror.example/environment/chrome"); + assert.equal( + receivedConfig.browserDownloadMirrors.geckodriver, + "https://mirror.example/environment/geckodriver", + ); }); it("should reject an empty environment mirror instead of using standalone options", async () => { diff --git a/test/src/cli/commands/install-deps/index.ts b/test/src/cli/commands/install-deps/index.ts index 8ae5e4c62..3fd556529 100644 --- a/test/src/cli/commands/install-deps/index.ts +++ b/test/src/cli/commands/install-deps/index.ts @@ -114,6 +114,7 @@ describe("cli/commands/install-deps", () => { chrome: "https://mirror.example/chrome", chromium: null, firefox: null, + geckodriver: null, }; testplaneStub.config.browserDownloadMirrors = browserDownloadMirrors; diff --git a/test/src/config/index.js b/test/src/config/index.js index 74808f473..bbcae280e 100644 --- a/test/src/config/index.js +++ b/test/src/config/index.js @@ -236,6 +236,7 @@ describe("config", () => { chrome: null, chromium: "https://mirror.example/worker-chromium", firefox: null, + geckodriver: null, }, }, }); @@ -243,6 +244,7 @@ describe("config", () => { chrome: "https://mirror.example/master-chrome", chromium: null, firefox: "https://mirror.example/master-firefox", + geckodriver: "https://mirror.example/master-geckodriver", }; const serializedMasterConfig = JSON.parse(JSON.stringify({ browserDownloadMirrors: masterMirrors })); diff --git a/test/src/config/options.js b/test/src/config/options.js index bb31cbec4..73c62bc4c 100644 --- a/test/src/config/options.js +++ b/test/src/config/options.js @@ -32,6 +32,7 @@ describe("config options", () => { chrome: "https://mirror.example/chrome", chromium: "https://mirror.example/chromium", firefox: "https://mirror.example/firefox", + geckodriver: "https://mirror.example/geckodriver", }, }, }); @@ -40,6 +41,7 @@ describe("config options", () => { chrome: "https://mirror.example/chrome", chromium: "https://mirror.example/chromium", firefox: "https://mirror.example/firefox", + geckodriver: "https://mirror.example/geckodriver", }); }); @@ -69,7 +71,7 @@ describe("config options", () => { ); }); - ["chrome", "chromium", "firefox"].forEach(browserName => { + ["chrome", "chromium", "firefox", "geckodriver"].forEach(browserName => { it(`should reject a non-absolute ${browserName} mirror URL`, () => { assert.throws( () => @@ -153,12 +155,26 @@ describe("config options", () => { assert.equal(result.browserDownloadMirrors.chrome, "https://uppercase.example/chrome"); }); + it("should prefer uppercase GeckoDriver env over config and compatibility env", () => { + const result = parse_({ + options: { browserDownloadMirrors: { geckodriver: "https://config.example/geckodriver" } }, + env: { + ["testplane_browser_download_mirrors_geckodriver"]: "https://lowercase.example/geckodriver", + ["hermione_browser_download_mirrors_geckodriver"]: "https://legacy.example/geckodriver", + TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_GECKODRIVER: " https://env.example/cache/geckodriver/// ", + }, + }); + + assert.equal(result.browserDownloadMirrors.geckodriver, "https://env.example/cache/geckodriver"); + }); + it("should read uppercase environment variables for every mirror", () => { const result = parse_({ env: { TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_CHROME: "https://env.example/chrome", TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_CHROMIUM: "https://env.example/chromium", TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_FIREFOX: "https://env.example/firefox", + TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_GECKODRIVER: "https://env.example/geckodriver", }, }); @@ -166,6 +182,7 @@ describe("config options", () => { chrome: "https://env.example/chrome", chromium: "https://env.example/chromium", firefox: "https://env.example/firefox", + geckodriver: "https://env.example/geckodriver", }); }); @@ -174,6 +191,7 @@ describe("config options", () => { ["chrome", "TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_CHROME"], ["chromium", "TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_CHROMIUM"], ["firefox", "TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_FIREFOX"], + ["geckodriver", "TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_GECKODRIVER"], ].forEach(([browserName, envName]) => { assert.throws( () => diff --git a/test/type-tests/browser-download-mirrors.ts b/test/type-tests/browser-download-mirrors.ts index a0041c29d..f19d83341 100644 --- a/test/type-tests/browser-download-mirrors.ts +++ b/test/type-tests/browser-download-mirrors.ts @@ -4,6 +4,7 @@ export const browserDownloadMirrors: BrowserDownloadMirrorsInput = { chrome: "https://mirror.example/chrome", chromium: "https://mirror.example/chromium", firefox: "https://mirror.example/firefox", + geckodriver: "https://mirror.example/geckodriver", }; export const configWithBrowserDownloadMirrors = {