UNPKG

@eyeo/get-browser-binary

Version:

Install browser binaries and matching webdrivers

340 lines (291 loc) 11.7 kB
/* * 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); } }