@eyeo/get-browser-binary
Version:
Install browser binaries and matching webdrivers
340 lines (291 loc) • 11.7 kB
JavaScript
/*
* Copyright (c) 2006-present eyeo GmbH
*
* This module is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import path from "path";
import fs from "fs";
import got from "got";
import {Builder} from "selenium-webdriver";
import chrome from "selenium-webdriver/chrome.js";
import extractZip from "extract-zip";
import {Browser} from "./browser.js";
import {download, getMajorVersion, checkVersion, checkPlatform, errMsg,
snapshotsBaseDir, platformArch, checkExtensionPaths} from "./utils.js";
async function enableDeveloperMode(driver) {
const currentHandle = await driver.getWindowHandle();
await driver.switchTo().newWindow("window");
await driver.navigate().to("chrome://extensions");
await driver.executeScript(() => {
const devModeToggle = document
.querySelector("extensions-manager").shadowRoot
.getElementById("toolbar").shadowRoot
.querySelector("#toolbar #devMode");
if (!devModeToggle.checked)
devModeToggle.click();
});
await driver.wait(async() => {
const checked = await driver.executeScript(() => {
const devModeToggle = document
.querySelector("extensions-manager").shadowRoot
.getElementById("toolbar").shadowRoot
.querySelector("#toolbar #devMode");
return devModeToggle.checked;
});
return checked;
}, 500);
await driver.close();
await driver.switchTo().window(currentHandle);
}
/**
* Browser and webdriver functionality for Chromium.
* @hideconstructor
* @extends Browser
*/
export class Chromium extends Browser {
static #CHANNELS = ["latest", "beta", "dev"];
static #MAX_VERSION_DECREMENTS = 200;
static async #getVersionForChannel(channel) {
if (!Chromium.#CHANNELS.includes(channel))
return channel;
if (channel == "latest")
channel = "stable";
let os = {
"win32-ia32": "win",
"win32-x64": "win64",
"linux-x64": "linux",
"darwin-x64": "mac",
"darwin-arm64": "mac_arm64"
}[platformArch];
let url = `https://versionhistory.googleapis.com/v1/chrome/platforms/${os}/channels/${channel}/versions/all/releases`;
let data;
try {
data = await got(url).json();
}
catch (err) {
throw new Error(`${errMsg.browserVersionCheck}: ${url}\n${err}`);
}
let version;
let fraction;
for ({version, fraction} of data.releases) {
// Versions having small fractions may not appear on Chromium Dash
if (fraction > 0.0025)
break;
}
return version;
}
static #getBinaryPath(dir) {
checkPlatform();
return {
win32: path.join(dir, "chrome-win", "chrome.exe"),
linux: path.join(dir, "chrome-linux", "chrome"),
darwin: path.join(dir, "chrome-mac", "Chromium.app", "Contents", "MacOS",
"Chromium")
}[process.platform];
}
static async #getBase(chromiumVersion) {
let url;
let chromiumBase;
try {
let majorVersion = getMajorVersion(chromiumVersion);
if (majorVersion < 91) {
// Below v91, base branch position only exists once per milestone in
// the /fetch_milestones endpoint
url = "https://chromiumdash.appspot.com/fetch_milestones?only_branched=true";
let data = await got(url).json();
for (let {milestone, chromium_main_branch_position: base} of data) {
if (milestone == majorVersion) {
chromiumBase = base;
break;
}
}
if (!chromiumBase)
throw new Error(`${errMsg.browserVersionCheck}: ${url}`);
}
else {
url = `https://chromiumdash.appspot.com/fetch_version?version=${chromiumVersion}`;
({chromium_main_branch_position: chromiumBase} = await got(url).json());
}
}
catch (err) {
throw new Error(`${errMsg.browserVersionCheck}: ${url}\n${err}`);
}
return parseInt(chromiumBase, 10);
}
/** @see Browser.getInstalledVersion */
static async getInstalledVersion(binary) {
const installedVersion = await super.getInstalledVersion(binary);
// Linux example: "Chromium 112.0.5615.49 built on Debian 11.6"
// Windows example: "114.0.5735.0"
return installedVersion.split(" ")[1] || installedVersion;
}
static async #getInstalledBrowserInfo(binary) {
const versionNumber = await Chromium.getInstalledVersion(binary);
return {binary, versionNumber};
}
/**
* Installs the browser. The Chromium executable gets extracted in the
* {@link snapshotsBaseDir} folder, ready to go.
* @param {string} [version=latest] Either "latest", "beta", "dev" or a full
* version number (i.e. "77.0.3865.0").
* @param {number} [downloadTimeout=0] Allowed time in ms for the download of
* install files to complete. When set to 0 there is no time limit.
* @return {BrowserBinary}
* @throws {Error} Unsupported browser version, Unsupported platform, Browser
* download failed.
*/
static async installBrowser(version = "latest", downloadTimeout = 0) {
const MIN_VERSION = process.arch == "arm64" ? 92 : 77;
checkVersion(version, MIN_VERSION, Chromium.#CHANNELS);
let versionNumber = await Chromium.#getVersionForChannel(version);
let base = await Chromium.#getBase(versionNumber);
let startBase = base;
let [platformDir, fileName] = {
"win32-ia32": ["Win", "chrome-win.zip"],
"win32-x64": ["Win_x64", "chrome-win.zip"],
"linux-x64": ["Linux_x64", "chrome-linux.zip"],
"darwin-x64": ["Mac", "chrome-mac.zip"],
"darwin-arm64": ["Mac_Arm", "chrome-mac.zip"]
}[platformArch];
let archive;
let browserDir;
let snapshotsDir = path.join(snapshotsBaseDir, "chromium");
let binary;
while (true) {
browserDir = path.join(snapshotsDir, `chromium-${platformArch}-${base}`);
binary = Chromium.#getBinaryPath(browserDir);
try {
await fs.promises.access(browserDir);
return {binary, versionNumber, base};
}
catch (e) {}
await fs.promises.mkdir(path.dirname(browserDir), {recursive: true});
archive = path.join(snapshotsDir, "cache", `${base}-${fileName}`);
let url = `https://www.googleapis.com/download/storage/v1/b/chromium-browser-snapshots/o/${platformDir}%2F${base}%2F${fileName}?alt=media`;
try {
try {
await fs.promises.access(archive);
}
catch (e) {
await download(url, archive, downloadTimeout);
}
break;
}
catch (err) {
if (err.name == "HTTPError") {
// Chromium advises decrementing the branch_base_position when no
// matching build was found. See https://www.chromium.org/getting-involved/download-chromium
base--;
if (base <= startBase - Chromium.#MAX_VERSION_DECREMENTS)
throw new Error(`${errMsg.browserDownload}: Chromium base ${startBase}`);
}
else {
throw new Error(`${errMsg.browserDownload}: ${url}\n${err}`);
}
}
}
await extractZip(archive, {dir: browserDir});
return {binary, versionNumber};
}
/** @see Browser.getDriver */
static async getDriver(version = "latest", {
headless = true, extensionPaths = [], incognito = false, insecure = false,
extraArgs = [], customBrowserBinary
} = {}, downloadTimeout = 0) {
let {binary, versionNumber} = customBrowserBinary ?
await Chromium.#getInstalledBrowserInfo(customBrowserBinary) :
await Chromium.installBrowser(version, downloadTimeout);
const majorVersion = getMajorVersion(versionNumber);
const chromeOptions = {};
// https://issues.chromium.org/issues/409441960
if (majorVersion >= 136)
chromeOptions.enableExtensionTargets = true;
const options = new chrome.Options({"goog:chromeOptions": chromeOptions})
.addArguments("no-sandbox", ...extraArgs);
if (extensionPaths.length > 0) {
await checkExtensionPaths(extensionPaths);
options.addArguments(`load-extension=${extensionPaths.join(",")}`);
}
if (majorVersion >= 139)
options.addArguments("--disable-features=LocalNetworkAccessChecks");
if (headless) {
// https://www.selenium.dev/blog/2023/headless-is-going-away/
if (majorVersion >= 109)
options.addArguments("headless=new");
else if (majorVersion >= 96)
options.addArguments("headless=chrome");
else
options.addArguments("headless");
}
if (insecure)
options.addArguments("ignore-certificate-errors");
if (incognito)
options.addArguments("incognito");
options.setChromeBinaryPath(binary);
let builder = new Builder();
builder.forBrowser("chrome");
builder.setChromeOptions(options);
const driver = builder.build();
// From Chromium 134 on, developer mode needs to be enabled
// for custom extensions to work properly
if (majorVersion >= 134)
await enableDeveloperMode(driver);
return driver;
}
/** @see Browser.enableExtensionInIncognito */
static async enableExtensionInIncognito(driver, extensionTitle) {
const currentHandle = await driver.getWindowHandle();
const browserVersion =
getMajorVersion((await driver.getCapabilities()).getBrowserVersion());
if (browserVersion >= 115)
// On Chromium 115 opening chrome://extensions on the default tab causes
// WebDriverError: disconnected. Switching to a new window as a workaround
await driver.switchTo().newWindow("window");
await driver.navigate().to("chrome://extensions");
await driver.executeScript((title, errorMsg) => {
const enableIncognitoMode = () => document
.querySelector("extensions-manager").shadowRoot
.querySelector("extensions-detail-view").shadowRoot
.getElementById("allow-incognito").shadowRoot
.getElementById("crToggle").click();
const getExtensionDetailsButton = () => {
const extensions = document
.querySelector("extensions-manager").shadowRoot
.getElementById("items-list").shadowRoot
.querySelectorAll("extensions-item");
let detailsButton;
for (let {shadowRoot} of extensions) {
const extensionName = shadowRoot.getElementById("name").innerHTML;
if (!extensionName.includes(title))
continue;
detailsButton = shadowRoot.getElementById("detailsButton");
break;
}
return detailsButton;
};
return new Promise((resolve, reject) => {
const detailsButton = getExtensionDetailsButton();
if (!detailsButton)
reject(`${errorMsg}: ${title}`);
detailsButton.click();
setTimeout(() => resolve(enableIncognitoMode()), 100);
});
}, extensionTitle, errMsg.extensionNotFound);
if (browserVersion >= 115 && process.platform == "win32")
// Closing the previously opened new window. Needed on Windows to avoid a
// further `WebDriverError: disconnected` error
await driver.close();
await driver.switchTo().window(currentHandle);
}
}