UNPKG

appium-chromedriver

Version:
451 lines (424 loc) 15.5 kB
import { getChromedriverDir, retrieveData, getOsInfo, convertToInt, getCpuType, } from '../utils'; import _ from 'lodash'; import B from 'bluebird'; import path from 'path'; import {system, fs, logger, tempDir, zip, util, net} from '@appium/support'; import { STORAGE_REQ_TIMEOUT_MS, GOOGLEAPIS_CDN, USER_AGENT, CHROMELABS_URL, ARCH, OS, CPU, } from '../constants'; import {parseGoogleapiStorageXml} from './googleapis'; import {parseKnownGoodVersionsWithDownloadsJson, parseLatestKnownGoodVersionsJson} from './chromelabs'; import {compareVersions} from 'compare-versions'; import * as semver from 'semver'; const MAX_PARALLEL_DOWNLOADS = 5; const STORAGE_INFOS = /** @type {readonly StorageInfo[]} */ ([{ url: GOOGLEAPIS_CDN, accept: 'application/xml', }, { url: `${CHROMELABS_URL}/chrome-for-testing/known-good-versions-with-downloads.json`, accept: 'application/json', }]); const CHROME_FOR_TESTING_LAST_GOOD_VERSIONS = `${CHROMELABS_URL}/chrome-for-testing/last-known-good-versions.json`; const log = logger.getLogger('ChromedriverStorageClient'); /** * * @param {string} src * @param {string} checksum * @returns {Promise<boolean>} */ async function isCrcOk(src, checksum) { const md5 = await fs.hash(src, 'md5'); return _.toLower(md5) === _.toLower(checksum); } export class ChromedriverStorageClient { /** * * @param {import('../types').ChromedriverStorageClientOpts} args */ constructor(args = {}) { const {chromedriverDir = getChromedriverDir(), timeout = STORAGE_REQ_TIMEOUT_MS} = args; this.chromedriverDir = chromedriverDir; this.timeout = timeout; /** @type {ChromedriverDetailsMapping} */ this.mapping = {}; } /** * Retrieves chromedriver mapping from the storage * * @param {boolean} shouldParseNotes [true] - if set to `true` * then additional chromedrivers info is going to be retrieved and * parsed from release notes * @returns {Promise<ChromedriverDetailsMapping>} */ async retrieveMapping(shouldParseNotes = true) { /** @type {(si: StorageInfo) => Promise<string|undefined>} */ const retrieveResponseSafely = async (/** @type {StorageInfo} */ {url, accept}) => { try { return await retrieveData(url, { 'user-agent': USER_AGENT, accept: `${accept}, */*`, }, {timeout: this.timeout}); } catch (e) { log.debug(/** @type {Error} */(e).stack); log.warn( `Cannot retrieve Chromedrivers info from ${url}. ` + `Make sure this URL is accessible from your network. ` + `Original error: ${/** @type {Error} */(e).message}` ); } }; const [xmlStr, jsonStr] = await B.all(STORAGE_INFOS.map(retrieveResponseSafely)); // Apply the best effort approach and fetch the mapping from at least one server if possible. // We'll fail later anyway if the target chromedriver version is not there. if (!xmlStr && !jsonStr) { throw new Error( `Cannot retrieve the information about available Chromedrivers from ` + `${STORAGE_INFOS.map(({url}) => url)}. Please make sure these URLs are avilable ` + `within your local network, check Appium server logs and/or ` + `consult the driver troubleshooting guide.` ); } this.mapping = xmlStr ? await parseGoogleapiStorageXml(xmlStr, shouldParseNotes) : {}; if (jsonStr) { Object.assign(this.mapping, parseKnownGoodVersionsWithDownloadsJson(jsonStr)); } return this.mapping; } /** * Extracts downloaded chromedriver archive * into the given destination * * @param {string} src - The source archive path * @param {string} dst - The destination chromedriver path */ async unzipDriver(src, dst) { const tmpRoot = await tempDir.openDir(); try { await zip.extractAllTo(src, tmpRoot); const chromedriverPath = await fs.walkDir( tmpRoot, true, (itemPath, isDirectory) => !isDirectory && _.toLower(path.parse(itemPath).name) === 'chromedriver' ); if (!chromedriverPath) { throw new Error( 'The archive was unzipped properly, but we could not find any chromedriver executable' ); } log.debug(`Moving the extracted '${path.basename(chromedriverPath)}' to '${dst}'`); await fs.mv(chromedriverPath, dst, { mkdirp: true, }); } finally { await fs.rimraf(tmpRoot); } } /** * Filters `this.mapping` to only select matching * chromedriver entries by operating system information * and/or additional synchronization options (if provided) * * @param {OSInfo} osInfo * @param {SyncOptions} opts * @returns {Array<String>} The list of filtered chromedriver * entry names (version/archive name) */ selectMatchingDrivers(osInfo, opts = {}) { const {minBrowserVersion, versions = []} = opts; let driversToSync = _.keys(this.mapping); if (!_.isEmpty(versions)) { // Handle only selected versions if requested log.debug(`Selecting chromedrivers whose versions match to ${versions}`); driversToSync = driversToSync.filter((cdName) => versions.includes(`${this.mapping[cdName].version}`) ); log.debug(`Got ${util.pluralize('item', driversToSync.length, true)}`); if (_.isEmpty(driversToSync)) { return []; } } const minBrowserVersionInt = convertToInt(minBrowserVersion); if (minBrowserVersionInt !== null) { // Only select drivers that support the current browser whose major version number equals to `minBrowserVersion` log.debug( `Selecting chromedrivers whose minimum supported browser version matches to ${minBrowserVersionInt}` ); let closestMatchedVersionNumber = 0; // Select the newest available and compatible chromedriver for (const cdName of driversToSync) { const currentMinBrowserVersion = parseInt( String(this.mapping[cdName].minBrowserVersion), 10 ); if ( !Number.isNaN(currentMinBrowserVersion) && currentMinBrowserVersion <= minBrowserVersionInt && closestMatchedVersionNumber < currentMinBrowserVersion ) { closestMatchedVersionNumber = currentMinBrowserVersion; } } driversToSync = driversToSync.filter( (cdName) => `${this.mapping[cdName].minBrowserVersion}` === `${closestMatchedVersionNumber > 0 ? closestMatchedVersionNumber : minBrowserVersionInt}` ); log.debug(`Got ${util.pluralize('item', driversToSync.length, true)}`); if (_.isEmpty(driversToSync)) { return []; } log.debug( `Will select candidate ${util.pluralize('driver', driversToSync.length)} ` + `versioned as '${_.uniq(driversToSync.map((cdName) => this.mapping[cdName].version))}'` ); } if (!_.isEmpty(osInfo)) { // Filter out drivers for unsupported system architectures const {name, arch, cpu = getCpuType()} = osInfo; log.debug(`Selecting chromedrivers whose platform matches to ${name}:${cpu}${arch}`); let result = driversToSync.filter((cdName) => this.doesMatchForOsInfo(cdName, osInfo)); if (_.isEmpty(result) && arch === ARCH.X64 && cpu === CPU.INTEL) { // Fallback to X86 if X64 architecture is not available for this driver result = driversToSync.filter((cdName) => this.doesMatchForOsInfo(cdName, { name, arch: ARCH.X86, cpu })); } if (_.isEmpty(result) && name === OS.MAC && cpu === CPU.ARM) { // Fallback to Intel/Rosetta if ARM architecture is not available for this driver result = driversToSync.filter((cdName) => this.doesMatchForOsInfo(cdName, { name, arch, cpu: CPU.INTEL })); } driversToSync = result; log.debug(`Got ${util.pluralize('item', driversToSync.length, true)}`); } if (!_.isEmpty(driversToSync)) { log.debug('Excluding older patches if present'); /** @type {{[key: string]: string[]}} */ const patchesMap = {}; // Older chromedrivers must not be excluded as they follow a different // versioning pattern const versionWithPatchPattern = /\d+\.\d+\.\d+\.\d+/; const selectedVersions = new Set(); for (const cdName of driversToSync) { const cdVersion = this.mapping[cdName].version; if (!versionWithPatchPattern.test(cdVersion)) { selectedVersions.add(cdVersion); continue; } const verObj = semver.parse(cdVersion, {loose: true}); if (!verObj) { continue; } if (!_.isArray(patchesMap[verObj.major])) { patchesMap[verObj.major] = []; } patchesMap[verObj.major].push(cdVersion); } for (const majorVersion of _.keys(patchesMap)) { if (patchesMap[majorVersion].length <= 1) { continue; } patchesMap[majorVersion].sort( (/** @type {string} */ a, /** @type {string}} */ b) => compareVersions(b, a) ); } if (!_.isEmpty(patchesMap)) { log.debug('Versions mapping: ' + JSON.stringify(patchesMap, null, 2)); for (const sortedVersions of _.values(patchesMap)) { selectedVersions.add(sortedVersions[0]); } driversToSync = driversToSync.filter( (cdName) => selectedVersions.has(this.mapping[cdName].version) ); } } return driversToSync; } /** * Checks whether the given chromedriver matches the operating system to run on * * @param {string} cdName * @param {OSInfo} osInfo * @returns {boolean} */ doesMatchForOsInfo(cdName, {name, arch, cpu}) { const cdInfo = this.mapping[cdName]; if (!cdInfo) { return false; } if (cdInfo.os.name !== name || cdInfo.os.arch !== arch) { return false; } if (cpu && cdInfo.os.cpu && this.mapping[cdName].os.cpu !== cpu) { return false; } return true; } /** * Retrieves the given chromedriver from the storage * and unpacks it into `this.chromedriverDir` folder * * @param {number} index - The unique driver index * @param {string} driverKey - The driver key in `this.mapping` * @param {string} archivesRoot - The temporary folder path to extract * downloaded archives to * @param {boolean} isStrict [true] - Whether to throw an error (`true`) * or return a boolean result if the driver retrieval process fails * @throws {Error} if there was a failure while retrieving the driver * and `isStrict` is set to `true` * @returns {Promise<boolean>} if `true` then the chromedriver is successfully * downloaded and extracted. */ async retrieveDriver(index, driverKey, archivesRoot, isStrict = false) { const {url, etag, version} = this.mapping[driverKey]; const archivePath = path.resolve(archivesRoot, `${index}.zip`); log.debug(`Retrieving '${url}' to '${archivePath}'`); try { await net.downloadFile(url, archivePath, { isMetered: false, timeout: STORAGE_REQ_TIMEOUT_MS, }); } catch (e) { const err = /** @type {Error} */ (e); const msg = `Cannot download chromedriver archive. Original error: ${err.message}`; if (isStrict) { throw new Error(msg); } log.error(msg); return false; } if (etag && !(await isCrcOk(archivePath, etag))) { const msg = `The checksum for the downloaded chromedriver '${driverKey}' did not match`; if (isStrict) { throw new Error(msg); } log.error(msg); return false; } const fileName = `${path.parse(url).name}_v${version}` + (system.isWindows() ? '.exe' : ''); const targetPath = path.resolve(this.chromedriverDir, fileName); try { await this.unzipDriver(archivePath, targetPath); await fs.chmod(targetPath, 0o755); log.debug(`Permissions of the file '${targetPath}' have been changed to 755`); } catch (e) { const err = /** @type {Error} */ (e); if (isStrict) { throw err; } log.error(err.message); return false; } return true; } /** * Retrieves chromedrivers from the remote storage * to the local file system * * @param {SyncOptions} opts * @throws {Error} if there was a problem while retrieving * the drivers * @returns {Promise<string[]>} The list of successfully synchronized driver keys */ async syncDrivers(opts = {}) { if (_.isEmpty(this.mapping)) { await this.retrieveMapping(!!opts.minBrowserVersion); } if (_.isEmpty(this.mapping)) { throw new Error('Cannot retrieve chromedrivers mapping from Google storage'); } const driversToSync = this.selectMatchingDrivers(opts.osInfo ?? (await getOsInfo()), opts); if (_.isEmpty(driversToSync)) { log.debug(`There are no drivers to sync. Exiting`); return []; } log.debug( `Got ${util.pluralize('driver', driversToSync.length, true)} to sync: ` + JSON.stringify(driversToSync, null, 2) ); /** * @type {string[]} */ const synchronizedDrivers = []; const promises = []; const chunk = []; const archivesRoot = await tempDir.openDir(); try { for (const [idx, driverKey] of driversToSync.entries()) { const promise = B.resolve( (async () => { if (await this.retrieveDriver(idx, driverKey, archivesRoot, !_.isEmpty(opts))) { synchronizedDrivers.push(driverKey); } })() ); promises.push(promise); chunk.push(promise); if (chunk.length >= MAX_PARALLEL_DOWNLOADS) { await B.any(chunk); } _.remove(chunk, (p) => p.isFulfilled()); } await B.all(promises); } finally { await fs.rimraf(archivesRoot); } if (!_.isEmpty(synchronizedDrivers)) { log.info( `Successfully synchronized ` + `${util.pluralize('chromedriver', synchronizedDrivers.length, true)}` ); } else { log.info(`No chromedrivers were synchronized`); } return synchronizedDrivers; } /** * Return latest chromedriver version for Chrome for Testing. * @returns {Promise<string>} */ async getLatestKnownGoodVersion () { let jsonStr; try { jsonStr = await retrieveData( CHROME_FOR_TESTING_LAST_GOOD_VERSIONS, { 'user-agent': USER_AGENT, accept: `application/json, */*`, }, {timeout: STORAGE_REQ_TIMEOUT_MS} ); } catch (e) { const err = /** @type {Error} */ (e); throw new Error(`Cannot fetch the latest Chromedriver version. ` + `Make sure you can access ${CHROME_FOR_TESTING_LAST_GOOD_VERSIONS} from your machine or provide a mirror by setting ` + `a custom value to CHROMELABS_URL enironment variable. Original error: ${err.message}`); } return parseLatestKnownGoodVersionsJson(jsonStr); } } export default ChromedriverStorageClient; /** * @typedef {import('../types').SyncOptions} SyncOptions * @typedef {import('../types').OSInfo} OSInfo * @typedef {import('../types').ChromedriverDetails} ChromedriverDetails * @typedef {import('../types').ChromedriverDetailsMapping} ChromedriverDetailsMapping */ /** * @typedef {Object} StorageInfo * @property {string} url * @property {string} accept */