Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/dealing-with-browsers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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.
Expand All @@ -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 `<build-id>/<platform>/chrome-<platform>.zip`, `<build-id>/<platform>/chrome-headless-shell-<platform>.zip`, and `<build-id>/<platform>/chromedriver-<platform>.zip`.
- The Chromium mirror serves snapshot archives under paths such as `<platform-folder>/<revision>/<archive>.zip`.
- The Firefox mirror serves `firefox_versions.json` at its root. Release archives use paths such as `<version>/<platform>/en-US/<archive>`.
- The GeckoDriver mirror serves `Cargo.toml` at its root for latest-version discovery. Archives use paths such as `v<version>/geckodriver-v<version>-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.

Expand All @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion src/browser-installer/firefox/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export const installFirefox = async (
): Promise<string> => {
const [browserPath] = await Promise.all([
installFirefoxBrowser(version, { force, browserDownloadMirrors }),
needWebDriver && installLatestGeckoDriver(version, { force }),
needWebDriver && installLatestGeckoDriver(version, { force, browserDownloadMirrors }),
needUbuntuPackages && installUbuntuPackageDependencies(),
]);

Expand Down
100 changes: 92 additions & 8 deletions src/browser-installer/firefox/driver.ts
Original file line number Diff line number Diff line change
@@ -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<string> => {
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 = {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Despite our mirror only has linux64 geckodriver, it sounds right to implement other platforms support for the future, in case there would be a mirror with other platforms support

[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<string> => {
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+)["']/);
Comment on lines +41 to +42

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched to using explicit regexp matching


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<string> => {
const downloadMirroredGeckoDriver = async (
version: string,
platform: BrowserPlatform,
mirror: string,
): Promise<string> => {
const isWindows = platform === BrowserPlatform.WIN32 || platform === BrowserPlatform.WIN64;
const filename = `geckodriver-v${version}-${GECKODRIVER_PLATFORMS[platform]}${isWindows ? ".zip" : ".tar.gz"}`;
Comment on lines +60 to +61

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like every other mirror packs binaries into ".zip" for windows and ".tar.gz" for other platforms
So i suppose its fair to assume "geckodriver" would not be an exception

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));

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For chromedriver and chrome, we can use puppeteer's mirror feature
But "geckodriver" npm package we use in order to download geckodriver, does not have the ability to specify mirrors. So we download + unpack it manually


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<string> => {
const platform = getBrowserPlatform();
const existingLocallyDriverVersion = registry.getMatchedDriverVersion(
DriverName.GECKODRIVER,
Expand All @@ -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<string> => downloadGeckoDriver(latestVersion, getGeckoDriverDir(latestVersion));
const installFn = (): Promise<string> =>
mirror
? downloadMirroredGeckoDriver(latestVersion, platform, mirror)
: downloadGeckoDriver(latestVersion, getGeckoDriverDir(latestVersion));

return registry.installBinary(DriverName.GECKODRIVER, platform, latestVersion, installFn);
};
8 changes: 6 additions & 2 deletions src/browser-installer/firefox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
7 changes: 5 additions & 2 deletions src/browser-installer/mirrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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;
Expand All @@ -59,6 +59,9 @@ export const getBrowserDownloadMirror = (
case BrowserName.FIREFOX:
mirrorName = "firefox";
break;
case DriverName.GECKODRIVER:
mirrorName = "geckodriver";
break;
default:
return undefined;
}
Expand Down
4 changes: 3 additions & 1 deletion src/browser-installer/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions src/config/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ module.exports = {
chrome: null,
chromium: null,
firefox: null,
geckodriver: null,
},
gridUrl: LOCAL_GRID_URL,
browserWSEndpoint: null,
Expand Down
1 change: 1 addition & 0 deletions src/config/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const rootSection = section(
chrome: browserDownloadMirrorOption("chrome"),
chromium: browserDownloadMirrorOption("chromium"),
firefox: browserDownloadMirrorOption("firefox"),
geckodriver: browserDownloadMirrorOption("geckodriver"),
}),

prepareEnvironment: options.optionalFunction("prepareEnvironment"),
Expand Down
1 change: 1 addition & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,7 @@ export interface BrowserDownloadMirrors {
chrome: string | null;
chromium: string | null;
firefox: string | null;
geckodriver: string | null;
}

export type BrowserDownloadMirrorsInput = Partial<BrowserDownloadMirrors>;
Expand Down
1 change: 1 addition & 0 deletions src/constants/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion test/src/browser-installer/browser-version-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions test/src/browser-installer/chrome/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions test/src/browser-installer/chrome/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ describe("browser-installer/chrome/driver", () => {
chrome: "https://mirror.example/chrome",
chromium: null,
firefox: null,
geckodriver: null,
};

let installChromeDriver: typeof InstallChromeDriverType;
Expand Down
3 changes: 2 additions & 1 deletion test/src/browser-installer/chrome/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ describe("browser-installer/chrome", () => {
chrome: "https://mirror.example/chrome",
chromium: null,
firefox: null,
geckodriver: null,
};

let runChromeDriver: typeof RunChromeDriverType;
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions test/src/browser-installer/chromium/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ describe("browser-installer/chromium/browser", () => {
chrome: null,
chromium: "https://mirror.example/chromium",
firefox: null,
geckodriver: null,
};

let installChromium: typeof InstallChromiumType;
Expand Down
12 changes: 11 additions & 1 deletion test/src/browser-installer/firefox/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading
Loading