From 2f9d36c800e1d6318fcdd92de5737b744c7432dd Mon Sep 17 00:00:00 2001 From: Roman Kuznetsov Date: Wed, 26 Aug 2026 03:43:50 +0300 Subject: [PATCH] feat: support browser download mirrors --- docs/dealing-with-browsers.md | 105 +++++- src/browser-installer/chrome/browser.ts | 106 +++++-- src/browser-installer/chrome/driver.ts | 52 ++- src/browser-installer/chrome/index.ts | 8 +- src/browser-installer/chrome/utils.ts | 71 +++++ src/browser-installer/chromium/browser.ts | 41 ++- src/browser-installer/constants.ts | 10 +- src/browser-installer/firefox/browser.ts | 113 +++++-- src/browser-installer/install.ts | 41 ++- src/browser-installer/mirrors.ts | 76 +++++ .../resolve-browser-version.ts | 15 +- src/browser-installer/run.ts | 16 +- src/browser-installer/ubuntu-packages/apt.ts | 2 + src/browser-pool/webdriver-pool.ts | 24 +- src/browser/new-browser.ts | 80 +++-- src/browser/standalone/launchBrowser.ts | 1 + src/browser/standalone/types.ts | 3 +- src/cli/commands/install-deps/index.ts | 4 +- src/config/defaults.js | 5 + src/config/index.ts | 6 + src/config/options.js | 35 +- src/config/types.ts | 10 + src/constants/config.ts | 6 + src/index.ts | 2 +- .../browser-installer/chrome-linux64.zip | Bin 0 -> 172 bytes .../browser-version-contract.ts | 192 +++++++++++ test/src/browser-installer/chrome/browser.ts | 209 +++++++++++- test/src/browser-installer/chrome/driver.ts | 113 ++++++- test/src/browser-installer/chrome/index.ts | 28 +- test/src/browser-installer/chrome/utils.ts | 179 +++++++++++ .../src/browser-installer/chromium/browser.ts | 65 +++- test/src/browser-installer/firefox/browser.ts | 114 ++++++- test/src/browser-installer/install.ts | 178 ++++++++++- .../mirror-download-contract.ts | 264 ++++++++++++++++ test/src/browser-installer/mirrors.ts | 87 +++++ test/src/browser-installer/registry.ts | 15 + test/src/browser-installer/run.ts | 51 ++- .../browser-installer/ubuntu-packages/apt.ts | 23 ++ test/src/browser-pool/basic-pool.js | 20 ++ test/src/browser-pool/webdriver-pool.ts | 51 ++- test/src/browser/new-browser.ts | 298 +++++++++++++++++- test/src/browser/standalone/launchBrowser.js | 126 ++++++++ test/src/cli/commands/install-deps/index.ts | 15 + test/src/config/index.js | 50 +++ test/src/config/options.js | 169 ++++++++++ test/src/profiler/output/console.ts | 3 +- test/type-tests/browser-download-mirrors.ts | 33 ++ 47 files changed, 2949 insertions(+), 166 deletions(-) create mode 100644 src/browser-installer/chrome/utils.ts create mode 100644 src/browser-installer/mirrors.ts create mode 100644 test/fixtures/browser-installer/chrome-linux64.zip create mode 100644 test/src/browser-installer/browser-version-contract.ts create mode 100644 test/src/browser-installer/chrome/utils.ts create mode 100644 test/src/browser-installer/mirror-download-contract.ts create mode 100644 test/src/browser-installer/mirrors.ts create mode 100644 test/src/browser-installer/ubuntu-packages/apt.ts create mode 100644 test/src/browser/standalone/launchBrowser.js create mode 100644 test/type-tests/browser-download-mirrors.ts diff --git a/docs/dealing-with-browsers.md b/docs/dealing-with-browsers.md index e96fd3433..0907fe5f2 100644 --- a/docs/dealing-with-browsers.md +++ b/docs/dealing-with-browsers.md @@ -2,30 +2,109 @@ ## Dealing with Browsers -All you need are browsers that Testplane could use for testing. To do this you need to install some browsers, such as [chrome](https://www.google.com/chrome/) (to automate this process you can use the [@testplane/headless-chrome](https://github.com/gemini-testing/testplane-headless-chrome) plugin). +Testplane v9 runs browser sessions through WebDriver. You can connect to a remote WebDriver grid or let Testplane install and run supported browsers and drivers locally. -Next, you have two ways to configure Testplane to work with browsers: +### Local browsers and drivers -* Using the devtools protocol (available only for `Chromium`-based browsers). This method does not need to be pre-configured. Just go to the [quick start](#quick-start). -* Using the webdriver protocol. In this case you need to set up [Selenium](http://www.seleniumhq.org/) grid. The simplest way to get started is to use one of the NPM selenium standalone packages, such as [vvo/selenium-standalone](https://github.com/vvo/selenium-standalone). For more information about setting up, see [selenium-standalone](#selenium-standalone). +Set `gridUrl` to `"local"` and describe the browsers in the usual `browsers` section: -### Selenium-standalone -Install `selenium-standalone` by command: +```typescript +export default { + gridUrl: "local", -``` -npm i -g selenium-standalone + browsers: { + chrome: { + desiredCapabilities: { + browserName: "chrome", + browserVersion: "130", + }, + }, + firefox: { + desiredCapabilities: { + browserName: "firefox", + }, + }, + }, +} satisfies import("testplane").ConfigInput; ``` -Next you need to install browser drivers +Install the configured browser binaries and drivers in advance: +```bash +npx testplane install-deps ``` -selenium-standalone install + +You can also request explicit versions: + +```bash +npx testplane install-deps chrome@130 firefox@128 ``` -and run your server by executing +If `install-deps` is not run first, Testplane can download missing local dependencies when the browser session starts. For a remote grid, set `gridUrl` to its WebDriver endpoint instead of `"local"`. + +For local browser and driver installation, a `browserVersion` consisting only of digits is normalized by adding `.0`: `"139"` is treated as `"139.0"`. Versions containing dots or channel names are not rewritten by this normalization. The same rule applies to explicit `install-deps` versions such as `chrome@139`. + +### Browser download mirrors + +Configure mirrors once at the root of the Testplane config. The map is not a per-browser option: +```typescript +export default { + gridUrl: "local", + + browserDownloadMirrors: { + chrome: "https://mirror.example/chrome-for-testing", + chromium: "https://mirror.example/chromium-browser-snapshots", + firefox: "https://mirror.example/firefox", + }, + + browsers: { + chrome: { + desiredCapabilities: { + browserName: "chrome", + browserVersion: "130", + }, + }, + }, +} satisfies import("testplane").ConfigInput; ``` -selenium-standalone start + +CI can supply or override individual mirrors with uppercase environment variables: + +```bash +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 ``` -:warning: If you will get error like `No Java runtime present, requesting install.` you should install [Java Development Kit (JDK)](https://www.oracle.com/technetwork/java/javase/downloads/index.html) for your OS. +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. + +Mirror URLs must be absolute `http:` or `https:` URLs without credentials, a query string, or a fragment. Testplane trims surrounding whitespace and trailing slashes while preserving a pathname prefix. + +To see which mirror is used for each downloaded binary, enable `DEBUG=testplane:browser-installer` when running `testplane install-deps` or starting a local browser. The debug message includes the binary name, resolved version, and mirror URL. No mirror download message is emitted when an installed binary is reused. + +### Mirror layout + +A mirror must preserve the archive layout expected by `@puppeteer/browsers` for every target platform used in CI. + +- The Chrome mirror serves Chrome for Testing metadata at its root, including `LATEST_RELEASE_STABLE`, channel files such as `LATEST_RELEASE_BETA`, `latest-versions-per-milestone.json`, and `latest-patch-versions-per-build.json`. +- 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/`. + +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. + +The actual platform directory and archive names vary between Linux, macOS, Windows, and architectures. Mirror the upstream paths rather than inventing a new layout. + +### Source exceptions and fallback behavior + +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. + +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. + +Download errors retain the underlying network diagnostics, including the mirror URL. +If an artifact is unavailable during the pre-download check, the error includes the configured mirror URL. diff --git a/src/browser-installer/chrome/browser.ts b/src/browser-installer/chrome/browser.ts index e4cfdcb73..9490ddd16 100644 --- a/src/browser-installer/chrome/browser.ts +++ b/src/browser-installer/chrome/browser.ts @@ -14,11 +14,22 @@ import { normalizeChromeVersion } from "../utils"; import { installUbuntuPackageDependencies } from "../ubuntu-packages"; import { installChromeDriver } from "./driver"; import { BrowserName } from "../../browser/types"; +import type { BrowserDownloadMirrors } from "../../config/types"; +import { getBrowserDownloadMirror } from "../mirrors"; +import { resolveChromeBuildIdFromMirror, type ChromeBuildIdResolver } from "./utils"; const installChromeBrowser = async ( browserName: typeof BrowserName.CHROME | typeof BrowserName.CHROMEHEADLESSSHELL, version: string, - { force = false } = {}, + { + force = false, + browserDownloadMirrors, + resolveMirrorBuildId, + }: { + force?: boolean; + browserDownloadMirrors?: BrowserDownloadMirrors; + resolveMirrorBuildId?: ChromeBuildIdResolver; + } = {}, ): Promise => { const milestone = getMilestone(version); @@ -27,7 +38,7 @@ const installChromeBrowser = async ( const { installChromium } = await import("../chromium"); - return installChromium(version, { force }); + return installChromium(version, { force, browserDownloadMirrors }); } const platform = getBrowserPlatform(); @@ -40,30 +51,41 @@ const installChromeBrowser = async ( } const normalizedVersion = normalizeChromeVersion(version); - const buildId = await resolveBuildId(browserName, platform, normalizedVersion); + const mirror = getBrowserDownloadMirror(browserName, browserDownloadMirrors); + const buildId = mirror + ? await (resolveMirrorBuildId ? resolveMirrorBuildId() : resolveChromeBuildIdFromMirror(version, mirror)) + : await resolveBuildId(browserName, platform, normalizedVersion); const cacheDir = getBrowsersDir(); - const canBeInstalled = await canDownload({ browser: browserName, platform, buildId, cacheDir }); + const canBeInstalled = await canDownload({ browser: browserName, platform, buildId, cacheDir, baseUrl: mirror }); if (!canBeInstalled) { throw new Error( - [ - `${browserName}@${version} can't be installed.`, - `Probably the version '${version}' is invalid, please try another version.`, - "Version examples: '120', '120.0'", - ].join("\n"), + mirror + ? `Couldn't download browser artifact from the configured mirror: ${mirror}` + : [ + `${browserName}@${version} can't be installed.`, + `Probably the version '${version}' is invalid, please try another version.`, + "Version examples: '120', '120.0'", + ].join("\n"), ); } - const installFn = (downloadProgressCallback: DownloadProgressCallback): Promise => - puppeteerInstall({ + const installFn = (downloadProgressCallback: DownloadProgressCallback): Promise => { + if (mirror) { + browserInstallerDebug(`downloading ${browserName}@${buildId} from mirror ${mirror}`); + } + + return puppeteerInstall({ platform, buildId, cacheDir, downloadProgressCallback, browser: browserName, + baseUrl: mirror, unpack: true, }).then(result => result.executablePath); + }; return registry.installBinary(browserName, platform, buildId, installFn); }; @@ -71,30 +93,60 @@ const installChromeBrowser = async ( export const installChrome = async ( browserName: typeof BrowserName.CHROME | typeof BrowserName.CHROMEHEADLESSSHELL, version: string, - { force = false, needWebDriver = false, needUbuntuPackages = false } = {}, + { + force = false, + needWebDriver = false, + needUbuntuPackages = false, + browserDownloadMirrors, + }: { + force?: boolean; + needWebDriver?: boolean; + needUbuntuPackages?: boolean; + browserDownloadMirrors?: BrowserDownloadMirrors; + } = {}, ): Promise => { + const chromeMirror = getBrowserDownloadMirror(BrowserName.CHROME, browserDownloadMirrors); + const resolveMirrorBuildId = chromeMirror + ? _.once(() => resolveChromeBuildIdFromMirror(version, chromeMirror)) + : undefined; const [browserPath] = await Promise.all([ - installChromeBrowser(browserName, version, { force }), - needWebDriver && installChromeDriver(version, { force }), + installChromeBrowser(browserName, version, { force, browserDownloadMirrors, resolveMirrorBuildId }), + needWebDriver && + installChromeDriver(version, { + force, + browserDownloadMirrors, + resolveMirrorBuildId, + }), needUbuntuPackages && installUbuntuPackageDependencies(), ]); return browserPath; }; -export const resolveLatestChromeVersion = _.memoize(async (force = false): Promise => { - if (!force) { - const platform = getBrowserPlatform(); - const existingLocallyBrowserVersion = registry.getMatchedBrowserVersion(BrowserName.CHROME, platform); +export const resolveLatestChromeVersion = _.memoize( + async (force = false, browserDownloadMirrors?: BrowserDownloadMirrors): Promise => { + if (!force) { + const platform = getBrowserPlatform(); + const existingLocallyBrowserVersion = registry.getMatchedBrowserVersion(BrowserName.CHROME, platform); - if (existingLocallyBrowserVersion) { - return existingLocallyBrowserVersion; + if (existingLocallyBrowserVersion) { + return existingLocallyBrowserVersion; + } + } + + const mirror = getBrowserDownloadMirror(BrowserName.CHROME, browserDownloadMirrors); + + if (mirror) { + return resolveChromeBuildIdFromMirror("stable", mirror); } - } - return retryFetch(CHROME_FOR_TESTING_LATEST_STABLE_API_URL) - .then(res => res.text()) - .catch(() => { - throw new Error("Couldn't resolve latest chrome version"); - }); -}); + return retryFetch(CHROME_FOR_TESTING_LATEST_STABLE_API_URL) + .then(res => res.text()) + .then(version => version.trim()) + .catch(() => { + throw new Error("Couldn't resolve latest chrome version"); + }); + }, + (force, browserDownloadMirrors) => + `${force}:${getBrowserDownloadMirror(BrowserName.CHROME, browserDownloadMirrors) ?? "default"}`, +); diff --git a/src/browser-installer/chrome/driver.ts b/src/browser-installer/chrome/driver.ts index 5512d1625..5f2499a8c 100644 --- a/src/browser-installer/chrome/driver.ts +++ b/src/browser-installer/chrome/driver.ts @@ -9,8 +9,23 @@ import { type DownloadProgressCallback, } from "../utils"; import registry from "../registry"; +import { BrowserName } from "../../browser/types"; +import type { BrowserDownloadMirrors } from "../../config/types"; +import { getBrowserDownloadMirror } from "../mirrors"; +import { resolveChromeBuildIdFromMirror, type ChromeBuildIdResolver } from "./utils"; -export const installChromeDriver = async (chromeVersion: string, { force = false } = {}): Promise => { +export const installChromeDriver = async ( + chromeVersion: string, + { + force = false, + browserDownloadMirrors, + resolveMirrorBuildId, + }: { + force?: boolean; + browserDownloadMirrors?: BrowserDownloadMirrors; + resolveMirrorBuildId?: ChromeBuildIdResolver; + } = {}, +): Promise => { const platform = getBrowserPlatform(); const existingLocallyDriverVersion = registry.getMatchedDriverVersion( DriverName.CHROMEDRIVER, @@ -38,30 +53,47 @@ export const installChromeDriver = async (chromeVersion: string, { force = false return installChromeDriverManually(milestone); } - const buildId = await resolveBuildId(DriverName.CHROMEDRIVER, platform, milestone); + const mirror = getBrowserDownloadMirror(BrowserName.CHROME, browserDownloadMirrors); + const buildId = mirror + ? await (resolveMirrorBuildId ? resolveMirrorBuildId() : resolveChromeBuildIdFromMirror(chromeVersion, mirror)) + : await resolveBuildId(DriverName.CHROMEDRIVER, platform, milestone); const cacheDir = getChromeDriverDir(); - const canBeInstalled = await canDownload({ browser: DriverName.CHROMEDRIVER, platform, buildId, cacheDir }); + const canBeInstalled = await canDownload({ + browser: DriverName.CHROMEDRIVER, + platform, + buildId, + cacheDir, + baseUrl: mirror, + }); if (!canBeInstalled) { throw new Error( - [ - `chromedriver@${buildId} can't be installed.`, - `Probably the major browser version '${milestone}' is invalid`, - "Correct chrome version examples: '123', '124'", - ].join("\n"), + mirror + ? `Couldn't download browser artifact from the configured mirror: ${mirror}` + : [ + `chromedriver@${buildId} can't be installed.`, + `Probably the major browser version '${milestone}' is invalid`, + "Correct chrome version examples: '123', '124'", + ].join("\n"), ); } - const installFn = (downloadProgressCallback: DownloadProgressCallback): Promise => - puppeteerInstall({ + const installFn = (downloadProgressCallback: DownloadProgressCallback): Promise => { + if (mirror) { + browserInstallerDebug(`downloading ${DriverName.CHROMEDRIVER}@${buildId} from mirror ${mirror}`); + } + + return puppeteerInstall({ platform, buildId, cacheDir: getChromeDriverDir(), browser: DriverName.CHROMEDRIVER, + baseUrl: mirror, unpack: true, downloadProgressCallback, }).then(result => result.executablePath); + }; return registry.installBinary(DriverName.CHROMEDRIVER, platform, buildId, installFn); }; diff --git a/src/browser-installer/chrome/index.ts b/src/browser-installer/chrome/index.ts index 54af50d40..fd73bac09 100644 --- a/src/browser-installer/chrome/index.ts +++ b/src/browser-installer/chrome/index.ts @@ -8,15 +8,19 @@ import { installChrome, resolveLatestChromeVersion } from "./browser"; import { installChromeDriver } from "./driver"; import { isUbuntu, getUbuntuLinkerEnv } from "../ubuntu-packages"; import RuntimeConfig from "../../config/runtime-config"; +import type { BrowserDownloadMirrors } from "../../config/types"; export { installChrome, resolveLatestChromeVersion, installChromeDriver }; export const runChromeDriver = async ( chromeVersion: string, - { debug = false } = {}, + { + debug = false, + browserDownloadMirrors, + }: { debug?: boolean; browserDownloadMirrors?: BrowserDownloadMirrors } = {}, ): Promise<{ gridUrl: string; process: ChildProcess; port: number; kill: () => void }> => { const [chromeDriverPath, randomPort, chromeDriverEnv] = await Promise.all([ - installChromeDriver(chromeVersion), + installChromeDriver(chromeVersion, { browserDownloadMirrors }), getPort(), isUbuntu() .then(isUbuntu => (isUbuntu ? getUbuntuLinkerEnv() : null)) diff --git a/src/browser-installer/chrome/utils.ts b/src/browser-installer/chrome/utils.ts new file mode 100644 index 000000000..99e2286b6 --- /dev/null +++ b/src/browser-installer/chrome/utils.ts @@ -0,0 +1,71 @@ +import { + CHROME_FOR_TESTING_LATEST_RELEASE_FILENAME_PREFIX, + CHROME_FOR_TESTING_MILESTONES_FILENAME, + CHROME_FOR_TESTING_PATCH_VERSIONS_FILENAME, +} from "../constants"; +import { getBrowserDownloadMirrorFileUrl } from "../mirrors"; +import { retryFetch } from "../utils"; + +type ChromeVersionsResponse = { + milestones?: Record; + builds?: Record; +}; + +export type ChromeBuildIdResolver = () => Promise; + +const EXACT_VERSION_PATTERN = /^\d+\.\d+\.\d+\.\d+$/; +const MILESTONE_VERSION_PATTERN = /^\d+(?:\.\d+)?$/; +const BUILD_VERSION_PATTERN = /^\d+\.\d+\.\d+$/; +const CHANNELS = new Set(["stable", "beta", "dev", "canary", "latest"]); + +const createResolutionError = (selector: string): Error => + new Error(`Couldn't resolve Chrome-for-Testing build ID for selector '${selector}' from the configured mirror`); + +export const resolveChromeBuildIdFromMirror = async (version: string, mirror: string): Promise => { + const selector = version.trim(); + + if (EXACT_VERSION_PATTERN.test(selector)) { + return selector; + } + + const isChannel = CHANNELS.has(selector); + const isMilestone = MILESTONE_VERSION_PATTERN.test(selector); + let filename: string; + + if (isChannel) { + // Match @puppeteer/browsers: the explicit "latest" tag aliases Canary, not Stable. + const channel = selector === "latest" ? "canary" : selector; + + filename = `${CHROME_FOR_TESTING_LATEST_RELEASE_FILENAME_PREFIX}${channel.toUpperCase()}`; + } else if (isMilestone) { + filename = CHROME_FOR_TESTING_MILESTONES_FILENAME; + } else if (BUILD_VERSION_PATTERN.test(selector)) { + filename = CHROME_FOR_TESTING_PATCH_VERSIONS_FILENAME; + } else { + throw createResolutionError(selector); + } + + const response = await retryFetch(getBrowserDownloadMirrorFileUrl(mirror, filename)); + + if (!response.ok) { + throw createResolutionError(selector); + } + + let buildId: unknown; + + if (isChannel) { + buildId = await response.text(); + } else { + const data = (await response.json()) as ChromeVersionsResponse | null; + + buildId = isMilestone ? data?.milestones?.[selector.split(".")[0]]?.version : data?.builds?.[selector]?.version; + } + + const resolvedVersion = typeof buildId === "string" ? buildId.trim() : ""; + + if (!EXACT_VERSION_PATTERN.test(resolvedVersion) || (!isChannel && !resolvedVersion.startsWith(`${selector}.`))) { + throw createResolutionError(selector); + } + + return resolvedVersion; +}; diff --git a/src/browser-installer/chromium/browser.ts b/src/browser-installer/chromium/browser.ts index e8a6b8f9e..954757a1f 100644 --- a/src/browser-installer/chromium/browser.ts +++ b/src/browser-installer/chromium/browser.ts @@ -5,8 +5,16 @@ import { getChromiumBuildId } from "./utils"; import { getChromePlatform } from "../utils"; import { MIN_CHROMIUM_VERSION } from "../constants"; import { BrowserName } from "../../browser/types"; +import type { BrowserDownloadMirrors } from "../../config/types"; +import { getBrowserDownloadMirror } from "../mirrors"; -export const installChromium = async (version: string, { force = false } = {}): Promise => { +export const installChromium = async ( + version: string, + { + force = false, + browserDownloadMirrors, + }: { force?: boolean; browserDownloadMirrors?: BrowserDownloadMirrors } = {}, +): Promise => { const milestone = getMilestone(version); if (Number(milestone) < MIN_CHROMIUM_VERSION) { @@ -29,29 +37,44 @@ export const installChromium = async (version: string, { force = false } = {}): const buildId = await getChromiumBuildId(platform, milestone); const cacheDir = getBrowsersDir(); - const canBeInstalled = await canDownload({ browser: BrowserName.CHROMIUM, platform, buildId, cacheDir }); + const mirror = getBrowserDownloadMirror(BrowserName.CHROMIUM, browserDownloadMirrors); + const canBeInstalled = await canDownload({ + browser: BrowserName.CHROMIUM, + platform, + buildId, + cacheDir, + baseUrl: mirror, + }); if (!canBeInstalled) { throw new Error( - [ - `chrome@${version} can't be installed.`, - `Probably the version '${version}' is invalid, please try another version.`, - "Version examples: '93', '93.0'", - ].join("\n"), + mirror + ? `Couldn't download browser artifact from the configured mirror: ${mirror}` + : [ + `chrome@${version} can't be installed.`, + `Probably the version '${version}' is invalid, please try another version.`, + "Version examples: '93', '93.0'", + ].join("\n"), ); } browserInstallerDebug(`installing chromium@${buildId} (${milestone}) for ${platform}`); - const installFn = (downloadProgressCallback: DownloadProgressCallback): Promise => - puppeteerInstall({ + const installFn = (downloadProgressCallback: DownloadProgressCallback): Promise => { + if (mirror) { + browserInstallerDebug(`downloading ${BrowserName.CHROMIUM}@${buildId} from mirror ${mirror}`); + } + + return puppeteerInstall({ platform, buildId, cacheDir, downloadProgressCallback, browser: BrowserName.CHROMIUM, + baseUrl: mirror, unpack: true, }).then(result => result.executablePath); + }; return registry.installBinary(BrowserName.CHROMIUM, platform, milestone, installFn); }; diff --git a/src/browser-installer/constants.ts b/src/browser-installer/constants.ts index 75fbc0a0c..5c927d5c2 100644 --- a/src/browser-installer/constants.ts +++ b/src/browser-installer/constants.ts @@ -1,14 +1,18 @@ export const CHROMEDRIVER_STORAGE_API = "https://chromedriver.storage.googleapis.com"; const CHROME_FOR_TESTING_VERSIONS_API_URL = "https://googlechromelabs.github.io/chrome-for-testing"; -export const CHROME_FOR_TESTING_MILESTONES_API_URL = `${CHROME_FOR_TESTING_VERSIONS_API_URL}/latest-versions-per-milestone.json`; -export const CHROME_FOR_TESTING_LATEST_STABLE_API_URL = `${CHROME_FOR_TESTING_VERSIONS_API_URL}/LATEST_RELEASE_STABLE`; +export const CHROME_FOR_TESTING_MILESTONES_FILENAME = "latest-versions-per-milestone.json"; +export const CHROME_FOR_TESTING_PATCH_VERSIONS_FILENAME = "latest-patch-versions-per-build.json"; +export const CHROME_FOR_TESTING_LATEST_RELEASE_FILENAME_PREFIX = "LATEST_RELEASE_"; +export const CHROME_FOR_TESTING_MILESTONES_API_URL = `${CHROME_FOR_TESTING_VERSIONS_API_URL}/${CHROME_FOR_TESTING_MILESTONES_FILENAME}`; +export const CHROME_FOR_TESTING_LATEST_STABLE_API_URL = `${CHROME_FOR_TESTING_VERSIONS_API_URL}/${CHROME_FOR_TESTING_LATEST_RELEASE_FILENAME_PREFIX}STABLE`; export const GECKODRIVER_CARGO_TOML = "https://raw.githubusercontent.com/mozilla/geckodriver/release/Cargo.toml"; const FIREFOX_VERSIONS_VERSIONS_API_URL = "https://product-details.mozilla.org/1.0"; +export const FIREFOX_VERSIONS_LATEST_VERSIONS_FILENAME = "firefox_versions.json"; export const FIREFOX_VERSIONS_ALL_VERSIONS_API_URL = `${FIREFOX_VERSIONS_VERSIONS_API_URL}/firefox.json`; -export const FIREFOX_VERSIONS_LATEST_VERSIONS_API_URL = `${FIREFOX_VERSIONS_VERSIONS_API_URL}/firefox_versions.json`; +export const FIREFOX_VERSIONS_LATEST_VERSIONS_API_URL = `${FIREFOX_VERSIONS_VERSIONS_API_URL}/${FIREFOX_VERSIONS_LATEST_VERSIONS_FILENAME}`; export const MSEDGEDRIVER_API = "https://msedgedriver.azureedge.net"; diff --git a/src/browser-installer/firefox/browser.ts b/src/browser-installer/firefox/browser.ts index b3b022a86..06ebae56d 100644 --- a/src/browser-installer/firefox/browser.ts +++ b/src/browser-installer/firefox/browser.ts @@ -12,9 +12,20 @@ import { getFirefoxBuildId, normalizeFirefoxVersion } from "./utils"; import { installLatestGeckoDriver } from "./driver"; import { installUbuntuPackageDependencies } from "../ubuntu-packages"; import { BrowserName } from "../../browser/types"; -import { FIREFOX_VERSIONS_LATEST_VERSIONS_API_URL } from "../constants"; +import { FIREFOX_VERSIONS_LATEST_VERSIONS_API_URL, FIREFOX_VERSIONS_LATEST_VERSIONS_FILENAME } from "../constants"; +import type { BrowserDownloadMirrors } from "../../config/types"; +import { getBrowserDownloadMirror, getBrowserDownloadMirrorFileUrl } from "../mirrors"; -const installFirefoxBrowser = async (version: string, { force = false } = {}): Promise => { +const LATEST_FIREFOX_VERSION_PATTERN = /^\d+(?:\.\d+){0,3}$/; +const MIRRORED_FIREFOX_VERSION_ERROR = "Couldn't resolve latest firefox version from the configured mirror"; + +const installFirefoxBrowser = async ( + version: string, + { + force = false, + browserDownloadMirrors, + }: { force?: boolean; browserDownloadMirrors?: BrowserDownloadMirrors } = {}, +): Promise => { const platform = getBrowserPlatform(); const existingLocallyBrowserVersion = registry.getMatchedBrowserVersion(BrowserName.FIREFOX, platform, version); @@ -28,39 +39,64 @@ const installFirefoxBrowser = async (version: string, { force = false } = {}): P const buildId = getFirefoxBuildId(normalizedVersion); const cacheDir = getBrowsersDir(); - const canBeInstalled = await canDownload({ browser: BrowserName.FIREFOX, platform, buildId, cacheDir }); + const mirror = getBrowserDownloadMirror(BrowserName.FIREFOX, browserDownloadMirrors); + const canBeInstalled = await canDownload({ + browser: BrowserName.FIREFOX, + platform, + buildId, + cacheDir, + baseUrl: mirror, + }); if (!canBeInstalled) { throw new Error( - [ - `firefox@${version} can't be installed.`, - `Probably the version '${version}' is invalid, please try another version.`, - "Version examples: '120', '130.0', '131.0'", - ].join("\n"), + mirror + ? `Couldn't download browser artifact from the configured mirror: ${mirror}` + : [ + `firefox@${version} can't be installed.`, + `Probably the version '${version}' is invalid, please try another version.`, + "Version examples: '120', '130.0', '131.0'", + ].join("\n"), ); } browserInstallerDebug(`installing firefox@${buildId} for ${platform}`); - const installFn = (downloadProgressCallback: DownloadProgressCallback): Promise => - puppeteerInstall({ + const installFn = (downloadProgressCallback: DownloadProgressCallback): Promise => { + if (mirror) { + browserInstallerDebug(`downloading ${BrowserName.FIREFOX}@${buildId} from mirror ${mirror}`); + } + + return puppeteerInstall({ platform, buildId, cacheDir, downloadProgressCallback, browser: BrowserName.FIREFOX, + baseUrl: mirror, unpack: true, }).then(result => result.executablePath); + }; return registry.installBinary(BrowserName.FIREFOX, platform, buildId, installFn); }; export const installFirefox = async ( version: string, - { force = false, needWebDriver = false, needUbuntuPackages = false } = {}, + { + force = false, + needWebDriver = false, + needUbuntuPackages = false, + browserDownloadMirrors, + }: { + force?: boolean; + needWebDriver?: boolean; + needUbuntuPackages?: boolean; + browserDownloadMirrors?: BrowserDownloadMirrors; + } = {}, ): Promise => { const [browserPath] = await Promise.all([ - installFirefoxBrowser(version, { force }), + installFirefoxBrowser(version, { force, browserDownloadMirrors }), needWebDriver && installLatestGeckoDriver(version, { force }), needUbuntuPackages && installUbuntuPackageDependencies(), ]); @@ -68,20 +104,45 @@ export const installFirefox = async ( return browserPath; }; -export const resolveLatestFirefoxVersion = _.memoize(async (force = false): Promise => { - if (!force) { - const platform = getBrowserPlatform(); - const existingLocallyBrowserVersion = registry.getMatchedBrowserVersion(BrowserName.FIREFOX, platform); +export const resolveLatestFirefoxVersion = _.memoize( + async (force = false, browserDownloadMirrors?: BrowserDownloadMirrors): Promise => { + if (!force) { + const platform = getBrowserPlatform(); + const existingLocallyBrowserVersion = registry.getMatchedBrowserVersion(BrowserName.FIREFOX, platform); - if (existingLocallyBrowserVersion) { - return existingLocallyBrowserVersion; + if (existingLocallyBrowserVersion) { + return existingLocallyBrowserVersion; + } + } + + const mirror = getBrowserDownloadMirror(BrowserName.FIREFOX, browserDownloadMirrors); + + if (mirror) { + const response = await retryFetch( + getBrowserDownloadMirrorFileUrl(mirror, FIREFOX_VERSIONS_LATEST_VERSIONS_FILENAME), + ); + + if (!response.ok) { + throw new Error(MIRRORED_FIREFOX_VERSION_ERROR); + } + + const data = (await response.json()) as { LATEST_FIREFOX_VERSION?: unknown } | null; + const version = data?.LATEST_FIREFOX_VERSION; + + if (typeof version !== "string" || !LATEST_FIREFOX_VERSION_PATTERN.test(version.trim())) { + throw new Error(MIRRORED_FIREFOX_VERSION_ERROR); + } + + return version.trim(); } - } - return retryFetch(FIREFOX_VERSIONS_LATEST_VERSIONS_API_URL) - .then(res => res.json()) - .then(({ LATEST_FIREFOX_VERSION }) => LATEST_FIREFOX_VERSION) - .catch(() => { - throw new Error("Couldn't resolve latest firefox version"); - }); -}); + return retryFetch(FIREFOX_VERSIONS_LATEST_VERSIONS_API_URL) + .then(res => res.json()) + .then(({ LATEST_FIREFOX_VERSION }) => LATEST_FIREFOX_VERSION) + .catch(() => { + throw new Error("Couldn't resolve latest firefox version"); + }); + }, + (force, browserDownloadMirrors) => + `${force}:${getBrowserDownloadMirror(BrowserName.FIREFOX, browserDownloadMirrors) ?? "default"}`, +); diff --git a/src/browser-installer/install.ts b/src/browser-installer/install.ts index 65ff3f400..294b29929 100644 --- a/src/browser-installer/install.ts +++ b/src/browser-installer/install.ts @@ -2,6 +2,7 @@ import _ from "lodash"; import { browserInstallerDebug, type SupportedBrowser } from "./utils"; import { getNormalizedBrowserName } from "../utils/browser"; import { BrowserName } from "../browser/types"; +import type { BrowserDownloadMirrors } from "../config/types"; /** * @returns path to installed browser binary @@ -9,8 +10,19 @@ import { BrowserName } from "../browser/types"; export const installBrowser = async ( browserName: SupportedBrowser, browserVersion?: string, - { force = false, shouldInstallWebDriver = false, shouldInstallUbuntuPackages = false } = {}, + { + force = false, + shouldInstallWebDriver = false, + shouldInstallUbuntuPackages = false, + browserDownloadMirrors, + }: { + force?: boolean; + shouldInstallWebDriver?: boolean; + shouldInstallUbuntuPackages?: boolean; + browserDownloadMirrors?: BrowserDownloadMirrors; + } = {}, ): Promise => { + const normalizedBrowserVersion = browserVersion?.replace(/^(\d+)$/, "$1.0"); const { isUbuntu } = await import("./ubuntu-packages"); const needUbuntuPackages = shouldInstallUbuntuPackages && (await isUbuntu()); @@ -30,25 +42,33 @@ export const installBrowser = async ( case BrowserName.CHROMEHEADLESSSHELL: { const { installChrome, resolveLatestChromeVersion } = await import("./chrome"); const w3cBrowserName = browserName === BrowserName.CHROMIUM ? BrowserName.CHROME : browserName; - const version = browserVersion || (await resolveLatestChromeVersion(force)); + const version = + normalizedBrowserVersion || (await resolveLatestChromeVersion(force, browserDownloadMirrors)); return installChrome(w3cBrowserName, version, { force, needUbuntuPackages, needWebDriver: shouldInstallWebDriver, + browserDownloadMirrors, }); } case BrowserName.FIREFOX: { const { installFirefox, resolveLatestFirefoxVersion } = await import("./firefox"); - const version = browserVersion || (await resolveLatestFirefoxVersion(force)); + const version = + normalizedBrowserVersion || (await resolveLatestFirefoxVersion(force, browserDownloadMirrors)); - return installFirefox(version, { force, needUbuntuPackages, needWebDriver: shouldInstallWebDriver }); + return installFirefox(version, { + force, + needUbuntuPackages, + needWebDriver: shouldInstallWebDriver, + browserDownloadMirrors, + }); } case BrowserName.EDGE: { const { installEdgeDriver, resolveEdgeVersion } = await import("./edge"); - const version = browserVersion || (await resolveEdgeVersion()); + const version = normalizedBrowserVersion || (await resolveEdgeVersion()); if (shouldInstallWebDriver) { await installEdgeDriver(version, { force }); @@ -83,9 +103,15 @@ const forceInstallBinaries = async ( installFn: typeof installBrowser, browserName?: string, browserVersion?: string, + browserDownloadMirrors?: BrowserDownloadMirrors, ): ForceInstallBinaryResult => { const normalizedBrowserName = getNormalizedBrowserName(browserName); - const installOpts = { force: true, shouldInstallWebDriver: true, shouldInstallUbuntuPackages: true }; + const installOpts = { + force: true, + shouldInstallWebDriver: true, + shouldInstallUbuntuPackages: true, + browserDownloadMirrors, + }; if (!normalizedBrowserName) { return { @@ -108,6 +134,7 @@ const forceInstallBinaries = async ( export const installBrowsersWithDrivers = async ( browsersToInstall: { browserName?: string; browserVersion?: string }[], + { browserDownloadMirrors }: { browserDownloadMirrors?: BrowserDownloadMirrors } = {}, ): Promise>> => { const uniqBrowsers = _.uniqBy(browsersToInstall, b => `${b.browserName}@${b.browserVersion}`); const installPromises = [] as Promise[]; @@ -115,7 +142,7 @@ export const installBrowsersWithDrivers = async ( for (const { browserName, browserVersion } of uniqBrowsers) { installPromises.push( - forceInstallBinaries(installBrowser, browserName, browserVersion).then(result => { + forceInstallBinaries(installBrowser, browserName, browserVersion, browserDownloadMirrors).then(result => { const key = browserVersion ? `${browserName}@${browserVersion}` : String(browserName); browsersInstallResult[key] = result; diff --git a/src/browser-installer/mirrors.ts b/src/browser-installer/mirrors.ts new file mode 100644 index 000000000..dad2bb97e --- /dev/null +++ b/src/browser-installer/mirrors.ts @@ -0,0 +1,76 @@ +import { URL } from "node:url"; + +import type { BrowserDownloadMirrors } from "../config/types"; +import { BrowserName } from "../browser/types"; +import type { SupportedBrowser } from "./utils"; + +export const normalizeBrowserDownloadMirror = (mirror: unknown, optionName: string): string => { + if (typeof mirror !== "string") { + throw new Error(`"${optionName}" must be a string`); + } + + const trimmedMirror = mirror.trim(); + + if (!trimmedMirror) { + throw new Error(`"${optionName}" must not be empty`); + } + + let url: URL; + + try { + url = new URL(trimmedMirror); + } catch { + throw new Error(`"${optionName}" must be a valid URL, got ${mirror}`); + } + + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`"${optionName}" must be an absolute http: or https:, got ${url.protocol}`); + } + + if (url.username || url.password) { + throw new Error(`"${optionName}" must not contain a username or password`); + } + + if (trimmedMirror.includes("?")) { + throw new Error(`"${optionName}" must not contain a query string`); + } + + if (trimmedMirror.includes("#")) { + throw new Error(`"${optionName}" must not contain a fragment`); + } + + return url.toString().replace(/\/+$/, ""); +}; + +export const getBrowserDownloadMirror = ( + browserName: SupportedBrowser, + mirrors?: BrowserDownloadMirrors, +): string | undefined => { + let mirrorName: keyof BrowserDownloadMirrors; + + switch (browserName) { + case BrowserName.CHROME: + case BrowserName.CHROMEHEADLESSSHELL: + mirrorName = "chrome"; + break; + case BrowserName.CHROMIUM: + mirrorName = "chromium"; + break; + case BrowserName.FIREFOX: + mirrorName = "firefox"; + break; + default: + return undefined; + } + + const mirror = mirrors?.[mirrorName]; + + if (mirror === null || mirror === undefined) { + return undefined; + } + + return normalizeBrowserDownloadMirror(mirror, `browserDownloadMirrors.${mirrorName}`); +}; + +export const getBrowserDownloadMirrorFileUrl = (mirror: string, filename: string): string => + new URL(filename, `${mirror.replace(/\/+$/, "")}/`).toString(); diff --git a/src/browser-installer/resolve-browser-version.ts b/src/browser-installer/resolve-browser-version.ts index 8d8696f74..777e505e4 100644 --- a/src/browser-installer/resolve-browser-version.ts +++ b/src/browser-installer/resolve-browser-version.ts @@ -1,12 +1,21 @@ import { BrowserName, type W3CBrowserName } from "../browser/types"; +import type { BrowserDownloadMirrors } from "../config/types"; -export const resolveBrowserVersion = (browserName: W3CBrowserName, { force = false } = {}): Promise => { +export const resolveBrowserVersion = ( + browserName: W3CBrowserName, + { + force = false, + browserDownloadMirrors, + }: { force?: boolean; browserDownloadMirrors?: BrowserDownloadMirrors } = {}, +): Promise => { switch (browserName) { case BrowserName.CHROME: case BrowserName.CHROMEHEADLESSSHELL: - return import("./chrome").then(module => module.resolveLatestChromeVersion(force)); + return import("./chrome").then(module => module.resolveLatestChromeVersion(force, browserDownloadMirrors)); case BrowserName.FIREFOX: - return import("./firefox").then(module => module.resolveLatestFirefoxVersion(force)); + return import("./firefox").then(module => + module.resolveLatestFirefoxVersion(force, browserDownloadMirrors), + ); case BrowserName.EDGE: return import("./edge").then(module => module.resolveEdgeVersion()); case BrowserName.SAFARI: diff --git a/src/browser-installer/run.ts b/src/browser-installer/run.ts index f1c07cee8..8b9ba6784 100644 --- a/src/browser-installer/run.ts +++ b/src/browser-installer/run.ts @@ -2,13 +2,21 @@ import type { ChildProcess } from "child_process"; import { installBrowser } from "./install"; import type { SupportedBrowser } from "./utils"; import { BrowserName } from "../browser/types"; +import type { BrowserDownloadMirrors } from "../config/types"; export const runBrowserDriver = async ( browserName: SupportedBrowser, browserVersion: string, - { debug = false } = {}, + { + debug = false, + browserDownloadMirrors, + }: { debug?: boolean; browserDownloadMirrors?: BrowserDownloadMirrors } = {}, ): Promise<{ gridUrl: string; process: ChildProcess; port: number }> => { - const installBrowserOpts = { shouldInstallWebDriver: true, shouldInstallUbuntuPackages: true }; + const installBrowserOpts = { + shouldInstallWebDriver: true, + shouldInstallUbuntuPackages: true, + browserDownloadMirrors, + }; await installBrowser(browserName, browserVersion, installBrowserOpts); @@ -16,7 +24,9 @@ export const runBrowserDriver = async ( case BrowserName.CHROME: case BrowserName.CHROMIUM: case BrowserName.CHROMEHEADLESSSHELL: - return import("./chrome").then(module => module.runChromeDriver(browserVersion, { debug })); + return import("./chrome").then(module => + module.runChromeDriver(browserVersion, { debug, browserDownloadMirrors }), + ); case BrowserName.FIREFOX: return import("./firefox").then(module => module.runGeckoDriver(browserVersion, { debug })); case BrowserName.EDGE: diff --git a/src/browser-installer/ubuntu-packages/apt.ts b/src/browser-installer/ubuntu-packages/apt.ts index 64c88239c..acb01cc58 100644 --- a/src/browser-installer/ubuntu-packages/apt.ts +++ b/src/browser-installer/ubuntu-packages/apt.ts @@ -145,6 +145,8 @@ export const installUbuntuPackages = async ( browserInstallerDebug(`There are ${dependenciesToDownload.length} deb packages to download`); if (!dependenciesToDownload.length) { + downloadProgressCallback(100); + return; } diff --git a/src/browser-pool/webdriver-pool.ts b/src/browser-pool/webdriver-pool.ts index 78ba84e9a..ad67e71d4 100644 --- a/src/browser-pool/webdriver-pool.ts +++ b/src/browser-pool/webdriver-pool.ts @@ -1,6 +1,7 @@ import type { ChildProcess } from "child_process"; import { getNormalizedBrowserName } from "../utils/browser"; import type { SupportedBrowser } from "../browser-installer"; +import type { BrowserDownloadMirrors } from "../config/types"; type BrowserVersion = string; type Port = string; @@ -19,7 +20,10 @@ export class WebdriverPool { async getWebdriver( browserName?: string, browserVersion?: string, - { debug = false } = {}, + { + debug = false, + browserDownloadMirrors, + }: { debug?: boolean; browserDownloadMirrors?: BrowserDownloadMirrors } = {}, ): ReturnType { const browserNameNormalized = getNormalizedBrowserName(browserName); @@ -33,7 +37,8 @@ export class WebdriverPool { } const { resolveBrowserVersion } = await import("../browser-installer"); - const browserVersionNormalized = browserVersion || (await resolveBrowserVersion(browserNameNormalized)); + const browserVersionNormalized = + browserVersion || (await resolveBrowserVersion(browserNameNormalized, { browserDownloadMirrors })); const wdProcesses = this.driverProcess.get(browserNameNormalized)?.get(browserVersionNormalized) ?? {}; @@ -50,7 +55,10 @@ export class WebdriverPool { } } - return this.createWebdriverProcess(browserNameNormalized, browserVersionNormalized, { debug }); + return this.createWebdriverProcess(browserNameNormalized, browserVersionNormalized, { + debug, + browserDownloadMirrors, + }); } private freeWebdriver(port: Port): void { @@ -75,10 +83,16 @@ export class WebdriverPool { private async createWebdriverProcess( browserName: SupportedBrowser, browserVersion: string, - { debug = false } = {}, + { + debug = false, + browserDownloadMirrors, + }: { debug?: boolean; browserDownloadMirrors?: BrowserDownloadMirrors } = {}, ): Promise { const { runBrowserDriver } = await import("../browser-installer"); - const driver = await runBrowserDriver(browserName, browserVersion, { debug }); + const driver = await runBrowserDriver(browserName, browserVersion, { + debug, + browserDownloadMirrors, + }); if (!this.driverProcess.has(browserName)) { this.driverProcess.set(browserName, new Map()); diff --git a/src/browser/new-browser.ts b/src/browser/new-browser.ts index 3acc10a3d..2aaf4f5f2 100644 --- a/src/browser/new-browser.ts +++ b/src/browser/new-browser.ts @@ -56,14 +56,17 @@ const headlessBrowserOptions: HeadlessBrowserOptions = { }; export class NewBrowser extends Browser { + private _rootConfig: Config; private _onExit: (err?: Error) => Promise = async () => {}; private _initPromise: Promise | null = null; private _quitPromise: Promise | null = null; - private _killPromise: Promise | null = null; + private _cleanupPromise: Promise | null = null; + private _shouldKillWebdriver = false; constructor(config: Config, opts: BrowserOpts) { super(config, opts); + this._rootConfig = config; this._onExit = async (err?: Error): Promise => await this.quit(err); signalHandler.on("exit", this._onExit); } @@ -75,13 +78,19 @@ export class NewBrowser extends Browser { } private async _init(): Promise { - this._session = await this._createSession(); + try { + this._session = await this._createSession(); + + this._addCommands(); + this.restoreHttpTimeout(); + await this._setPageLoadTimeout(); - this._addCommands(); - this.restoreHttpTimeout(); - await this._setPageLoadTimeout(); + return this; + } catch (error) { + await this.kill(); - return this; + throw error; + } } reset(): Promise { @@ -103,33 +112,52 @@ export class NewBrowser extends Browser { private async _quit(): Promise { try { await this._initPromise; - this.setHttpTimeout(this._config.sessionQuitTimeout); - await this._session!.deleteSession(); - this._wdProcess?.free(); - } catch (e) { - warn(`WARNING: Can not close session: ${(e as Error).message}`); - this._wdProcess?.kill(); - } finally { - this._wdProcess = null; + } catch { + return; } + + await this._cleanup({ canReuseWebdriver: true }); } kill(): Promise { - if (this._killPromise) { - return this._killPromise; + return this._cleanup({ canReuseWebdriver: false }); + } + + private _cleanup({ canReuseWebdriver }: { canReuseWebdriver: boolean }): Promise { + if (!canReuseWebdriver) { + this._shouldKillWebdriver = true; } - this._killPromise = this._kill(); + this._cleanupPromise ??= this._performCleanup(canReuseWebdriver); - return this._killPromise; + return this._cleanupPromise; } - private async _kill(): Promise { + private async _performCleanup(canReuseWebdriver: boolean): Promise { + signalHandler.off("exit", this._onExit); + try { - await this._session!.deleteSession(); - this._wdProcess?.kill(); - } catch (e) { - warn(`WARNING: Can not kill WebDriver process: ${(e as Error).message}`); + if (this._session) { + if (canReuseWebdriver) { + this.setHttpTimeout(this._config.sessionQuitTimeout); + } + + await this._session.deleteSession(); + } + } catch (error) { + this._shouldKillWebdriver = true; + const warningSubject = canReuseWebdriver ? "close session" : "kill WebDriver process"; + + warn(`WARNING: Can not ${warningSubject}: ${(error as Error)?.message ?? String(error)}`); + } finally { + if (canReuseWebdriver && this._session && !this._shouldKillWebdriver) { + this._wdProcess?.free(); + } else { + this._wdProcess?.kill(); + } + + // Pool cleanup still reads session metadata after cancellation. + this._wdProcess = null; } } @@ -278,7 +306,10 @@ export class NewBrowser extends Browser { this._wdProcess = await this._wdPool.getWebdriver( this._config.desiredCapabilities?.browserName, this._config.desiredCapabilities?.browserVersion, - { debug: this._config.system.debug }, + { + debug: this._rootConfig.system.debug, + browserDownloadMirrors: this._rootConfig.browserDownloadMirrors, + }, ); return this._wdProcess.gridUrl; @@ -303,6 +334,7 @@ export class NewBrowser extends Browser { const executablePath = await installBrowser(browserNameW3C, config.desiredCapabilities?.browserVersion, { shouldInstallWebDriver: false, shouldInstallUbuntuPackages: true, + browserDownloadMirrors: this._rootConfig.browserDownloadMirrors, }); if (executablePath) { diff --git a/src/browser/standalone/launchBrowser.ts b/src/browser/standalone/launchBrowser.ts index c32b286a6..93834005e 100644 --- a/src/browser/standalone/launchBrowser.ts +++ b/src/browser/standalone/launchBrowser.ts @@ -56,6 +56,7 @@ export async function launchBrowser( const filesToRemove: string[] = []; const config = new Config({ + browserDownloadMirrors: options.browserDownloadMirrors, browsers: { [browserName]: browserConfig, }, diff --git a/src/browser/standalone/types.ts b/src/browser/standalone/types.ts index d2ec8c205..dd134d49c 100644 --- a/src/browser/standalone/types.ts +++ b/src/browser/standalone/types.ts @@ -1,4 +1,4 @@ -import type { CommonConfig, SystemConfig } from "../../config/types"; +import type { BrowserDownloadMirrorsInput, CommonConfig, SystemConfig } from "../../config/types"; export type StandaloneBrowserOptions = Pick< CommonConfig, @@ -25,4 +25,5 @@ export type StandaloneBrowserOptions = Pick< export type StandaloneBrowserOptionsInput = Partial> & { system?: Partial; + browserDownloadMirrors?: BrowserDownloadMirrorsInput; }; diff --git a/src/cli/commands/install-deps/index.ts b/src/cli/commands/install-deps/index.ts index 7b25e4725..b2e6f75e1 100644 --- a/src/cli/commands/install-deps/index.ts +++ b/src/cli/commands/install-deps/index.ts @@ -61,7 +61,9 @@ export const registerCmd = (cliTool: typeof commander, testplane: Testplane): vo } }); - const browsersInstallResult = await installBrowsersWithDrivers(browsersToInstall); + const browsersInstallResult = await installBrowsersWithDrivers(browsersToInstall, { + browserDownloadMirrors: testplane.config.browserDownloadMirrors, + }); const browserTags = Object.keys(browsersInstallResult); const browsersInstallPerStatus: BrowsersInstallPerStatus = { [BrowserInstallStatus.Ok]: [], diff --git a/src/config/defaults.js b/src/config/defaults.js index 63b09ae5b..5bc8c8316 100644 --- a/src/config/defaults.js +++ b/src/config/defaults.js @@ -6,6 +6,11 @@ const { TimeTravelMode } = require("./types"); module.exports = { baseUrl: "http://localhost", + browserDownloadMirrors: { + chrome: null, + chromium: null, + firefox: null, + }, gridUrl: LOCAL_GRID_URL, browserWSEndpoint: null, desiredCapabilities: null, diff --git a/src/config/index.ts b/src/config/index.ts index 6dc75c02e..146fde60c 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -206,5 +206,11 @@ export class Config { // all functions are transformed to strings and all regular expressions to empty objects return typeof l === typeof r ? r : l; }); + + // Mirrors are serialized in the master and must replace worker values even when + // an unset worker value is null and the configured master value is a string. + if (config.browserDownloadMirrors) { + this.browserDownloadMirrors = { ...config.browserDownloadMirrors }; + } } } diff --git a/src/config/options.js b/src/config/options.js index e870b0700..35476472f 100644 --- a/src/config/options.js +++ b/src/config/options.js @@ -5,14 +5,33 @@ const { root, section, map, option } = require("gemini-configparser"); const browserOptions = require("./browser-options"); const defaults = require("./defaults"); const optionsBuilder = require("./options-builder"); -const { NODEJS_TEST_RUN_ENV, BROWSER_TEST_RUN_ENV, ENV_PREFIXES } = require("../constants/config"); +const { normalizeBrowserDownloadMirror } = require("../browser-installer/mirrors"); +const { + NODEJS_TEST_RUN_ENV, + BROWSER_TEST_RUN_ENV, + ENV_PREFIXES, + BROWSER_DOWNLOAD_MIRRORS_ENV_VARS, +} = require("../constants/config"); const options = optionsBuilder(_.propertyOf(defaults)); +const browserDownloadMirrorOption = browserName => + option({ + defaultValue: defaults.browserDownloadMirrors[browserName], + map: value => + value === null ? value : normalizeBrowserDownloadMirror(value, `browserDownloadMirrors.${browserName}`), + }); + const rootSection = section( _.extend(browserOptions.getTopLevel(), { browsers: map(section(browserOptions.getPerBrowser())), + browserDownloadMirrors: section({ + chrome: browserDownloadMirrorOption("chrome"), + chromium: browserDownloadMirrorOption("chromium"), + firefox: browserDownloadMirrorOption("firefox"), + }), + prepareEnvironment: options.optionalFunction("prepareEnvironment"), beforeAll: options.optionalFunction("beforeAll"), afterAll: options.optionalFunction("afterAll"), @@ -264,4 +283,16 @@ const rootSection = section( }), ); -module.exports = root(rootSection, { envPrefix: ENV_PREFIXES }); +const parseOptions = root(rootSection, { envPrefix: ENV_PREFIXES }); + +module.exports = ({ env, ...config }) => { + const normalizedEnv = { ...env }; + + for (const envName of Object.values(BROWSER_DOWNLOAD_MIRRORS_ENV_VARS)) { + if (Object.prototype.hasOwnProperty.call(env, envName)) { + normalizedEnv[envName.toLowerCase()] = env[envName]; + } + } + + return parseOptions({ ...config, env: normalizedEnv }); +}; diff --git a/src/config/types.ts b/src/config/types.ts index 9bd43e318..471335ad1 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -365,6 +365,14 @@ export type SelectivityMapSourceMapUrlFn = (assetInfo: { sourceMapUrl: string; }) => string | boolean | void; +export interface BrowserDownloadMirrors { + chrome: string | null; + chromium: string | null; + firefox: string | null; +} + +export type BrowserDownloadMirrorsInput = Partial; + export interface CommonConfig { configPath?: string; automationProtocol: "webdriver"; @@ -529,6 +537,7 @@ export type HookType = (params: { config: Config }) => Promise | unknow // Only browsers desiredCapabilities are required in input config export type ConfigInputData = Partial & { browsers: Record; + browserDownloadMirrors?: BrowserDownloadMirrorsInput; plugins?: Record; sets?: Record; prepareEnvironment?: () => void | Promise | null; @@ -541,6 +550,7 @@ export type ConfigInput = ConfigInputData | (() => ConfigInputData) | (() => Pro export interface ConfigParsed extends CommonConfig { browsers: Record; + browserDownloadMirrors: BrowserDownloadMirrors; plugins: Record>; sets: Record; prepareEnvironment?: () => void | Promise | null; diff --git a/src/constants/config.ts b/src/constants/config.ts index 913ce546b..e51608129 100644 --- a/src/constants/config.ts +++ b/src/constants/config.ts @@ -2,6 +2,12 @@ import { BrowserName, type W3CBrowserName } from "../browser/types"; export const ENV_PREFIXES = ["testplane_", "hermione_"]; +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", +} as const; + export const WEBDRIVER_PROTOCOL = "webdriver"; export const SAVE_HISTORY_MODE = { ALL: "all", diff --git a/src/index.ts b/src/index.ts index cab66d13c..954d33437 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,7 +35,7 @@ export type { } from "./types"; export type { Config, ProfilerConfig, ProfilerLevel } from "./config"; export { TimeTravelMode, SelectivityMode } from "./config"; -export type { ConfigInput, AssertViewOpts } from "./config/types"; +export type { ConfigInput, AssertViewOpts, BrowserDownloadMirrors, BrowserDownloadMirrorsInput } from "./config/types"; export type { ProfilerResultV1 } from "./profiler/schema"; export type { TestCollection, diff --git a/test/fixtures/browser-installer/chrome-linux64.zip b/test/fixtures/browser-installer/chrome-linux64.zip new file mode 100644 index 0000000000000000000000000000000000000000..05cffb5c2215e13092cc654cb4a1e36db7fb8f9a GIT binary patch literal 172 zcmWIWW@Zs#fB;1XVU`7t6o4EM76szujH3M9RNb7+ywVCY6MYD`B(=DtASW>|RRO|R zNXx7!DJ@D>$W1KDPA%dJ@MdHZVa9DJ&|ENh+X$ktS`pyQ$_A2T1VU#Z9Sq_y000|& BByRu! literal 0 HcmV?d00001 diff --git a/test/src/browser-installer/browser-version-contract.ts b/test/src/browser-installer/browser-version-contract.ts new file mode 100644 index 000000000..cd29fdb9e --- /dev/null +++ b/test/src/browser-installer/browser-version-contract.ts @@ -0,0 +1,192 @@ +import { BrowserPlatform } from "@puppeteer/browsers"; +import fs from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import type { AddressInfo } from "node:net"; +import { URL } from "node:url"; +import proxyquire from "proxyquire"; + +import type { installBrowser as InstallBrowser } from "../../../src/browser-installer/install"; +import * as browserInstallerUtils from "../../../src/browser-installer/utils"; +import { BrowserName } from "../../../src/browser/types"; + +const MIRROR_PATH_PREFIX = "/nested/cache/chrome"; +const METADATA_PATH = `${MIRROR_PATH_PREFIX}/latest-versions-per-milestone.json`; +const FIXTURE_ARCHIVE_PATH = path.resolve(__dirname, "../../fixtures/browser-installer/chrome-linux64.zip"); +const FIXTURE_MARKER = "testplane chrome fixture marker\n"; +const BUILDS_BY_MILESTONE = { + "139": "139.0.7258.1", + "144": "144.0.7559.1", +} as const; + +type RecordedRequest = { + method: string; + pathname: string; +}; + +type RegistryContents = { + binaries: Record>; +}; + +const appendNoProxy = (value: string | undefined): string => + [value, "127.0.0.1", "localhost"].filter(Boolean).join(","); + +const createInstallBrowser = (cacheDir: string): typeof InstallBrowser => { + const installerUtils = { + ...browserInstallerUtils, + getBrowserPlatform: (): BrowserPlatform => BrowserPlatform.LINUX, + getBrowsersDir: (): string => path.join(cacheDir, "browsers"), + getRegistryPath: (): string => path.join(cacheDir, "registry.json"), + }; + const registry = proxyquire("../../../src/browser-installer/registry", { + "../utils": installerUtils, + }).default; + const installChrome = proxyquire("../../../src/browser-installer/chrome/browser", { + "../registry": { default: registry }, + "../utils": installerUtils, + }).installChrome; + + return proxyquire("../../../src/browser-installer/install", { + "./chrome": { installChrome }, + "./ubuntu-packages": { isUbuntu: async (): Promise => false }, + }).installBrowser as typeof InstallBrowser; +}; + +describe("browser-installer numeric browser version contract", () => { + let server: http.Server; + let tempDir: string; + let fixtureArchive: Buffer; + let savedNoProxy: string | undefined; + let savedNoProxyLowercase: string | undefined; + + const requests: RecordedRequest[] = []; + + before(async () => { + fixtureArchive = await fs.promises.readFile(FIXTURE_ARCHIVE_PATH); + }); + + beforeEach(async () => { + requests.length = 0; + tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "testplane-browser-version-contract-")); + savedNoProxy = process.env.NO_PROXY; + savedNoProxyLowercase = process.env["no_proxy"]; + process.env.NO_PROXY = appendNoProxy(savedNoProxy); + process.env["no_proxy"] = appendNoProxy(savedNoProxyLowercase); + server = http.createServer((request, response) => { + const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1"); + const method = request.method ?? "GET"; + + requests.push({ method, pathname: requestUrl.pathname }); + + if (requestUrl.pathname === METADATA_PATH) { + const body = JSON.stringify({ + milestones: Object.fromEntries( + Object.entries(BUILDS_BY_MILESTONE).map(([milestone, version]) => [milestone, { version }]), + ), + }); + + response.writeHead(200, { + "content-type": "application/json", + "content-length": Buffer.byteLength(body), + }); + response.end(body); + return; + } + + const isKnownArchive = Object.values(BUILDS_BY_MILESTONE).some( + buildId => requestUrl.pathname === `${MIRROR_PATH_PREFIX}/${buildId}/linux64/chrome-linux64.zip`, + ); + + if (isKnownArchive && (method === "HEAD" || method === "GET")) { + response.writeHead(200, { + "content-type": "application/zip", + "content-length": fixtureArchive.length, + }); + response.end(method === "GET" ? fixtureArchive : undefined); + return; + } + + response.writeHead(404); + response.end("not found"); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + }); + + afterEach(async () => { + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + await fs.promises.rm(tempDir, { recursive: true, force: true }); + + if (savedNoProxy === undefined) { + delete process.env.NO_PROXY; + } else { + process.env.NO_PROXY = savedNoProxy; + } + + if (savedNoProxyLowercase === undefined) { + delete process.env["no_proxy"]; + } else { + process.env["no_proxy"] = savedNoProxyLowercase; + } + }); + + it("should resolve and install numeric and explicit-decimal Chrome milestones identically", async function () { + this.timeout(30_000); + + const { port } = server.address() as AddressInfo; + const mirror = `http://127.0.0.1:${port}${MIRROR_PATH_PREFIX}`; + + for (const [milestone, buildId] of Object.entries(BUILDS_BY_MILESTONE)) { + const results: Array<{ relativeInstalledPath: string; requests: RecordedRequest[] }> = []; + + for (const version of [milestone, `${milestone}.0`]) { + const cacheDir = path.join(tempDir, `cache-${version}`); + const requestOffset = requests.length; + const installedPath = await createInstallBrowser(cacheDir)(BrowserName.CHROME, version, { + force: true, + browserDownloadMirrors: { chrome: mirror, chromium: null, firefox: null }, + }); + + if (!installedPath) { + throw new Error("Chrome installation unexpectedly returned no path"); + } + + const archivePath = `${MIRROR_PATH_PREFIX}/${buildId}/linux64/chrome-linux64.zip`; + const caseRequests = requests.slice(requestOffset); + const relativeInstalledPath = path.relative(cacheDir, installedPath); + const expectedInstalledPath = path.join( + "browsers", + "chrome", + `linux-${buildId}`, + "chrome-linux64", + "chrome", + ); + const registryPath = path.join(cacheDir, "registry.json"); + const registryContents = JSON.parse( + await fs.promises.readFile(registryPath, "utf8"), + ) as RegistryContents; + + assert.deepEqual(caseRequests, [ + { method: "GET", pathname: METADATA_PATH }, + { method: "HEAD", pathname: archivePath }, + { method: "GET", pathname: archivePath }, + ]); + assert.equal(relativeInstalledPath, expectedInstalledPath); + assert.equal(await fs.promises.readFile(installedPath, "utf8"), FIXTURE_MARKER); + assert.equal( + path.resolve(registryPath, registryContents.binaries.chrome_linux[buildId]), + installedPath, + ); + + results.push({ relativeInstalledPath, requests: caseRequests }); + } + + assert.deepEqual(results[0], results[1]); + } + }); +}); diff --git a/test/src/browser-installer/chrome/browser.ts b/test/src/browser-installer/chrome/browser.ts index e72427203..f98bbf82b 100644 --- a/test/src/browser-installer/chrome/browser.ts +++ b/test/src/browser-installer/chrome/browser.ts @@ -8,6 +8,11 @@ import type { describe("browser-installer/chrome/browser", () => { const sandbox = sinon.createSandbox(); + const browserDownloadMirrors = { + chrome: "https://mirror.example/chrome", + chromium: "https://mirror.example/chromium", + firefox: null, + }; let installChrome: typeof InstallChromeType; let resolveLatestChromeVersion: typeof ResolveLatestChromeVersionType; @@ -26,6 +31,8 @@ describe("browser-installer/chrome/browser", () => { let installChromeDriverStub: SinonStub; let installUbuntuPackageDependenciesStub: SinonStub; + let resolveChromeBuildIdFromMirrorStub: SinonStub; + let browserInstallerDebugStub: SinonStub; beforeEach(() => { installChromiumStub = sandbox.stub().resolves("/chromium/browser/path"); @@ -42,11 +49,14 @@ describe("browser-installer/chrome/browser", () => { installChromeDriverStub = sandbox.stub(); installUbuntuPackageDependenciesStub = sandbox.stub(); + resolveChromeBuildIdFromMirrorStub = sandbox.stub().resolves("115.0.5790.170"); + browserInstallerDebugStub = sandbox.stub(); ({ installChrome, resolveLatestChromeVersion } = proxyquire( "../../../../src/browser-installer/chrome/browser", { "./driver": { installChromeDriver: installChromeDriverStub }, + "./utils": { resolveChromeBuildIdFromMirror: resolveChromeBuildIdFromMirrorStub }, "../chromium": { installChromium: installChromiumStub }, "../ubuntu-packages": { installUbuntuPackageDependencies: installUbuntuPackageDependenciesStub }, "@puppeteer/browsers": { @@ -57,6 +67,7 @@ describe("browser-installer/chrome/browser", () => { "../utils": { ...require("src/browser-installer/utils"), retryFetch: retryFetchStub, + browserInstallerDebug: browserInstallerDebugStub, }, "../registry": { default: { @@ -76,11 +87,12 @@ describe("browser-installer/chrome/browser", () => { getMatchedBrowserVersionStub.withArgs(BrowserName.CHROME, sinon.match.string, "115").returns("115.0"); getBinaryPathStub.withArgs(BrowserName.CHROME, sinon.match.string, "115.0").returns("/browser/path"); - const binaryPath = await installChrome(BrowserName.CHROME, "115"); + const binaryPath = await installChrome(BrowserName.CHROME, "115", { browserDownloadMirrors }); assert.equal(binaryPath, "/browser/path"); assert.notCalled(resolveBuildIdStub); assert.notCalled(installBinaryStub); + assert.neverCalledWith(browserInstallerDebugStub, sinon.match(/from mirror/)); }); it("should not try to resolve browser path locally with 'force' flag", async () => { @@ -109,6 +121,116 @@ describe("browser-installer/chrome/browser", () => { assert.equal(binaryPath, "/new/downloaded/browser/path"); }); + it("should resolve and download browser from mirror", async () => { + installBinaryStub.callsFake((_browserName, _platform, _version, installFn) => installFn(sandbox.stub())); + + await installChrome(BrowserName.CHROME, "115", { browserDownloadMirrors }); + + assert.notCalled(resolveBuildIdStub); + assert.calledOnceWith(resolveChromeBuildIdFromMirrorStub, "115", browserDownloadMirrors.chrome); + assert.calledOnceWith( + canDownloadStub, + sinon.match({ + browser: BrowserName.CHROME, + buildId: "115.0.5790.170", + baseUrl: browserDownloadMirrors.chrome, + }), + ); + assert.calledOnceWith( + puppeteerInstallStub, + sinon.match({ + browser: BrowserName.CHROME, + buildId: "115.0.5790.170", + baseUrl: browserDownloadMirrors.chrome, + }), + ); + assert.calledWithExactly( + browserInstallerDebugStub, + `downloading chrome@115.0.5790.170 from mirror ${browserDownloadMirrors.chrome}`, + ); + }); + + it("should log the chrome mirror when downloading headless shell", async () => { + installBinaryStub.callsFake((_browserName, _platform, _version, installFn) => installFn(sandbox.stub())); + + await installChrome(BrowserName.CHROMEHEADLESSSHELL, "115", { browserDownloadMirrors }); + + assert.calledWithExactly( + browserInstallerDebugStub, + `downloading chrome-headless-shell@115.0.5790.170 from mirror ${browserDownloadMirrors.chrome}`, + ); + }); + + it("should not log a mirror download when the registry reuses a binary", async () => { + installBinaryStub.resolves("/already/installed/browser/path"); + + await installChrome(BrowserName.CHROME, "115", { browserDownloadMirrors }); + + assert.notCalled(puppeteerInstallStub); + assert.neverCalledWith(browserInstallerDebugStub, sinon.match(/from mirror/)); + }); + + it("should normalize the mirror before sharing the lazy build resolver", async () => { + const paddedMirrors = { + ...browserDownloadMirrors, + chrome: " https://mirror.example/chrome/// ", + }; + + await installChrome(BrowserName.CHROME, "115", { browserDownloadMirrors: paddedMirrors }); + + assert.calledOnceWith(resolveChromeBuildIdFromMirrorStub, "115", "https://mirror.example/chrome"); + assert.calledOnceWith( + canDownloadStub, + sinon.match({ + browser: BrowserName.CHROME, + baseUrl: "https://mirror.example/chrome", + }), + ); + }); + + it("should preserve the mirrored browser artifact download error", async () => { + const downloadError = new Error(`Download failed. URL: ${browserDownloadMirrors.chrome}/115/chrome.zip`); + + puppeteerInstallStub.rejects(downloadError); + installBinaryStub.callsFake((_browserName, _platform, _version, installFn) => installFn(sandbox.stub())); + + const error = await installChrome(BrowserName.CHROME, "115", { browserDownloadMirrors }).catch( + error => error, + ); + + assert.strictEqual(error, downloadError); + }); + + it("should report a configured mirror error when the browser artifact is unavailable", async () => { + canDownloadStub.resolves(false); + + const error = await installChrome(BrowserName.CHROME, "115", { browserDownloadMirrors }).catch( + error => error, + ); + + assert.instanceOf(error, Error); + assert.equal( + error.message, + `Couldn't download browser artifact from the configured mirror: ${browserDownloadMirrors.chrome}`, + ); + assert.neverCalledWith(browserInstallerDebugStub, sinon.match(/from mirror/)); + }); + + it("should not probe mirror artifact URLs when metadata cannot resolve the selector", async () => { + const error = new Error( + "Couldn't resolve Chrome-for-Testing build ID for selector '999' from mirror 'https://mirror.example/chrome'", + ); + + resolveChromeBuildIdFromMirrorStub.rejects(error); + + await assert.isRejected( + installChrome(BrowserName.CHROME, "999", { browserDownloadMirrors }), + error.message, + ); + assert.notCalled(canDownloadStub); + assert.notCalled(installBinaryStub); + }); + it("should use chromium browser download if version is too low", async () => { getMatchedBrowserVersionStub.returns(null); installChromiumStub.withArgs("80").resolves("/browser/chromium/path"); @@ -120,6 +242,15 @@ describe("browser-installer/chrome/browser", () => { assert.notCalled(installBinaryStub); }); + it("should pass mirrors to chromium fallback", async () => { + await installChrome(BrowserName.CHROME, "80", { browserDownloadMirrors }); + + assert.calledOnceWith(installChromiumStub, "80", { + force: false, + browserDownloadMirrors, + }); + }); + it("should throw an error if can't download the browser", async () => { getMatchedBrowserVersionStub.withArgs(BrowserName.CHROME, sinon.match.string, "115").returns(null); resolveBuildIdStub.withArgs(BrowserName.CHROME, sinon.match.string, "115").resolves("115"); @@ -138,7 +269,65 @@ describe("browser-installer/chrome/browser", () => { it("should try to install chromedriver if 'needWebDriver' is set", async () => { await installChrome(BrowserName.CHROME, "115", { needWebDriver: true }); - assert.calledOnceWith(installChromeDriverStub, "115", { force: false }); + assert.calledOnceWith(installChromeDriverStub, "115", { + force: false, + browserDownloadMirrors: undefined, + resolveMirrorBuildId: undefined, + }); + }); + + it("should pass mirrors to chromedriver installer", async () => { + await installChrome(BrowserName.CHROME, "115", { needWebDriver: true, browserDownloadMirrors }); + + assert.calledOnceWith( + installChromeDriverStub, + "115", + sinon.match({ + force: false, + browserDownloadMirrors, + resolveMirrorBuildId: sinon.match.func, + }), + ); + }); + + it("should share one mirror build resolution between paired browser and driver installs", async () => { + let driverBuildId: string | undefined; + + resolveChromeBuildIdFromMirrorStub.onFirstCall().resolves("115.0.5790.170"); + resolveChromeBuildIdFromMirrorStub.onSecondCall().resolves("115.0.5790.171"); + installBinaryStub.callsFake((_browserName, _platform, _version, installFn) => installFn(sandbox.stub())); + installChromeDriverStub.callsFake(async (_version, options) => { + driverBuildId = await options.resolveMirrorBuildId(); + + return "/chrome/driver/path"; + }); + + await installChrome(BrowserName.CHROME, "115", { needWebDriver: true, browserDownloadMirrors }); + + assert.calledOnceWith(resolveChromeBuildIdFromMirrorStub, "115", browserDownloadMirrors.chrome); + assert.equal(driverBuildId, "115.0.5790.170"); + assert.calledOnceWith( + puppeteerInstallStub, + sinon.match({ + browser: BrowserName.CHROME, + buildId: driverBuildId, + baseUrl: browserDownloadMirrors.chrome, + }), + ); + }); + + it("should resolve a shared mirror build lazily", async () => { + getMatchedBrowserVersionStub + .withArgs(BrowserName.CHROME, sinon.match.string, "115") + .returns("115.0.5790.170"); + getBinaryPathStub + .withArgs(BrowserName.CHROME, sinon.match.string, "115.0.5790.170") + .returns("/browser/path"); + installChromeDriverStub.resolves("/driver/path"); + + await installChrome(BrowserName.CHROME, "115", { needWebDriver: true, browserDownloadMirrors }); + + assert.notCalled(resolveChromeBuildIdFromMirrorStub); }); it("should try to install ubuntu dependencies if 'needWebDriver' is set", async () => { @@ -150,9 +339,7 @@ describe("browser-installer/chrome/browser", () => { describe("resolveLatestChromeVersion", () => { beforeEach(() => { - const apiUrl = "https://googlechromelabs.github.io/chrome-for-testing/LATEST_RELEASE_STABLE"; - - retryFetchStub.withArgs(apiUrl).resolves({ text: () => Promise.resolve("100.0.500.0") }); + retryFetchStub.resolves({ text: () => Promise.resolve(" \n100.0.500.0\t") }); }); it("should resolve local version", async () => { @@ -173,6 +360,18 @@ describe("browser-installer/chrome/browser", () => { assert.calledOnce(retryFetchStub); }); + it("should resolve network version from mirror", async () => { + resolveChromeBuildIdFromMirrorStub + .withArgs("stable", browserDownloadMirrors.chrome) + .resolves("101.0.500.0"); + + const version = await resolveLatestChromeVersion(false, browserDownloadMirrors); + + assert.equal(version, "101.0.500.0"); + assert.calledOnceWith(resolveChromeBuildIdFromMirrorStub, "stable", browserDownloadMirrors.chrome); + assert.notCalled(retryFetchStub); + }); + it("should resolve network version on force mode", async () => { getMatchedBrowserVersionStub.withArgs(BrowserName.CHROME, sinon.match.string).returns("500.0.100.0"); diff --git a/test/src/browser-installer/chrome/driver.ts b/test/src/browser-installer/chrome/driver.ts index dd387a3ce..77f6ebf20 100644 --- a/test/src/browser-installer/chrome/driver.ts +++ b/test/src/browser-installer/chrome/driver.ts @@ -5,6 +5,11 @@ import { DriverName } from "../../../../src/browser-installer/utils"; describe("browser-installer/chrome/driver", () => { const sandbox = sinon.createSandbox(); + const browserDownloadMirrors = { + chrome: "https://mirror.example/chrome", + chromium: null, + firefox: null, + }; let installChromeDriver: typeof InstallChromeDriverType; @@ -13,10 +18,12 @@ describe("browser-installer/chrome/driver", () => { let resolveBuildIdStub: SinonStub; let puppeteerInstallStub: SinonStub; let canDownloadStub: SinonStub; + let resolveChromeBuildIdFromMirrorStub: SinonStub; let getBinaryPathStub: SinonStub; let getMatchedDriverVersionStub: SinonStub; let installBinaryStub: SinonStub; + let browserInstallerDebugStub: SinonStub; beforeEach(() => { installChromeDriverManuallyStub = sandbox.stub().resolves({ executablePath: "/chromium/driver/path" }); @@ -24,10 +31,12 @@ describe("browser-installer/chrome/driver", () => { puppeteerInstallStub = sandbox.stub().resolves({ executablePath: "/chrome/driver/path" }); resolveBuildIdStub = sandbox.stub().resolves("115.0.5780.170"); canDownloadStub = sandbox.stub().resolves(true); + resolveChromeBuildIdFromMirrorStub = sandbox.stub().resolves("115.0.5790.170"); getBinaryPathStub = sandbox.stub().returns(null); getMatchedDriverVersionStub = sandbox.stub().returns(null); installBinaryStub = sandbox.stub(); + browserInstallerDebugStub = sandbox.stub(); installChromeDriver = proxyquire("../../../../src/browser-installer/chrome/driver", { "../chromium": { installChromeDriverManually: installChromeDriverManuallyStub }, @@ -36,6 +45,11 @@ describe("browser-installer/chrome/driver", () => { install: puppeteerInstallStub, canDownload: canDownloadStub, }, + "./utils": { resolveChromeBuildIdFromMirror: resolveChromeBuildIdFromMirrorStub }, + "../utils": { + ...require("src/browser-installer/utils"), + browserInstallerDebug: browserInstallerDebugStub, + }, "../registry": { default: { getBinaryPath: getBinaryPathStub, @@ -52,11 +66,12 @@ describe("browser-installer/chrome/driver", () => { getMatchedDriverVersionStub.withArgs(DriverName.CHROMEDRIVER, sinon.match.string, "115").returns("115.0"); getBinaryPathStub.withArgs(DriverName.CHROMEDRIVER, sinon.match.string, "115.0").returns("/driver/path"); - const driverPath = await installChromeDriver("115"); + const driverPath = await installChromeDriver("115", { browserDownloadMirrors }); assert.equal(driverPath, "/driver/path"); assert.notCalled(resolveBuildIdStub); assert.notCalled(installBinaryStub); + assert.neverCalledWith(browserInstallerDebugStub, sinon.match(/from mirror/)); }); it("should not try to resolve driver path locally with 'force' flag", async () => { @@ -84,6 +99,89 @@ describe("browser-installer/chrome/driver", () => { assert.equal(driverPath, "/new/downloaded/driver/path"); }); + it("should resolve and download driver from mirror", async () => { + installBinaryStub.callsFake((_driverName, _platform, _version, installFn) => installFn(sandbox.stub())); + + await installChromeDriver("115", { browserDownloadMirrors }); + + assert.notCalled(resolveBuildIdStub); + assert.calledOnceWith(resolveChromeBuildIdFromMirrorStub, "115", browserDownloadMirrors.chrome); + assert.calledOnceWith( + canDownloadStub, + sinon.match({ + browser: DriverName.CHROMEDRIVER, + buildId: "115.0.5790.170", + baseUrl: browserDownloadMirrors.chrome, + }), + ); + assert.calledOnceWith( + puppeteerInstallStub, + sinon.match({ + browser: DriverName.CHROMEDRIVER, + buildId: "115.0.5790.170", + baseUrl: browserDownloadMirrors.chrome, + }), + ); + assert.calledWithExactly( + browserInstallerDebugStub, + `downloading chromedriver@115.0.5790.170 from mirror ${browserDownloadMirrors.chrome}`, + ); + }); + + it("should preserve the mirrored driver artifact download error", async () => { + const downloadError = new Error(`Download failed. URL: ${browserDownloadMirrors.chrome}/115/chromedriver.zip`); + + puppeteerInstallStub.rejects(downloadError); + installBinaryStub.callsFake((_driverName, _platform, _version, installFn) => installFn(sandbox.stub())); + + const error = await installChromeDriver("115", { browserDownloadMirrors }).catch(error => error); + + assert.strictEqual(error, downloadError); + }); + + it("should report a configured mirror error when the driver artifact is unavailable", async () => { + canDownloadStub.resolves(false); + + const error = await installChromeDriver("115", { browserDownloadMirrors }).catch(error => error); + + assert.instanceOf(error, Error); + assert.equal( + error.message, + `Couldn't download browser artifact from the configured mirror: ${browserDownloadMirrors.chrome}`, + ); + assert.neverCalledWith(browserInstallerDebugStub, sinon.match(/from mirror/)); + }); + + it("should use a shared mirror build resolver when provided", async () => { + const resolveMirrorBuildIdStub = sandbox.stub().resolves("115.0.5790.171"); + + installBinaryStub.callsFake((_driverName, _platform, _version, installFn) => installFn(sandbox.stub())); + + await installChromeDriver("115", { + browserDownloadMirrors, + resolveMirrorBuildId: resolveMirrorBuildIdStub, + }); + + assert.calledOnceWith(resolveMirrorBuildIdStub); + assert.notCalled(resolveChromeBuildIdFromMirrorStub); + assert.calledOnceWith( + canDownloadStub, + sinon.match({ + browser: DriverName.CHROMEDRIVER, + buildId: "115.0.5790.171", + baseUrl: browserDownloadMirrors.chrome, + }), + ); + assert.calledOnceWith( + puppeteerInstallStub, + sinon.match({ + browser: DriverName.CHROMEDRIVER, + buildId: "115.0.5790.171", + baseUrl: browserDownloadMirrors.chrome, + }), + ); + }); + it("should use chromium driver manual download if version is too low", async () => { getMatchedDriverVersionStub.returns(null); installChromeDriverManuallyStub.withArgs("80").resolves("/driver/manual/path"); @@ -93,6 +191,19 @@ describe("browser-installer/chrome/driver", () => { assert.equal(result, "/driver/manual/path"); assert.notCalled(resolveBuildIdStub); assert.notCalled(installBinaryStub); + assert.neverCalledWith(browserInstallerDebugStub, sinon.match(/from mirror/)); + }); + + it("should keep ChromeDriver below 115 on the legacy source when a mirror is configured", async () => { + getMatchedDriverVersionStub.returns(null); + installChromeDriverManuallyStub.withArgs("114").resolves("/driver/manual/path"); + + const result = await installChromeDriver("114", { browserDownloadMirrors }); + + assert.equal(result, "/driver/manual/path"); + assert.notCalled(resolveChromeBuildIdFromMirrorStub); + assert.notCalled(resolveBuildIdStub); + assert.notCalled(installBinaryStub); }); it("should throw an error if can't download the driver", async () => { diff --git a/test/src/browser-installer/chrome/index.ts b/test/src/browser-installer/chrome/index.ts index 9906508a6..116926bbd 100644 --- a/test/src/browser-installer/chrome/index.ts +++ b/test/src/browser-installer/chrome/index.ts @@ -5,6 +5,11 @@ import type { runChromeDriver as RunChromeDriverType } from "../../../../src/bro describe("browser-installer/chrome", () => { const sandbox = sinon.createSandbox(); + const browserDownloadMirrors = { + chrome: "https://mirror.example/chrome", + chromium: null, + firefox: null, + }; let runChromeDriver: typeof RunChromeDriverType; @@ -55,10 +60,31 @@ describe("browser-installer/chrome", () => { await runChromeDriver("130"); - assert.calledOnceWith(installChromeDriverStub, "130"); + assert.calledOnceWithExactly(installChromeDriverStub, "130", { browserDownloadMirrors: undefined }); assert.calledOnceWith(spawnStub, "/driver/path", ["--port=10050", "--silent"]); }); + it("should pass browser download mirrors to driver installation", async () => { + sandbox.stub(process, "once"); + + await runChromeDriver("stable", { browserDownloadMirrors }); + + assert.calledOnceWithExactly(installChromeDriverStub, "stable", { browserDownloadMirrors }); + }); + + 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 }; + + await runChromeDriver("130", { + browserDownloadMirrors: emptyBrowserDownloadMirrors, + }); + + assert.calledOnceWithExactly(installChromeDriverStub, "130", { + browserDownloadMirrors: emptyBrowserDownloadMirrors, + }); + }); + it("should wait for port to be active", async () => { getPortStub.resolves(10050); diff --git a/test/src/browser-installer/chrome/utils.ts b/test/src/browser-installer/chrome/utils.ts new file mode 100644 index 000000000..ff5e101d4 --- /dev/null +++ b/test/src/browser-installer/chrome/utils.ts @@ -0,0 +1,179 @@ +import proxyquire from "proxyquire"; +import sinon, { type SinonStub } from "sinon"; +import type { resolveChromeBuildIdFromMirror as ResolveChromeBuildIdFromMirror } from "../../../../src/browser-installer/chrome/utils"; + +describe("browser-installer/chrome/utils", () => { + const sandbox = sinon.createSandbox(); + const mirror = "https://mirror.example/chrome"; + + let retryFetchStub: SinonStub; + let resolveChromeBuildIdFromMirror: typeof ResolveChromeBuildIdFromMirror; + + beforeEach(() => { + retryFetchStub = sandbox.stub(); + resolveChromeBuildIdFromMirror = proxyquire("../../../../src/browser-installer/chrome/utils", { + "../utils": { + ...require("src/browser-installer/utils"), + retryFetch: retryFetchStub, + }, + }).resolveChromeBuildIdFromMirror; + }); + + afterEach(() => sandbox.restore()); + + it("should resolve milestone from mirrored metadata", async () => { + retryFetchStub.resolves({ + ok: true, + json: () => Promise.resolve({ milestones: { "115": { version: "115.0.5790.170" } } }), + }); + + const result = await resolveChromeBuildIdFromMirror("115", mirror); + + assert.equal(result, "115.0.5790.170"); + assert.calledOnceWith(retryFetchStub, `${mirror}/latest-versions-per-milestone.json`); + }); + + it("should resolve two-part version from mirrored milestone metadata", async () => { + retryFetchStub.resolves({ + ok: true, + json: () => Promise.resolve({ milestones: { "115": { version: "115.0.5790.170" } } }), + }); + + const result = await resolveChromeBuildIdFromMirror("115.0", mirror); + + assert.equal(result, "115.0.5790.170"); + assert.calledOnceWith(retryFetchStub, `${mirror}/latest-versions-per-milestone.json`); + }); + + it("should resolve build prefix from mirrored build metadata", async () => { + retryFetchStub.resolves({ + ok: true, + json: () => Promise.resolve({ builds: { "115.0.5790": { version: "115.0.5790.170" } } }), + }); + + const result = await resolveChromeBuildIdFromMirror("115.0.5790", mirror); + + assert.equal(result, "115.0.5790.170"); + assert.calledOnceWith(retryFetchStub, `${mirror}/latest-patch-versions-per-build.json`); + }); + + it("should use exact version without metadata request", async () => { + const result = await resolveChromeBuildIdFromMirror("115.0.5790.170", mirror); + + assert.equal(result, "115.0.5790.170"); + assert.notCalled(retryFetchStub); + }); + + for (const [selector, channel] of [ + ["stable", "STABLE"], + ["beta", "BETA"], + ["dev", "DEV"], + ["canary", "CANARY"], + ["latest", "CANARY"], + ]) { + it(`should resolve ${selector} channel from mirror`, async () => { + retryFetchStub.resolves({ ok: true, text: () => Promise.resolve(" \n115.0.5790.170\t") }); + + const result = await resolveChromeBuildIdFromMirror(selector, mirror); + + assert.equal(result, "115.0.5790.170"); + assert.calledOnceWith(retryFetchStub, `${mirror}/LATEST_RELEASE_${channel}`); + }); + } + + for (const [selector, metadata] of [ + ["999", { milestones: {} }], + ["115.0.9999", { builds: {} }], + ] as const) { + it(`should throw a clear error when ${selector} cannot be resolved`, async () => { + retryFetchStub.resolves({ ok: true, json: () => Promise.resolve(metadata) }); + + await assert.isRejected( + resolveChromeBuildIdFromMirror(selector, mirror), + `Couldn't resolve Chrome-for-Testing build ID for selector '${selector}' from the configured mirror`, + ); + }); + } + + it("should reject milestone metadata for a different milestone", async () => { + retryFetchStub.resolves({ + ok: true, + json: () => Promise.resolve({ milestones: { "115": { version: "116.0.5845.96" } } }), + }); + + await assert.isRejected( + resolveChromeBuildIdFromMirror("115", mirror), + `Couldn't resolve Chrome-for-Testing build ID for selector '115' from the configured mirror`, + ); + }); + + it("should reject milestone metadata that does not match a two-part selector", async () => { + retryFetchStub.resolves({ + ok: true, + json: () => Promise.resolve({ milestones: { "115": { version: "115.1.5790.170" } } }), + }); + + await assert.isRejected( + resolveChromeBuildIdFromMirror("115.0", mirror), + `Couldn't resolve Chrome-for-Testing build ID for selector '115.0' from the configured mirror`, + ); + }); + + it("should normalize malformed mirror metadata errors", async () => { + retryFetchStub.resolves({ ok: true, json: () => Promise.resolve(null) }); + + await assert.isRejected( + resolveChromeBuildIdFromMirror("115", mirror), + `Couldn't resolve Chrome-for-Testing build ID for selector '115' from the configured mirror`, + ); + }); + + it("should preserve the original network error", async () => { + const networkError = new Error(`Failed to fetch ${mirror}/latest-versions-per-milestone.json`); + + retryFetchStub.rejects(networkError); + + const error = (await resolveChromeBuildIdFromMirror("115", mirror).catch(err => err)) as Error; + + assert.strictEqual(error, networkError); + }); + + it("should throw a clear error when channel metadata is empty", async () => { + retryFetchStub.resolves({ ok: true, text: () => Promise.resolve(" \n") }); + + await assert.isRejected( + resolveChromeBuildIdFromMirror("stable", mirror), + `Couldn't resolve Chrome-for-Testing build ID for selector 'stable' from the configured mirror`, + ); + }); + + it("should reject unsupported selectors without requesting metadata", async () => { + await assert.isRejected( + resolveChromeBuildIdFromMirror("nightly", mirror), + `Couldn't resolve Chrome-for-Testing build ID for selector 'nightly' from the configured mirror`, + ); + assert.notCalled(retryFetchStub); + }); + + it("should reject an unsuccessful metadata response even when its body contains a valid version", async () => { + retryFetchStub.resolves({ ok: false, text: () => Promise.resolve("115.0.5790.170") }); + + await assert.isRejected( + resolveChromeBuildIdFromMirror("stable", mirror), + "Couldn't resolve Chrome-for-Testing build ID for selector 'stable' from the configured mirror", + ); + }); + + it("should reject an HTML response instead of a version", async () => { + const payload = `Download failed from ${mirror}/LATEST_RELEASE_STABLE`; + + retryFetchStub.resolves({ ok: true, text: () => Promise.resolve(payload) }); + + const error = (await resolveChromeBuildIdFromMirror("stable", mirror).catch(err => err)) as Error; + + assert.equal( + error.message, + "Couldn't resolve Chrome-for-Testing build ID for selector 'stable' from the configured mirror", + ); + }); +}); diff --git a/test/src/browser-installer/chromium/browser.ts b/test/src/browser-installer/chromium/browser.ts index ae19055fe..31f93bd39 100644 --- a/test/src/browser-installer/chromium/browser.ts +++ b/test/src/browser-installer/chromium/browser.ts @@ -5,6 +5,11 @@ import { BrowserName } from "../../../../src/browser/types"; describe("browser-installer/chromium/browser", () => { const sandbox = sinon.createSandbox(); + const browserDownloadMirrors = { + chrome: null, + chromium: "https://mirror.example/chromium", + firefox: null, + }; let installChromium: typeof InstallChromiumType; @@ -15,6 +20,7 @@ describe("browser-installer/chromium/browser", () => { let getBinaryPathStub: SinonStub; let getMatchedBrowserVersionStub: SinonStub; let installBinaryStub: SinonStub; + let browserInstallerDebugStub: SinonStub; beforeEach(() => { puppeteerInstallStub = sandbox.stub().resolves({ executablePath: "/chromium/browser/path" }); @@ -24,6 +30,7 @@ describe("browser-installer/chromium/browser", () => { getBinaryPathStub = sandbox.stub().returns(null); getMatchedBrowserVersionStub = sandbox.stub().returns(null); installBinaryStub = sandbox.stub(); + browserInstallerDebugStub = sandbox.stub(); installChromium = proxyquire("../../../../src/browser-installer/chromium/browser", { "@puppeteer/browsers": { @@ -31,6 +38,10 @@ describe("browser-installer/chromium/browser", () => { canDownload: canDownloadStub, }, "./utils": { getChromiumBuildId: getChromiumBuildIdStub }, + "../utils": { + ...require("src/browser-installer/utils"), + browserInstallerDebug: browserInstallerDebugStub, + }, "../registry": { default: { getBinaryPath: getBinaryPathStub, @@ -47,11 +58,12 @@ describe("browser-installer/chromium/browser", () => { getMatchedBrowserVersionStub.withArgs(BrowserName.CHROMIUM, sinon.match.string, "80").returns("80"); getBinaryPathStub.withArgs(BrowserName.CHROMIUM, sinon.match.string, "80").returns("/browser/path"); - const binaryPath = await installChromium("80"); + const binaryPath = await installChromium("80", { browserDownloadMirrors }); assert.equal(binaryPath, "/browser/path"); assert.notCalled(getChromiumBuildIdStub); assert.notCalled(installBinaryStub); + assert.neverCalledWith(browserInstallerDebugStub, sinon.match(/from mirror/)); }); it("should not try to resolve browser path locally with 'force' flag", async () => { @@ -80,6 +92,57 @@ describe("browser-installer/chromium/browser", () => { assert.equal(binaryPath, "/new/downloaded/browser/path"); }); + it("should download browser from mirror", async () => { + installBinaryStub.callsFake((_browserName, _platform, _version, installFn) => installFn(sandbox.stub())); + + await installChromium("80", { browserDownloadMirrors }); + + assert.calledOnceWith( + canDownloadStub, + sinon.match({ + browser: BrowserName.CHROMIUM, + buildId: "100500", + baseUrl: browserDownloadMirrors.chromium, + }), + ); + assert.calledOnceWith( + puppeteerInstallStub, + sinon.match({ + browser: BrowserName.CHROMIUM, + buildId: "100500", + baseUrl: browserDownloadMirrors.chromium, + }), + ); + assert.calledWithExactly( + browserInstallerDebugStub, + `downloading chromium@100500 from mirror ${browserDownloadMirrors.chromium}`, + ); + }); + + it("should preserve the mirrored browser artifact download error", async () => { + const downloadError = new Error(`Download failed. URL: ${browserDownloadMirrors.chromium}/100500/chromium.zip`); + + puppeteerInstallStub.rejects(downloadError); + installBinaryStub.callsFake((_browserName, _platform, _version, installFn) => installFn(sandbox.stub())); + + const error = await installChromium("80", { browserDownloadMirrors }).catch(error => error); + + assert.strictEqual(error, downloadError); + }); + + it("should report a configured mirror error when the browser artifact is unavailable", async () => { + canDownloadStub.resolves(false); + + const error = await installChromium("80", { browserDownloadMirrors }).catch(error => error); + + assert.instanceOf(error, Error); + assert.equal( + error.message, + `Couldn't download browser artifact from the configured mirror: ${browserDownloadMirrors.chromium}`, + ); + assert.neverCalledWith(browserInstallerDebugStub, sinon.match(/from mirror/)); + }); + it("should throw an error if version is too low", async () => { getMatchedBrowserVersionStub.returns(null); diff --git a/test/src/browser-installer/firefox/browser.ts b/test/src/browser-installer/firefox/browser.ts index 5bb745c73..fa584ff49 100644 --- a/test/src/browser-installer/firefox/browser.ts +++ b/test/src/browser-installer/firefox/browser.ts @@ -8,6 +8,11 @@ import type { describe("browser-installer/firefox/browser", () => { const sandbox = sinon.createSandbox(); + const browserDownloadMirrors = { + chrome: null, + chromium: null, + firefox: "https://mirror.example/firefox", + }; let installFirefox: typeof InstallFirefoxType; let resolveLatestFirefoxVersion: typeof ResolveLatestFirefoxVersionType; @@ -23,6 +28,7 @@ describe("browser-installer/firefox/browser", () => { let installLatestGeckoDriverStub: SinonStub; let installUbuntuPackageDependenciesStub: SinonStub; + let browserInstallerDebugStub: SinonStub; beforeEach(() => { puppeteerInstallStub = sandbox.stub().resolves({ executablePath: "/firefox/browser/path" }); @@ -36,6 +42,7 @@ describe("browser-installer/firefox/browser", () => { installLatestGeckoDriverStub = sandbox.stub(); installUbuntuPackageDependenciesStub = sandbox.stub(); + browserInstallerDebugStub = sandbox.stub(); ({ installFirefox, resolveLatestFirefoxVersion } = proxyquire( "../../../../src/browser-installer/firefox/browser", @@ -49,6 +56,7 @@ describe("browser-installer/firefox/browser", () => { "../utils": { ...require("src/browser-installer/utils"), retryFetch: retryFetchStub, + browserInstallerDebug: browserInstallerDebugStub, }, "../registry": { default: { @@ -68,10 +76,11 @@ describe("browser-installer/firefox/browser", () => { getMatchedBrowserVersionStub.withArgs(BrowserName.FIREFOX, sinon.match.string, "115").returns("115.0"); getBinaryPathStub.withArgs(BrowserName.FIREFOX, sinon.match.string, "115.0").returns("/browser/path"); - const binaryPath = await installFirefox("115"); + const binaryPath = await installFirefox("115", { browserDownloadMirrors }); assert.equal(binaryPath, "/browser/path"); assert.notCalled(installBinaryStub); + assert.neverCalledWith(browserInstallerDebugStub, sinon.match(/from mirror/)); }); it("should not try to resolve browser path locally with 'force' flag", async () => { @@ -99,6 +108,57 @@ describe("browser-installer/firefox/browser", () => { assert.equal(binaryPath, "/new/downloaded/browser/path"); }); + it("should download browser from mirror", async () => { + installBinaryStub.callsFake((_browserName, _platform, _version, installFn) => installFn(sandbox.stub())); + + await installFirefox("115", { browserDownloadMirrors }); + + assert.calledOnceWith( + canDownloadStub, + sinon.match({ + browser: BrowserName.FIREFOX, + buildId: "stable_115.0", + baseUrl: browserDownloadMirrors.firefox, + }), + ); + assert.calledOnceWith( + puppeteerInstallStub, + sinon.match({ + browser: BrowserName.FIREFOX, + buildId: "stable_115.0", + baseUrl: browserDownloadMirrors.firefox, + }), + ); + assert.calledWithExactly( + browserInstallerDebugStub, + `downloading firefox@stable_115.0 from mirror ${browserDownloadMirrors.firefox}`, + ); + }); + + it("should preserve the mirrored browser artifact download error", async () => { + const downloadError = new Error(`Download failed. URL: ${browserDownloadMirrors.firefox}/115/firefox.zip`); + + puppeteerInstallStub.rejects(downloadError); + installBinaryStub.callsFake((_browserName, _platform, _version, installFn) => installFn(sandbox.stub())); + + const error = await installFirefox("115", { browserDownloadMirrors }).catch(error => error); + + assert.strictEqual(error, downloadError); + }); + + it("should report a configured mirror error when the browser artifact is unavailable", async () => { + canDownloadStub.resolves(false); + + const error = await installFirefox("115", { browserDownloadMirrors }).catch(error => error); + + assert.instanceOf(error, Error); + assert.equal( + error.message, + `Couldn't download browser artifact from the configured mirror: ${browserDownloadMirrors.firefox}`, + ); + assert.neverCalledWith(browserInstallerDebugStub, sinon.match(/from mirror/)); + }); + it("should throw an error if can't download the browser", async () => { getMatchedBrowserVersionStub.withArgs(BrowserName.FIREFOX, sinon.match.string, "115").returns(null); canDownloadStub.resolves(false); @@ -153,6 +213,58 @@ describe("browser-installer/firefox/browser", () => { assert.calledOnce(retryFetchStub); }); + it("should resolve network version from mirror", async () => { + retryFetchStub + .withArgs("https://mirror.example/firefox/firefox_versions.json") + .resolves({ ok: true, json: () => Promise.resolve({ LATEST_FIREFOX_VERSION: "101.0" }) }); + + const version = await resolveLatestFirefoxVersion(false, browserDownloadMirrors); + + assert.equal(version, "101.0"); + assert.calledOnceWith(retryFetchStub, "https://mirror.example/firefox/firefox_versions.json"); + }); + + it("should reject an unsuccessful mirror response even when its body contains a valid version", async () => { + retryFetchStub.resolves({ + ok: false, + json: () => Promise.resolve({ LATEST_FIREFOX_VERSION: "101.0" }), + }); + + await assert.isRejected( + resolveLatestFirefoxVersion(false, browserDownloadMirrors), + "Couldn't resolve latest firefox version from the configured mirror", + ); + }); + + it("should preserve mirrored latest version request errors", async () => { + const requestError = new Error(`Failed to fetch ${browserDownloadMirrors.firefox}/firefox_versions.json`); + + retryFetchStub.rejects(requestError); + + const error = await resolveLatestFirefoxVersion(false, browserDownloadMirrors).catch(error => error); + + assert.strictEqual(error, requestError); + }); + + for (const [name, value] of [ + ["missing", undefined], + ["wrong-type", 101], + ["empty", " "], + ["unsafe", `101.0 from ${browserDownloadMirrors.firefox}`], + ]) { + it(`should reject ${name} latest version mirror metadata`, async () => { + retryFetchStub.resolves({ + ok: true, + json: () => Promise.resolve({ LATEST_FIREFOX_VERSION: value }), + }); + + await assert.isRejected( + resolveLatestFirefoxVersion(false, browserDownloadMirrors), + "Couldn't resolve latest firefox version from the configured mirror", + ); + }); + } + it("should resolve network version on force mode", async () => { getMatchedBrowserVersionStub.withArgs(BrowserName.FIREFOX, sinon.match.string).returns("500.100"); diff --git a/test/src/browser-installer/install.ts b/test/src/browser-installer/install.ts index ff5fd7646..98b3bbe94 100644 --- a/test/src/browser-installer/install.ts +++ b/test/src/browser-installer/install.ts @@ -8,15 +8,23 @@ import { BrowserName } from "../../../src/browser/types"; describe("browser-installer/install", () => { const sandbox = sinon.createSandbox(); + const browserDownloadMirrors = { + chrome: "https://mirror.example/chrome", + chromium: "https://mirror.example/chromium", + firefox: "https://mirror.example/firefox", + }; let installBrowser: typeof InstallBrowser; let installBrowsersWithDrivers: typeof InstallBrowsersWithDrivers; let installChromeStub: SinonStub; let installChromeDriverStub: SinonStub; + let resolveLatestChromeVersionStub: SinonStub; let installFirefoxStub: SinonStub; let installLatestGeckoDriverStub: SinonStub; + let resolveLatestFirefoxVersionStub: SinonStub; let installEdgeDriverStub: SinonStub; + let resolveEdgeVersionStub: SinonStub; let isUbuntuStub: SinonStub; let installUbuntuPackageDependenciesStub: SinonStub; @@ -24,17 +32,28 @@ describe("browser-installer/install", () => { beforeEach(() => { installChromeStub = sandbox.stub(); installChromeDriverStub = sandbox.stub(); + resolveLatestChromeVersionStub = sandbox.stub(); installFirefoxStub = sandbox.stub(); installLatestGeckoDriverStub = sandbox.stub(); + resolveLatestFirefoxVersionStub = sandbox.stub(); installEdgeDriverStub = sandbox.stub(); + resolveEdgeVersionStub = sandbox.stub(); isUbuntuStub = sandbox.stub().resolves(false); installUbuntuPackageDependenciesStub = sandbox.stub().resolves(); const installer = proxyquire("../../../src/browser-installer/install", { - "./chrome": { installChrome: installChromeStub, installChromeDriver: installChromeDriverStub }, - "./edge": { installEdgeDriver: installEdgeDriverStub }, - "./firefox": { installFirefox: installFirefoxStub, installLatestGeckoDriver: installLatestGeckoDriverStub }, + "./chrome": { + installChrome: installChromeStub, + installChromeDriver: installChromeDriverStub, + resolveLatestChromeVersion: resolveLatestChromeVersionStub, + }, + "./edge": { installEdgeDriver: installEdgeDriverStub, resolveEdgeVersion: resolveEdgeVersionStub }, + "./firefox": { + installFirefox: installFirefoxStub, + installLatestGeckoDriver: installLatestGeckoDriverStub, + resolveLatestFirefoxVersion: resolveLatestFirefoxVersionStub, + }, "./ubuntu-packages": { isUbuntu: isUbuntuStub, installUbuntuPackageDependencies: installUbuntuPackageDependenciesStub, @@ -48,24 +67,82 @@ describe("browser-installer/install", () => { afterEach(() => sandbox.restore()); describe(`installBrowser`, () => { + describe("browser version normalization", () => { + const installOptions = { + force: false, + needUbuntuPackages: false, + needWebDriver: false, + browserDownloadMirrors, + }; + + it("should append .0 to digit-only versions for Chrome browser variants", async () => { + await installBrowser(BrowserName.CHROME, "139", { browserDownloadMirrors }); + await installBrowser(BrowserName.CHROMIUM, "144", { browserDownloadMirrors }); + await installBrowser(BrowserName.CHROMEHEADLESSSHELL, "145", { browserDownloadMirrors }); + + assert.calledWithExactly(installChromeStub, BrowserName.CHROME, "139.0", installOptions); + assert.calledWithExactly(installChromeStub, BrowserName.CHROME, "144.0", installOptions); + assert.calledWithExactly(installChromeStub, BrowserName.CHROMEHEADLESSSHELL, "145.0", installOptions); + }); + + it("should append .0 to a digit-only Firefox version", async () => { + await installBrowser(BrowserName.FIREFOX, "144", { browserDownloadMirrors }); + + assert.calledOnceWithExactly(installFirefoxStub, "144.0", installOptions); + }); + + it("should append .0 to a digit-only Edge version", async () => { + await installBrowser(BrowserName.EDGE, "139", { shouldInstallWebDriver: true }); + + assert.calledOnceWithExactly(installEdgeDriverStub, "139.0", { force: false }); + }); + + ["139.0", "139.0.7258.1", "stable", "beta", "dev", "canary", "latest"].forEach(version => { + it(`should preserve supplied Chrome selector ${version}`, async () => { + await installBrowser(BrowserName.CHROME, version, { browserDownloadMirrors }); + + assert.calledOnceWithExactly(installChromeStub, BrowserName.CHROME, version, installOptions); + }); + }); + + it("should preserve a version resolved for an omitted Chrome version", async () => { + resolveLatestChromeVersionStub.resolves("139"); + + await installBrowser(BrowserName.CHROME, undefined, { browserDownloadMirrors }); + + assert.calledOnceWithExactly(resolveLatestChromeVersionStub, false, browserDownloadMirrors); + assert.calledOnceWithExactly(installChromeStub, BrowserName.CHROME, "139", installOptions); + }); + + it("should preserve a version resolved for an omitted Edge version", async () => { + resolveEdgeVersionStub.resolves("139"); + + await installBrowser(BrowserName.EDGE, undefined, { shouldInstallWebDriver: true }); + + assert.calledOnceWithExactly(resolveEdgeVersionStub); + assert.calledOnceWithExactly(installEdgeDriverStub, "139", { force: false }); + }); + }); + [true, false].forEach(force => { describe(`force: ${force}`, () => { describe("chrome", () => { it("should install browser", async () => { - installChromeStub.withArgs("chrome", "115").resolves("/browser/path"); + installChromeStub.withArgs("chrome", "115.0").resolves("/browser/path"); const binaryPath = await installBrowser(BrowserName.CHROME, "115", { force }); assert.equal(binaryPath, "/browser/path"); - assert.calledOnceWith(installChromeStub, "chrome", "115", { + assert.calledOnceWith(installChromeStub, "chrome", "115.0", { force, needUbuntuPackages: false, needWebDriver: false, + browserDownloadMirrors: undefined, }); }); it("should install browser with webdriver", async () => { - installChromeStub.withArgs("chrome", "115").resolves("/browser/path"); + installChromeStub.withArgs("chrome", "115.0").resolves("/browser/path"); const binaryPath = await installBrowser(BrowserName.CHROME, "115", { force, @@ -73,30 +150,57 @@ describe("browser-installer/install", () => { }); assert.equal(binaryPath, "/browser/path"); - assert.calledOnceWith(installChromeStub, "chrome", "115", { + assert.calledOnceWith(installChromeStub, "chrome", "115.0", { force, needUbuntuPackages: false, needWebDriver: true, + browserDownloadMirrors: undefined, + }); + }); + + it("should pass browser download mirrors", async () => { + await installBrowser(BrowserName.CHROME, "115", { force, browserDownloadMirrors }); + + assert.calledOnceWith(installChromeStub, "chrome", "115.0", { + force, + needUbuntuPackages: false, + needWebDriver: false, + browserDownloadMirrors, + }); + }); + + it("should pass browser download mirrors when resolving an omitted version", async () => { + resolveLatestChromeVersionStub.resolves("stable-chrome"); + + await installBrowser(BrowserName.CHROME, undefined, { force, browserDownloadMirrors }); + + assert.calledOnceWithExactly(resolveLatestChromeVersionStub, force, browserDownloadMirrors); + assert.calledOnceWith(installChromeStub, "chrome", "stable-chrome", { + force, + needUbuntuPackages: false, + needWebDriver: false, + browserDownloadMirrors, }); }); }); describe("firefox", () => { it("should install browser", async () => { - installFirefoxStub.withArgs("115").resolves("/browser/path"); + installFirefoxStub.withArgs("115.0").resolves("/browser/path"); const binaryPath = await installBrowser(BrowserName.FIREFOX, "115", { force }); assert.equal(binaryPath, "/browser/path"); - assert.calledOnceWith(installFirefoxStub, "115", { + assert.calledOnceWith(installFirefoxStub, "115.0", { force, needUbuntuPackages: false, needWebDriver: false, + browserDownloadMirrors: undefined, }); }); it("should install browser with webdriver", async () => { - installFirefoxStub.withArgs("115").resolves("/browser/path"); + installFirefoxStub.withArgs("115.0").resolves("/browser/path"); const binaryPath = await installBrowser(BrowserName.FIREFOX, "115", { force, @@ -104,10 +208,25 @@ describe("browser-installer/install", () => { }); assert.equal(binaryPath, "/browser/path"); - assert.calledOnceWith(installFirefoxStub, "115", { + assert.calledOnceWith(installFirefoxStub, "115.0", { force, needUbuntuPackages: false, needWebDriver: true, + browserDownloadMirrors: undefined, + }); + }); + + it("should pass browser download mirrors when resolving an omitted version", async () => { + resolveLatestFirefoxVersionStub.resolves("stable-firefox"); + + await installBrowser(BrowserName.FIREFOX, undefined, { force, browserDownloadMirrors }); + + assert.calledOnceWithExactly(resolveLatestFirefoxVersionStub, force, browserDownloadMirrors); + assert.calledOnceWith(installFirefoxStub, "stable-firefox", { + force, + needUbuntuPackages: false, + needWebDriver: false, + browserDownloadMirrors, }); }); }); @@ -127,7 +246,7 @@ describe("browser-installer/install", () => { }); assert.equal(binaryPath, null); - assert.calledOnceWith(installEdgeDriverStub, "115", { force }); + assert.calledOnceWith(installEdgeDriverStub, "115.0", { force }); }); }); @@ -146,13 +265,43 @@ describe("browser-installer/install", () => { }); describe("installBrowsersWithDrivers", () => { + it("should normalize installation while keeping the requested version in the result key", async () => { + installChromeStub.resolves("/browser/path"); + + const result = await installBrowsersWithDrivers([{ browserName: "chrome", browserVersion: "139" }], { + browserDownloadMirrors, + }); + + assert.calledOnceWithExactly(installChromeStub, BrowserName.CHROME, "139.0", { + force: true, + needUbuntuPackages: false, + needWebDriver: true, + browserDownloadMirrors, + }); + assert.deepEqual(result, { "chrome@139": { status: "ok" } }); + }); + it("should force install browser with driver", async () => { await installBrowsersWithDrivers([{ browserName: "chrome", browserVersion: "115" }]); - assert.calledOnceWith(installChromeStub, "chrome", "115", { + assert.calledOnceWith(installChromeStub, "chrome", "115.0", { + force: true, + needUbuntuPackages: false, + needWebDriver: true, + browserDownloadMirrors: undefined, + }); + }); + + it("should pass browser download mirrors", async () => { + await installBrowsersWithDrivers([{ browserName: "chrome", browserVersion: "115" }], { + browserDownloadMirrors, + }); + + assert.calledOnceWith(installChromeStub, "chrome", "115.0", { force: true, needUbuntuPackages: false, needWebDriver: true, + browserDownloadMirrors, }); }); @@ -161,10 +310,11 @@ describe("browser-installer/install", () => { await installBrowsersWithDrivers([{ browserName: "chrome", browserVersion: "115" }]); - assert.calledOnceWith(installChromeStub, "chrome", "115", { + assert.calledOnceWith(installChromeStub, "chrome", "115.0", { force: true, needWebDriver: true, needUbuntuPackages: true, + browserDownloadMirrors: undefined, }); }); diff --git a/test/src/browser-installer/mirror-download-contract.ts b/test/src/browser-installer/mirror-download-contract.ts new file mode 100644 index 000000000..ce11a4913 --- /dev/null +++ b/test/src/browser-installer/mirror-download-contract.ts @@ -0,0 +1,264 @@ +import { BrowserPlatform } from "@puppeteer/browsers"; +import fs from "node:fs"; +import http from "node:http"; +import https from "node:https"; +import os from "node:os"; +import path from "node:path"; +import type { AddressInfo } from "node:net"; +import { syncBuiltinESMExports } from "node:module"; +import { URL } from "node:url"; + +import proxyquire from "proxyquire"; +import sinon, { type SinonSandbox } from "sinon"; + +import { BrowserName } from "../../../src/browser/types"; +import type { installChrome as InstallChrome } from "../../../src/browser-installer/chrome/browser"; +import * as browserInstallerUtils from "../../../src/browser-installer/utils"; +import type { DownloadProgressCallback } from "../../../src/browser-installer/utils"; + +const BUILD_ID = "115.0.5790.170"; +const MIRROR_PATH_PREFIX = "/nested/cache/chrome"; +const LOOPBACK_HOSTNAME = "127.0.0.1"; + +type RecordedRequest = { + method: string; + pathname: string; +}; + +type RegistryInstallCall = { + name: string; + version: string; +}; + +const closeServer = async (server: http.Server): Promise => { + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); +}; + +describe("browser-installer mirror download contract", () => { + let sandbox: SinonSandbox; + let server: http.Server; + let tempDir: string; + let savedNoProxy: string | undefined; + let savedNoProxyLowercase: string | undefined; + + const requests: RecordedRequest[] = []; + const attemptedHosts: string[] = []; + const registryInstallCalls: RegistryInstallCall[] = []; + const downloadPromises: Promise[] = []; + const pendingArtifactResponses: http.ServerResponse[] = []; + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + requests.length = 0; + attemptedHosts.length = 0; + registryInstallCalls.length = 0; + downloadPromises.length = 0; + pendingArtifactResponses.length = 0; + tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "testplane-mirror-contract-")); + + server = http.createServer((request, response) => { + const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1"); + const method = request.method ?? "GET"; + + requests.push({ method, pathname: requestUrl.pathname }); + + if (requestUrl.pathname === `${MIRROR_PATH_PREFIX}/latest-versions-per-milestone.json`) { + const body = JSON.stringify({ milestones: { "115": { version: BUILD_ID } } }); + + response.writeHead(200, { + "content-type": "application/json", + "content-length": Buffer.byteLength(body), + }); + response.end(body); + return; + } + + if (requestUrl.pathname.startsWith(`${MIRROR_PATH_PREFIX}/${BUILD_ID}/`) && method === "HEAD") { + response.writeHead(200, { "content-length": "1" }); + response.end(); + return; + } + + if (requestUrl.pathname.startsWith(`${MIRROR_PATH_PREFIX}/${BUILD_ID}/`) && method === "GET") { + pendingArtifactResponses.push(response); + + if (pendingArtifactResponses.length === 2) { + for (const pendingResponse of pendingArtifactResponses) { + pendingResponse.writeHead(503, { "content-type": "text/plain" }); + pendingResponse.end("mirror artifact unavailable"); + } + } + + return; + } + + response.writeHead(404); + response.end("not found"); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + + const recordAndGuardHost = (hostname: string | null | undefined): void => { + const attemptedHost = String(hostname); + + attemptedHosts.push(attemptedHost); + + if (attemptedHost !== LOOPBACK_HOSTNAME) { + throw new Error(`Blocked unexpected outbound request to ${attemptedHost}`); + } + }; + const originalHttpRequest = http.request; + const originalHttpsRequest = https.request; + const originalFetch = globalThis.fetch; + + sandbox.stub(http, "request").callsFake(((options: http.RequestOptions, ...args: unknown[]) => { + recordAndGuardHost(options.hostname); + + return Reflect.apply(originalHttpRequest, http, [options, ...args]); + }) as typeof http.request); + sandbox.stub(https, "request").callsFake(((options: https.RequestOptions, ...args: unknown[]) => { + recordAndGuardHost(options.hostname); + + return Reflect.apply(originalHttpsRequest, https, [options, ...args]); + }) as typeof https.request); + sandbox.stub(globalThis, "fetch").callsFake((async (input, init) => { + recordAndGuardHost(new URL(input as string).hostname); + + return originalFetch(input, init); + }) as typeof fetch); + syncBuiltinESMExports(); + + savedNoProxy = process.env.NO_PROXY; + savedNoProxyLowercase = process.env["no_proxy"]; + process.env.NO_PROXY = [savedNoProxy, "127.0.0.1", "localhost"].filter(Boolean).join(","); + process.env["no_proxy"] = [savedNoProxyLowercase, "127.0.0.1", "localhost"].filter(Boolean).join(","); + }); + + afterEach(async () => { + await closeServer(server); + await Promise.allSettled(downloadPromises); + sandbox.restore(); + syncBuiltinESMExports(); + + if (savedNoProxy === undefined) { + delete process.env.NO_PROXY; + } else { + process.env.NO_PROXY = savedNoProxy; + } + + if (savedNoProxyLowercase === undefined) { + delete process.env["no_proxy"]; + } else { + process.env["no_proxy"] = savedNoProxyLowercase; + } + + await fs.promises.rm(tempDir, { recursive: true, force: true }); + }); + + it("should use one mirrored build for Chrome and ChromeDriver without upstream fallback", async () => { + const { port } = server.address() as AddressInfo; + const mirror = `http://127.0.0.1:${port}${MIRROR_PATH_PREFIX}`; + const registry = { + getMatchedBrowserVersion: sandbox.stub().returns(null), + getMatchedDriverVersion: sandbox.stub().returns(null), + installBinary: sandbox + .stub() + .callsFake( + ( + name: string, + _platform: string, + version: string, + installFn: (downloadProgressCallback: DownloadProgressCallback) => Promise, + ): Promise => { + registryInstallCalls.push({ name, version }); + const downloadPromise = installFn(() => undefined); + + downloadPromises.push(downloadPromise); + + return downloadPromise; + }, + ), + }; + const installerUtils = { + ...browserInstallerUtils, + getBrowserPlatform: (): BrowserPlatform => BrowserPlatform.LINUX, + getBrowsersDir: (): string => path.join(tempDir, "browsers"), + getChromeDriverDir: (): string => path.join(tempDir, "drivers"), + }; + const installChromeDriver = proxyquire("src/browser-installer/chrome/driver", { + "../registry": { default: registry }, + "../utils": installerUtils, + }).installChromeDriver; + const installChrome = proxyquire("src/browser-installer/chrome/browser", { + "../registry": { default: registry }, + "../utils": installerUtils, + "./driver": { installChromeDriver }, + }).installChrome as typeof InstallChrome; + + const result = await installChrome(BrowserName.CHROME, "115", { + force: true, + needWebDriver: true, + browserDownloadMirrors: { + chrome: mirror, + chromium: null, + firefox: null, + }, + }).catch(error => error as Error); + const downloadResults = await Promise.allSettled(downloadPromises); + + if (!(result instanceof Error)) { + throw new Error(`Chrome installation unexpectedly succeeded at ${result}`); + } + + assert.lengthOf(downloadResults, 2, "both artifact downloads should settle before cleanup"); + + const downloadErrors = downloadResults.map(downloadResult => { + assert.equal(downloadResult.status, "rejected"); + + const error = (downloadResult as PromiseRejectedResult).reason as Error; + + assert.instanceOf(error, Error); + assert.include(error.message, "server returned code 503"); + assert.include(error.message, mirror); + assert.include(error.message, BUILD_ID); + + return error; + }); + const chromeArchivePath = `${MIRROR_PATH_PREFIX}/${BUILD_ID}/linux64/chrome-linux64.zip`; + const driverArchivePath = `${MIRROR_PATH_PREFIX}/${BUILD_ID}/linux64/chromedriver-linux64.zip`; + + assert.include(downloadErrors, result, "installChrome should preserve the original download error"); + assert.sameDeepMembers(requests, [ + { method: "GET", pathname: `${MIRROR_PATH_PREFIX}/latest-versions-per-milestone.json` }, + { method: "HEAD", pathname: chromeArchivePath }, + { method: "GET", pathname: chromeArchivePath }, + { method: "HEAD", pathname: driverArchivePath }, + { method: "GET", pathname: driverArchivePath }, + ]); + assert.isTrue( + downloadErrors.some(error => error.message.includes("chrome-linux64.zip")), + "Chrome error should include the attempted mirror URL", + ); + assert.isTrue( + downloadErrors.some(error => error.message.includes("chromedriver-linux64.zip")), + "ChromeDriver error should include the attempted mirror URL", + ); + assert.sameDeepMembers(registryInstallCalls, [ + { name: "chrome", version: BUILD_ID }, + { name: "chromedriver", version: BUILD_ID }, + ]); + assert.lengthOf( + attemptedHosts, + requests.length, + "the outbound guard should observe every metadata and archive request", + ); + assert.isTrue( + attemptedHosts.every(hostname => hostname === LOOPBACK_HOSTNAME), + `all requests must stay on loopback, got: ${attemptedHosts.join(", ")}`, + ); + }); +}); diff --git a/test/src/browser-installer/mirrors.ts b/test/src/browser-installer/mirrors.ts new file mode 100644 index 000000000..b5ebdb6cc --- /dev/null +++ b/test/src/browser-installer/mirrors.ts @@ -0,0 +1,87 @@ +import { BrowserName } from "../../../src/browser/types"; +import { getBrowserDownloadMirror, getBrowserDownloadMirrorFileUrl } from "../../../src/browser-installer/mirrors"; + +describe("browser-installer/mirrors", () => { + const mirrors = { + chrome: "https://mirror.example/chrome/", + chromium: "https://mirror.example/chromium/", + firefox: "https://mirror.example/firefox/", + }; + + it("should resolve mirror for each downloadable browser", () => { + assert.equal(getBrowserDownloadMirror(BrowserName.CHROME, mirrors), "https://mirror.example/chrome"); + assert.equal( + getBrowserDownloadMirror(BrowserName.CHROMEHEADLESSSHELL, mirrors), + "https://mirror.example/chrome", + ); + assert.equal(getBrowserDownloadMirror(BrowserName.CHROMIUM, mirrors), "https://mirror.example/chromium"); + assert.equal(getBrowserDownloadMirror(BrowserName.FIREFOX, mirrors), "https://mirror.example/firefox"); + }); + + it("should not resolve mirrors for browsers that Testplane does not download", () => { + assert.isUndefined(getBrowserDownloadMirror(BrowserName.EDGE, mirrors)); + assert.isUndefined(getBrowserDownloadMirror(BrowserName.SAFARI, mirrors)); + }); + + it("should reject an empty configured mirror", () => { + assert.throws( + () => getBrowserDownloadMirror(BrowserName.CHROME, { ...mirrors, chrome: " " }), + '"browserDownloadMirrors.chrome" must not be empty', + ); + }); + + it("should preserve pathname prefixes and normalize whitespace and trailing slashes", () => { + assert.equal( + getBrowserDownloadMirror(BrowserName.CHROME, { + ...mirrors, + chrome: " https://mirror.example/cache/chrome/// ", + }), + "https://mirror.example/cache/chrome", + ); + }); + + [ + { + value: "mirror.example/chrome", + error: '"browserDownloadMirrors.chrome" must be a valid URL, got mirror.example/chrome', + }, + { + value: "ftp://mirror.example/chrome", + error: '"browserDownloadMirrors.chrome" must be an absolute http: or https:, got ftp', + }, + { + value: "https://user:password@mirror.example/chrome", + error: '"browserDownloadMirrors.chrome" must not contain a username or password', + }, + { + value: "https://mirror.example/chrome?channel=stable", + error: '"browserDownloadMirrors.chrome" must not contain a query string', + }, + { + value: "https://mirror.example/chrome#stable", + error: '"browserDownloadMirrors.chrome" must not contain a fragment', + }, + ].forEach(({ value, error }) => { + it(`should reject invalid programmatic mirror ${value}`, () => { + assert.throws(() => getBrowserDownloadMirror(BrowserName.CHROME, { ...mirrors, chrome: value }), error); + }); + }); + + it("should reject a non-string programmatic mirror", () => { + assert.throws( + () => + getBrowserDownloadMirror(BrowserName.CHROME, { + ...mirrors, + chrome: true as unknown as string, + }), + '"browserDownloadMirrors.chrome" must be a string', + ); + }); + + it("should join metadata filename to mirror URL", () => { + assert.equal( + getBrowserDownloadMirrorFileUrl("https://mirror.example/chrome/", "LATEST_RELEASE_STABLE"), + "https://mirror.example/chrome/LATEST_RELEASE_STABLE", + ); + }); +}); diff --git a/test/src/browser-installer/registry.ts b/test/src/browser-installer/registry.ts index d55bed8a4..72aead232 100644 --- a/test/src/browser-installer/registry.ts +++ b/test/src/browser-installer/registry.ts @@ -116,6 +116,21 @@ describe("browser-installer/registry", () => { assert.equal(versionFull, "115.0.5790.170"); }); + it("should not treat an installed Chrome build as the explicit stable channel", () => { + registry = createRegistry_({ + binaries: { + // eslint-disable-next-line camelcase + chrome_mac_arm: { "115.0.5790.170": "../browsers/chrome-115-0-5790-170" }, + }, + }); + + assert.equal( + registry.getMatchedBrowserVersion(BrowserName.CHROME, BrowserPlatform.MAC_ARM), + "115.0.5790.170", + ); + assert.isNull(registry.getMatchedBrowserVersion(BrowserName.CHROME, BrowserPlatform.MAC_ARM, "stable")); + }); + it("should return matching latest firefox browser version", () => { registry = createRegistry_({ binaries: { diff --git a/test/src/browser-installer/run.ts b/test/src/browser-installer/run.ts index 334691a8d..a995906d8 100644 --- a/test/src/browser-installer/run.ts +++ b/test/src/browser-installer/run.ts @@ -5,6 +5,11 @@ import { BrowserName } from "../../../src/browser/types"; describe("browser-installer/run", () => { const sandbox = sinon.createSandbox(); + const browserDownloadMirrors = { + chrome: "https://mirror.example/chrome", + chromium: null, + firefox: null, + }; let runBrowserDriver: typeof RunBrowserDriver; @@ -30,7 +35,10 @@ describe("browser-installer/run", () => { it(`should run chrome driver with debug: ${debug}`, async () => { await runBrowserDriver(BrowserName.CHROME, "some-version", { debug }); - assert.calledOnceWith(runChromeDriverStub, "some-version", { debug: Boolean(debug) }); + assert.calledOnceWithExactly(runChromeDriverStub, "some-version", { + debug: Boolean(debug), + browserDownloadMirrors: undefined, + }); }); }); @@ -40,16 +48,57 @@ describe("browser-installer/run", () => { assert.calledOnceWith(installBrowserStub, BrowserName.CHROME, "some-version", { shouldInstallWebDriver: true, shouldInstallUbuntuPackages: true, + browserDownloadMirrors: undefined, }); assert.callOrder(installBrowserStub, runChromeDriverStub); }); + it("should pass browser download mirrors to installer", async () => { + await runBrowserDriver(BrowserName.CHROME, "some-version", { browserDownloadMirrors }); + + assert.calledOnceWith(installBrowserStub, BrowserName.CHROME, "some-version", { + shouldInstallWebDriver: true, + shouldInstallUbuntuPackages: true, + browserDownloadMirrors, + }); + assert.calledOnceWithExactly(runChromeDriverStub, "some-version", { + debug: false, + browserDownloadMirrors, + }); + }); + + it("should preserve mirrors through the ChromeDriver launch installation seam", async () => { + const installChromeDriverStub = sandbox.stub().resolves("/driver/path"); + const spawnStub = sandbox.stub().returns({ kill: sandbox.stub() }); + sandbox.stub(process, "once"); + const runChromeDriver = proxyquire("../../../src/browser-installer/chrome", { + "./driver": { installChromeDriver: installChromeDriverStub }, + "../ubuntu-packages": { + isUbuntu: sandbox.stub().resolves(false), + getUbuntuLinkerEnv: sandbox.stub(), + }, + child_process: { spawn: spawnStub }, // eslint-disable-line camelcase + "get-port": sandbox.stub().resolves(12345), + "wait-port": sandbox.stub().resolves(), + }).runChromeDriver; + const runBrowserDriverThroughChrome = proxyquire.noCallThru()("../../../src/browser-installer/run", { + "./install": { installBrowser: installBrowserStub }, + "./chrome": { runChromeDriver }, + }).runBrowserDriver as typeof RunBrowserDriver; + + await runBrowserDriverThroughChrome(BrowserName.CHROME, "stable", { browserDownloadMirrors }); + + assert.calledOnceWithExactly(installChromeDriverStub, "stable", { browserDownloadMirrors }); + assert.calledOnce(spawnStub); + }); + it(`should try to install firefox before running its driver`, async () => { await runBrowserDriver(BrowserName.FIREFOX, "some-version"); assert.calledOnceWith(installBrowserStub, BrowserName.FIREFOX, "some-version", { shouldInstallWebDriver: true, shouldInstallUbuntuPackages: true, + browserDownloadMirrors: undefined, }); assert.callOrder(installBrowserStub, runGeckoDriverStub); }); diff --git a/test/src/browser-installer/ubuntu-packages/apt.ts b/test/src/browser-installer/ubuntu-packages/apt.ts new file mode 100644 index 000000000..0de2cb5fb --- /dev/null +++ b/test/src/browser-installer/ubuntu-packages/apt.ts @@ -0,0 +1,23 @@ +import proxyquire from "proxyquire"; +import sinon from "sinon"; +import type { installUbuntuPackages as InstallUbuntuPackages } from "../../../../src/browser-installer/ubuntu-packages/apt"; + +describe("browser-installer/ubuntu-packages/apt", () => { + it("should complete progress when all dependencies are already installed", async () => { + const execStub = sinon.stub(); + const downloadProgressCallback = sinon.spy(); + + execStub.onFirstCall().yields(null, "foo\n"); + execStub.onSecondCall().yields(null, "foo/stable,now 1.0 amd64 [installed]\n"); + + const { installUbuntuPackages } = proxyquire("../../../../src/browser-installer/ubuntu-packages/apt", { + child_process: { exec: execStub }, // eslint-disable-line camelcase + "./utils": { ensureUnixBinaryExists: sinon.stub().resolves() }, + }) as { installUbuntuPackages: typeof InstallUbuntuPackages }; + + await installUbuntuPackages(["foo"], "/packages", { downloadProgressCallback }); + + assert.deepEqual(downloadProgressCallback.lastCall.args, [100]); + assert.calledTwice(execStub); + }); +}); diff --git a/test/src/browser-pool/basic-pool.js b/test/src/browser-pool/basic-pool.js index bc037897b..00929974f 100644 --- a/test/src/browser-pool/basic-pool.js +++ b/test/src/browser-pool/basic-pool.js @@ -8,6 +8,7 @@ const { CancelledError } = require("src/browser-pool/cancelled-error"); const { WebdriverPool } = require("src/browser-pool/webdriver-pool"); const { MasterEvents: Events } = require("src/events"); const { stubBrowser } = require("./util"); +const { createBrowserConfig_, mkSessionStub_ } = require("../browser/utils"); const { makeConfigStub } = require("../../utils"); const { promiseDelay } = require("../../../src/utils/promise"); @@ -208,6 +209,25 @@ describe("browser-pool/basic-pool", () => { assert.calledOnce(bro2.quit); }); + it("should release a real browser after its cancel cleanup completes", async () => { + const emitter = new AsyncEmitter(); + const config = createBrowserConfig_(); + const session = mkSessionStub_(); + const browser = new NewBrowser(config, { id: "browser", emitter }); + sandbox.stub(browser, "_createSession").resolves(session); + const pool = mkPool_({ config, emitter }); + + await browser.init(); + pool._activeSessions[browser.sessionId] = browser; + + pool.cancel(); + await browser.quit(); + + await assert.isFulfilled(pool.freeBrowser(browser)); + assert.equal(browser.sessionId, session.sessionId); + assert.calledOnce(session.deleteSession); + }); + it("should quit all browser with the same id on cancel", async () => { const pool = mkPool_(); diff --git a/test/src/browser-pool/webdriver-pool.ts b/test/src/browser-pool/webdriver-pool.ts index 226211364..ff900a98d 100644 --- a/test/src/browser-pool/webdriver-pool.ts +++ b/test/src/browser-pool/webdriver-pool.ts @@ -4,14 +4,21 @@ import type { WebdriverPool as WdPoolType } from "../../../src/browser-pool/webd describe("browser-pool/webdriver-pool", () => { const sandbox = sinon.createSandbox(); + const browserDownloadMirrors = { + chrome: "https://mirror.example/chrome", + chromium: null, + firefox: null, + }; let wdPool: WdPoolType; let getDriverNameForBrowserNameStub: SinonStub; + let resolveBrowserVersionStub: SinonStub; let runBrowserDriverStub: SinonStub; beforeEach(() => { getDriverNameForBrowserNameStub = sandbox.stub().returns("edgedriver"); + resolveBrowserVersionStub = sandbox.stub(); runBrowserDriverStub = sandbox.stub().resolves({ gridUrl: "http://localhost:12345", process: { kill: sandbox.stub() }, @@ -22,6 +29,7 @@ describe("browser-pool/webdriver-pool", () => { "../browser-installer": { runBrowserDriver: runBrowserDriverStub, getDriverNameForBrowserName: getDriverNameForBrowserNameStub, + resolveBrowserVersion: resolveBrowserVersionStub, }, }); @@ -41,13 +49,52 @@ describe("browser-pool/webdriver-pool", () => { const driver = await wdPool.getWebdriver("MicrosoftEdge", "135.0"); assert.equal(driver.gridUrl, "http://localhost:100500"); - assert.calledOnceWith(runBrowserDriverStub, "MicrosoftEdge", "135.0", { debug: false }); + assert.calledOnceWith(runBrowserDriverStub, "MicrosoftEdge", "135.0", { + debug: false, + browserDownloadMirrors: undefined, + }); }); it("should run browser driver with debug mode", async () => { await wdPool.getWebdriver("MicrosoftEdge", "135.0"); - assert.calledOnceWith(runBrowserDriverStub, sinon.match.string, sinon.match.string, { debug: false }); + assert.calledOnceWith(runBrowserDriverStub, sinon.match.string, sinon.match.string, { + debug: false, + browserDownloadMirrors: undefined, + }); + }); + + it("should pass browser download mirrors to browser driver", async () => { + await wdPool.getWebdriver("chrome", "135.0", { browserDownloadMirrors }); + + assert.calledOnceWith(runBrowserDriverStub, "chrome", "135.0", { + debug: false, + browserDownloadMirrors, + }); + }); + + it("should pass browser download mirrors when resolving an omitted browser version", async () => { + resolveBrowserVersionStub.resolves("135.0"); + + await wdPool.getWebdriver("chrome", undefined, { browserDownloadMirrors }); + + assert.calledOnceWithExactly(resolveBrowserVersionStub, "chrome", { browserDownloadMirrors }); + assert.calledOnceWith(runBrowserDriverStub, "chrome", "135.0", { + debug: false, + browserDownloadMirrors, + }); + }); + + it("should pass an undefined mirror map when resolving an omitted browser version", async () => { + resolveBrowserVersionStub.resolves("135.0"); + + await wdPool.getWebdriver("chrome"); + + assert.calledOnceWithExactly(resolveBrowserVersionStub, "chrome", { browserDownloadMirrors: undefined }); + assert.calledOnceWith(runBrowserDriverStub, "chrome", "135.0", { + debug: false, + browserDownloadMirrors: undefined, + }); }); it("should run extra drivers if all of existing ones are busy", async () => { diff --git a/test/src/browser/new-browser.ts b/test/src/browser/new-browser.ts index bd61508b6..0ec2deb8a 100644 --- a/test/src/browser/new-browser.ts +++ b/test/src/browser/new-browser.ts @@ -6,7 +6,7 @@ import { runGroup } from "src/browser/history"; import { WEBDRIVER_PROTOCOL } from "src/constants/config"; import { X_REQUEST_ID_DELIMITER } from "src/constants/browser"; import RuntimeConfig from "src/config/runtime-config"; -import { mkNewBrowser_, mkSessionStub_, mkWdPool_ } from "./utils"; +import { createBrowserConfig_, mkNewBrowser_, mkSessionStub_, mkWdPool_ } from "./utils"; import { Config } from "src/config"; import { RequestOptions } from "node:https"; import { DesiredCapabilities, SelenoidOptions } from "@testplane/wdio-types/build/Capabilities"; @@ -377,9 +377,135 @@ describe("NewBrowser", () => { await assert.isFulfilled(browser.init()); assert.calledOnceWith(warnStub, "WARNING: Can not set page load timeout: o.O"); }); + + it("should preserve init error and clean up if session deletion fails", async () => { + const initError = new Error("failed to set page load timeout"); + const deleteError = new Error("failed to delete partial session"); + const wdProcess = { + gridUrl: "http://localhost:12345/", + free: sandbox.stub(), + kill: sandbox.stub(), + getPid: sandbox.stub().returns(12345), + }; + const wdPool = { getWebdriver: sandbox.stub().resolves(wdProcess) }; + session.setTimeout.rejects(initError); + session.deleteSession.rejects(deleteError); + const exitListenerCount = signalHandler.listenerCount("exit"); + const browser = mkBrowser_( + { + gridUrl: "local", + pageLoadTimeout: 100500, + desiredCapabilities: { + browserName: "chrome", + browserVersion: "115.0", + }, + }, + { wdPool }, + ); + + const error = await browser.init().catch((error: Error) => error); + + await Promise.all([browser.quit(), browser.kill()]); + + assert.strictEqual(error, initError); + assert.calledOnce(session.deleteSession); + assert.notCalled(wdProcess.free); + assert.calledOnce(wdProcess.kill); + assert.isUndefined(browser.getDriverPid()); + assert.calledOnceWith( + warnStub, + "WARNING: Can not kill WebDriver process: failed to delete partial session", + ); + assert.equal(signalHandler.listenerCount("exit"), exitListenerCount); + }); }); describe("should use local grid url", () => { + it("should pass browser download mirrors to webdriver pool and browser installer", async () => { + const browserDownloadMirrors = { + chrome: "https://mirror.example/chrome", + chromium: null, + firefox: null, + }; + const wdPool = mkWdPool_({ gridUrl: "http://localhost:12345/" }); + const config = Object.assign( + createBrowserConfig_({ + gridUrl: "local", + desiredCapabilities: { + browserName: "chrome", + browserVersion: "115.0", + }, + }), + { + browserDownloadMirrors: { + ...browserDownloadMirrors, + chrome: "https://mirror.example/old-chrome", + }, + }, + ); + const browser = NewBrowser.create(config, { id: "chrome", wdPool }); + config.browserDownloadMirrors = browserDownloadMirrors; + + await browser.init(); + + assert.calledOnceWith(wdPool.getWebdriver, "chrome", "115.0", { + debug: true, + browserDownloadMirrors, + }); + assert.calledOnceWith(installBrowserStub, "chrome", "115.0", { + shouldInstallWebDriver: false, + shouldInstallUbuntuPackages: true, + browserDownloadMirrors, + }); + }); + + it("should clean up webdriver and exit handler if mirrored browser installation fails", async () => { + const initError = new Error("mirror artifact is unavailable"); + const browserDownloadMirrors = { + chrome: "https://mirror.example/chrome", + chromium: null, + firefox: null, + }; + const wdProcess = { + gridUrl: "http://localhost:12345/", + free: sandbox.stub(), + kill: sandbox.stub(), + getPid: sandbox.stub().returns(12345), + }; + const wdPool = { getWebdriver: sandbox.stub().resolves(wdProcess) }; + const config = Object.assign( + createBrowserConfig_({ + gridUrl: "local", + desiredCapabilities: { + browserName: "chrome", + browserVersion: "115.0", + }, + }), + { browserDownloadMirrors }, + ); + installBrowserStub.rejects(initError); + const exitListenerCount = signalHandler.listenerCount("exit"); + const browser = NewBrowser.create(config, { id: "chrome", wdPool }); + + const error = await browser.init().catch((error: Error) => error); + + assert.strictEqual(error, initError); + assert.calledOnceWith(wdPool.getWebdriver, "chrome", "115.0", { + debug: true, + browserDownloadMirrors, + }); + assert.calledOnceWith(installBrowserStub, "chrome", "115.0", { + shouldInstallWebDriver: false, + shouldInstallUbuntuPackages: true, + browserDownloadMirrors, + }); + assert.notCalled(webdriverioRemoteStub); + assert.notCalled(wdProcess.free); + assert.calledOnce(wdProcess.kill); + assert.isUndefined(browser.getDriverPid()); + assert.equal(signalHandler.listenerCount("exit"), exitListenerCount); + }); + it("if gridUrl is 'local'", async () => { installBrowserStub.withArgs("chrome", "115.0").resolves("/browser/path/chrome/115.0"); (RuntimeConfig.getInstance as SinonStub).returns({ local: false }); @@ -503,6 +629,16 @@ describe("NewBrowser", () => { assert.strictEqual(browser.exitError, error); }); + it("should preserve session metadata after cleanup", async () => { + session.sessionId = "session-id"; + const browser = await mkBrowser_().init(); + + await browser.quit(); + + assert.equal(browser.sessionId, "session-id"); + assert.strictEqual(browser.publicAPI, session); + }); + it("should wait for session creation before finalizing it", async () => { let resolveSession: (browserSession: unknown) => void; webdriverioRemoteStub.returns(new Promise(resolve => (resolveSession = resolve))); @@ -568,8 +704,14 @@ describe("NewBrowser", () => { it("should kill webdriver session if cant quit normally", async () => { (RuntimeConfig.getInstance as SinonStub).returns({ local: false }); session.deleteSession.rejects(new Error("failed end")); - const wdProcess = { gridUrl: "http://localhost:12345", free: sandbox.stub(), kill: sandbox.stub() }; + const wdProcess = { + gridUrl: "http://localhost:12345", + free: sandbox.stub(), + kill: sandbox.stub(), + getPid: sandbox.stub().returns(12345), + }; const wdPool = { getWebdriver: sandbox.stub().resolves(wdProcess) }; + const exitListenerCount = signalHandler.listenerCount("exit"); const browser = mkBrowser_( { gridUrl: "local", @@ -588,6 +730,109 @@ describe("NewBrowser", () => { assert.notCalled(wdProcess.free); assert.calledOnce(wdProcess.kill); + assert.isUndefined(browser.getDriverPid()); + assert.equal(signalHandler.listenerCount("exit"), exitListenerCount); + + await browser.quit(); + + assert.calledOnce(wdProcess.kill); + }); + + [null, undefined].forEach(deleteError => { + it(`should kill webdriver if session deletion rejects with ${String(deleteError)}`, async () => { + (RuntimeConfig.getInstance as SinonStub).returns({ local: false }); + session.deleteSession.callsFake(() => Promise.reject(deleteError)); + const wdProcess = { + gridUrl: "http://localhost:12345", + free: sandbox.stub(), + kill: sandbox.stub(), + getPid: sandbox.stub().returns(12345), + }; + const wdPool = { getWebdriver: sandbox.stub().resolves(wdProcess) }; + const browser = mkBrowser_( + { + gridUrl: "local", + automationProtocol: "webdriver", + desiredCapabilities: { + browserName: "chrome", + browserVersion: "115.0", + }, + }, + { wdPool }, + ); + + await browser.init(); + await browser.quit(); + + assert.notCalled(wdProcess.free); + assert.calledOnce(wdProcess.kill); + assert.calledOnceWith(warnStub, `WARNING: Can not close session: ${String(deleteError)}`); + }); + }); + + it("should kill and clear webdriver process if session does not exist", async () => { + (RuntimeConfig.getInstance as SinonStub).returns({ local: false }); + const wdProcess = { + gridUrl: "http://localhost:12345", + free: sandbox.stub(), + kill: sandbox.stub(), + getPid: sandbox.stub().returns(12345), + }; + const wdPool = { getWebdriver: sandbox.stub().resolves(wdProcess) }; + const exitListenerCount = signalHandler.listenerCount("exit"); + const browser = mkBrowser_( + { + gridUrl: "local", + desiredCapabilities: { + browserName: "chrome", + browserVersion: "115.0", + }, + }, + { wdPool }, + ); + await browser._getLocalWebdriverGridUrl(); + + await browser.quit(); + + assert.notCalled(wdProcess.free); + assert.calledOnce(wdProcess.kill); + assert.isUndefined(browser.getDriverPid()); + assert.notCalled(warnStub); + assert.equal(signalHandler.listenerCount("exit"), exitListenerCount); + }); + }); + + describe("kill", () => { + it("should kill and clear webdriver process if session deletion fails", async () => { + const deleteError = new Error("failed end"); + session.deleteSession.rejects(deleteError); + const wdProcess = { + gridUrl: "http://localhost:12345", + free: sandbox.stub(), + kill: sandbox.stub(), + getPid: sandbox.stub().returns(12345), + }; + const wdPool = { getWebdriver: sandbox.stub().resolves(wdProcess) }; + const exitListenerCount = signalHandler.listenerCount("exit"); + const browser = mkBrowser_( + { + gridUrl: "local", + desiredCapabilities: { + browserName: "chrome", + browserVersion: "115.0", + }, + }, + { wdPool }, + ); + await browser.init(); + + await browser.kill(); + + assert.calledOnce(session.deleteSession); + assert.calledOnce(wdProcess.kill); + assert.isUndefined(browser.getDriverPid()); + assert.calledOnceWith(warnStub, "WARNING: Can not kill WebDriver process: failed end"); + assert.equal(signalHandler.listenerCount("exit"), exitListenerCount); }); it("should kill webdriver.io session only once", async () => { @@ -598,6 +843,55 @@ describe("NewBrowser", () => { assert.calledOnce(session.deleteSession); }); + + it("should escalate pending quit cleanup when kill is called concurrently", async () => { + let resolveDeleteSession!: () => void; + session.deleteSession.callsFake(() => new Promise(resolve => (resolveDeleteSession = resolve))); + const wdProcess = { + gridUrl: "http://localhost:12345", + free: sandbox.stub(), + kill: sandbox.stub(), + getPid: sandbox.stub().returns(12345), + }; + const browser = await mkBrowser_().init(); + Reflect.set(browser, "_wdProcess", wdProcess); + + const quitPromise = browser.quit(); + await Promise.resolve(); + const killPromise = browser.kill(); + + assert.calledOnce(session.deleteSession); + resolveDeleteSession(); + await Promise.all([quitPromise, killPromise]); + + assert.calledOnce(session.deleteSession); + assert.notCalled(wdProcess.free); + assert.calledOnce(wdProcess.kill); + }); + + it("should let kill own cleanup when quit is called concurrently", async () => { + let resolveDeleteSession!: () => void; + session.deleteSession.callsFake(() => new Promise(resolve => (resolveDeleteSession = resolve))); + const wdProcess = { + gridUrl: "http://localhost:12345", + free: sandbox.stub(), + kill: sandbox.stub(), + getPid: sandbox.stub().returns(12345), + }; + const browser = await mkBrowser_().init(); + Reflect.set(browser, "_wdProcess", wdProcess); + + const killPromise = browser.kill(); + const quitPromise = browser.quit(); + + assert.calledOnce(session.deleteSession); + resolveDeleteSession(); + await Promise.all([killPromise, quitPromise]); + + assert.calledOnce(session.deleteSession); + assert.notCalled(wdProcess.free); + assert.calledOnce(wdProcess.kill); + }); }); describe("sessionId", () => { diff --git a/test/src/browser/standalone/launchBrowser.js b/test/src/browser/standalone/launchBrowser.js new file mode 100644 index 000000000..437e6cb20 --- /dev/null +++ b/test/src/browser/standalone/launchBrowser.js @@ -0,0 +1,126 @@ +"use strict"; + +const proxyquire = require("proxyquire").noCallThru(); + +const MIRROR_ENV_NAMES = [ + "TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_CHROME", + "TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_CHROMIUM", + "TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_FIREFOX", + "testplane_browser_download_mirrors_chrome", + "testplane_browser_download_mirrors_chromium", + "testplane_browser_download_mirrors_firefox", + "hermione_browser_download_mirrors_chrome", + "hermione_browser_download_mirrors_chromium", + "hermione_browser_download_mirrors_firefox", +]; + +describe("browser/standalone/launchBrowser", () => { + const sandbox = sinon.createSandbox(); + const environmentNames = [...MIRROR_ENV_NAMES, "WDIO_LOG_LEVEL"]; + + let savedEnvironment; + let launchBrowser; + let receivedConfig; + + beforeEach(() => { + savedEnvironment = Object.fromEntries(environmentNames.map(name => [name, process.env[name]])); + environmentNames.forEach(name => delete process.env[name]); + + class NewBrowserStub { + constructor(config) { + receivedConfig = config; + this.publicAPI = { + sessionId: "session-id", + capabilities: {}, + options: {}, + }; + } + + async init() {} + + async kill() {} + + getDriverPid() { + return undefined; + } + } + + class ExistingBrowserStub { + constructor() { + this.publicAPI = { + overwriteCommand: sandbox.stub(), + addCommand: sandbox.stub(), + }; + } + + async init() {} + + async quit() {} + } + + class WebdriverPoolStub {} + class CalibratorStub {} + + ({ launchBrowser } = proxyquire("../../../../src/browser/standalone/launchBrowser", { + "./../new-browser": { NewBrowser: NewBrowserStub }, + "./../existing-browser": { ExistingBrowser: ExistingBrowserStub }, + "./../calibrator": { Calibrator: CalibratorStub }, + "../../browser-pool/webdriver-pool": { WebdriverPool: WebdriverPoolStub }, + })); + }); + + afterEach(() => { + environmentNames.forEach(name => { + if (savedEnvironment[name] === undefined) { + delete process.env[name]; + } else { + process.env[name] = savedEnvironment[name]; + } + }); + sandbox.restore(); + }); + + it("should accept browser download mirrors in standalone options", async () => { + await launchBrowser({ + browserDownloadMirrors: { + chrome: " https://mirror.example/options/chrome/// ", + }, + }); + + assert.equal(receivedConfig.browserDownloadMirrors.chrome, "https://mirror.example/options/chrome"); + 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"; + + await launchBrowser(); + + assert.equal(receivedConfig.browserDownloadMirrors.chrome, "https://mirror.example/environment/chrome"); + }); + + it("should prefer an environment mirror over standalone options", async () => { + process.env.TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_CHROME = "https://mirror.example/environment/chrome"; + + await launchBrowser({ + browserDownloadMirrors: { + chrome: "https://mirror.example/options/chrome", + }, + }); + + assert.equal(receivedConfig.browserDownloadMirrors.chrome, "https://mirror.example/environment/chrome"); + }); + + it("should reject an empty environment mirror instead of using standalone options", async () => { + process.env.TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_CHROME = ""; + + await assert.isRejected( + launchBrowser({ + browserDownloadMirrors: { + chrome: "https://mirror.example/options/chrome", + }, + }), + '"browserDownloadMirrors.chrome" must not be empty', + ); + }); +}); diff --git a/test/src/cli/commands/install-deps/index.ts b/test/src/cli/commands/install-deps/index.ts index 66c6108ce..8ae5e4c62 100644 --- a/test/src/cli/commands/install-deps/index.ts +++ b/test/src/cli/commands/install-deps/index.ts @@ -109,6 +109,21 @@ describe("cli/commands/install-deps", () => { ]); }); + it("should pass browser download mirrors from config", async () => { + const browserDownloadMirrors = { + chrome: "https://mirror.example/chrome", + chromium: null, + firefox: null, + }; + testplaneStub.config.browserDownloadMirrors = browserDownloadMirrors; + + await installBrowsers_("chrome@113"); + + assert.calledOnceWith(installBrowsersWithDriversStub, [{ browserName: "chrome", browserVersion: "113" }], { + browserDownloadMirrors, + }); + }); + it("should install browsers from config", async () => { testplaneStub.config.browsers = { "chrome@113": mkBrowser_("safari", "70"), diff --git a/test/src/config/index.js b/test/src/config/index.js index a1adcc1fd..74808f473 100644 --- a/test/src/config/index.js +++ b/test/src/config/index.js @@ -123,6 +123,33 @@ describe("config", () => { assert.instanceOf(config.forBrowser("bro1"), BrowserConfig); }); + it("should keep browser download mirrors only in the root config", async () => { + const browserDownloadMirrors = { + chrome: "https://mirror.example/chrome", + chromium: null, + firefox: null, + }; + const config = await initConfig({ + configParserReturns: { + browserDownloadMirrors, + browsers: { bro1: {} }, + }, + }); + const replacementMirrors = { + chrome: null, + chromium: "https://mirror.example/chromium", + firefox: "https://mirror.example/firefox", + }; + + config.browserDownloadMirrors = replacementMirrors; + const serializedConfig = config.serialize(); + + assert.strictEqual(config.browserDownloadMirrors, replacementMirrors); + assert.strictEqual(serializedConfig.browserDownloadMirrors, replacementMirrors); + assert.notProperty(config.forBrowser("bro1"), "browserDownloadMirrors"); + assert.notProperty(serializedConfig.browsers.bro1, "browserDownloadMirrors"); + }); + it("should extend browser config with its id", async () => { const config = await initConfig({ configParserReturns: { browsers: { bro: { some: "option" } } } }); @@ -201,5 +228,28 @@ describe("config", () => { assert.deepInclude(config, { option: 100500 }); }); + + it("should propagate serialized master mirrors to a worker despite null-to-string changes", async () => { + const config = await initConfig({ + configParserReturns: { + browserDownloadMirrors: { + chrome: null, + chromium: "https://mirror.example/worker-chromium", + firefox: null, + }, + }, + }); + const masterMirrors = { + chrome: "https://mirror.example/master-chrome", + chromium: null, + firefox: "https://mirror.example/master-firefox", + }; + const serializedMasterConfig = JSON.parse(JSON.stringify({ browserDownloadMirrors: masterMirrors })); + + config.mergeWith(serializedMasterConfig); + + assert.deepEqual(config.browserDownloadMirrors, masterMirrors); + assert.notStrictEqual(config.browserDownloadMirrors, masterMirrors); + }); }); }); diff --git a/test/src/config/options.js b/test/src/config/options.js index 1a370a6e6..bb31cbec4 100644 --- a/test/src/config/options.js +++ b/test/src/config/options.js @@ -18,6 +18,175 @@ describe("config options", () => { afterEach(() => sandbox.restore()); + describe("browserDownloadMirrors", () => { + it("should be empty by default", () => { + const result = parse_(); + + assert.deepEqual(result.browserDownloadMirrors, defaults.browserDownloadMirrors); + }); + + it("should read mirrors from config", () => { + const result = parse_({ + options: { + browserDownloadMirrors: { + chrome: "https://mirror.example/chrome", + chromium: "https://mirror.example/chromium", + firefox: "https://mirror.example/firefox", + }, + }, + }); + + assert.deepEqual(result.browserDownloadMirrors, { + chrome: "https://mirror.example/chrome", + chromium: "https://mirror.example/chromium", + firefox: "https://mirror.example/firefox", + }); + }); + + it("should preserve pathname prefixes and normalize whitespace and trailing slashes", () => { + const result = parse_({ + options: { + browserDownloadMirrors: { + chrome: " https://mirror.example/cache/chrome/// ", + }, + }, + }); + + assert.equal(result.browserDownloadMirrors.chrome, "https://mirror.example/cache/chrome"); + }); + + it("should reject non-string mirror values", () => { + assert.throws( + () => parse_({ options: { browserDownloadMirrors: { chrome: true } } }), + '"browserDownloadMirrors.chrome" must be a string', + ); + }); + + it("should reject empty mirror values", () => { + assert.throws( + () => parse_({ options: { browserDownloadMirrors: { chrome: " " } } }), + '"browserDownloadMirrors.chrome" must not be empty', + ); + }); + + ["chrome", "chromium", "firefox"].forEach(browserName => { + it(`should reject a non-absolute ${browserName} mirror URL`, () => { + assert.throws( + () => + parse_({ + options: { browserDownloadMirrors: { [browserName]: "mirror.example/browser" } }, + }), + `"browserDownloadMirrors.${browserName}" must be a valid URL, got mirror.example/browser`, + ); + }); + }); + + [ + { + name: "a non-HTTP protocol", + value: "ftp://mirror.example/chrome", + error: '"browserDownloadMirrors.chrome" must be an absolute http: or https:, got ftp', + }, + { + name: "a username", + value: "https://user@mirror.example/chrome", + error: '"browserDownloadMirrors.chrome" must not contain a username or password', + }, + { + name: "a password", + value: "https://user:password@mirror.example/chrome", + error: '"browserDownloadMirrors.chrome" must not contain a username or password', + }, + { + name: "a query string", + value: "https://mirror.example/chrome?channel=stable", + error: '"browserDownloadMirrors.chrome" must not contain a query string', + }, + { + name: "an empty query string", + value: "https://mirror.example/chrome?", + error: '"browserDownloadMirrors.chrome" must not contain a query string', + }, + { + name: "a fragment", + value: "https://mirror.example/chrome#stable", + error: '"browserDownloadMirrors.chrome" must not contain a fragment', + }, + { + name: "an empty fragment", + value: "https://mirror.example/chrome#", + error: '"browserDownloadMirrors.chrome" must not contain a fragment', + }, + ].forEach(({ name, value, error }) => { + it(`should reject a mirror URL with ${name}`, () => { + assert.throws(() => parse_({ options: { browserDownloadMirrors: { chrome: value } } }), error); + }); + }); + + it("should override config from lowercase environment variable", () => { + const result = parse_({ + options: { browserDownloadMirrors: { chrome: "https://config.example/chrome" } }, + env: { ["testplane_browser_download_mirrors_chrome"]: "https://env.example/chrome" }, + }); + + assert.equal(result.browserDownloadMirrors.chrome, "https://env.example/chrome"); + }); + + it("should retain the legacy lowercase hermione environment variable", () => { + const result = parse_({ + options: { browserDownloadMirrors: { chrome: "https://config.example/chrome" } }, + env: { ["hermione_browser_download_mirrors_chrome"]: "https://env.example/chrome" }, + }); + + assert.equal(result.browserDownloadMirrors.chrome, "https://env.example/chrome"); + }); + + it("should prefer uppercase environment variable", () => { + const result = parse_({ + options: { browserDownloadMirrors: { chrome: "https://config.example/chrome" } }, + env: { + ["testplane_browser_download_mirrors_chrome"]: "https://lowercase.example/chrome", + TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_CHROME: "https://uppercase.example/chrome", + }, + }); + + assert.equal(result.browserDownloadMirrors.chrome, "https://uppercase.example/chrome"); + }); + + 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", + }, + }); + + assert.deepEqual(result.browserDownloadMirrors, { + chrome: "https://env.example/chrome", + chromium: "https://env.example/chromium", + firefox: "https://env.example/firefox", + }); + }); + + it("should reject empty uppercase environment overrides for every mirror", () => { + [ + ["chrome", "TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_CHROME"], + ["chromium", "TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_CHROMIUM"], + ["firefox", "TESTPLANE_BROWSER_DOWNLOAD_MIRRORS_FIREFOX"], + ].forEach(([browserName, envName]) => { + assert.throws( + () => + parse_({ + options: { browserDownloadMirrors: { [browserName]: "https://config.example/browser" } }, + env: { [envName]: "" }, + }), + `"browserDownloadMirrors.${browserName}" must not be empty`, + ); + }); + }); + }); + describe("system", () => { describe("debug", () => { it("should throw error if debug is not a boolean", async () => { diff --git a/test/src/profiler/output/console.ts b/test/src/profiler/output/console.ts index f29e74245..14bca5cbd 100644 --- a/test/src/profiler/output/console.ts +++ b/test/src/profiler/output/console.ts @@ -147,7 +147,8 @@ describe("profiler/output/console", () => { assert.notCalled(stdout); assert.calledOnce(stderr); - assert.match(stderr.firstCall.args[0], /^\[profiler] Test run profile/); + assert.match(stderr.firstCall.args[0], /\[profiler]/); + assert.match(stderr.firstCall.args[0], /Test run profile/); }); it("should print one aligned report with phase and slow-test tables", () => { diff --git a/test/type-tests/browser-download-mirrors.ts b/test/type-tests/browser-download-mirrors.ts new file mode 100644 index 000000000..a0041c29d --- /dev/null +++ b/test/type-tests/browser-download-mirrors.ts @@ -0,0 +1,33 @@ +import { unstable, type BrowserDownloadMirrorsInput, type ConfigInput } from "../../src"; + +export const browserDownloadMirrors: BrowserDownloadMirrorsInput = { + chrome: "https://mirror.example/chrome", + chromium: "https://mirror.example/chromium", + firefox: "https://mirror.example/firefox", +}; + +export const configWithBrowserDownloadMirrors = { + browserDownloadMirrors, + browsers: { + chrome: { + desiredCapabilities: { + browserName: "chrome", + }, + }, + }, +} satisfies ConfigInput; + +export const launchStandaloneBrowserWithMirrors = (): ReturnType => + unstable.launchBrowser({ browserDownloadMirrors }); + +export const configWithInvalidPerBrowserMirrors = { + browsers: { + chrome: { + desiredCapabilities: { + browserName: "chrome", + }, + // @ts-expect-error browserDownloadMirrors is a root-only config option + browserDownloadMirrors, + }, + }, +} satisfies ConfigInput;