UNPKG

appium-chromedriver

Version:
418 lines (390 loc) 14.3 kB
import {fs, util} from '@appium/support'; import {asyncmap} from 'asyncbox'; import {compareVersions} from 'compare-versions'; import {type ExecError} from 'teen_process'; import path from 'node:path'; import * as semver from 'semver'; import {CHROMEDRIVER_CHROME_MAPPING, getChromedriverBinaryPath} from '../utils'; import type {ChromedriverVersionMapping} from '../types'; import type {ChromedriverCommandContext} from './types'; const NEW_CD_VERSION_FORMAT_MAJOR_VERSION = 73; const CD_VERSION_TIMEOUT = 5000; const GET_COMPATIBLE_CHROMEDRIVER_MAX_ITERATIONS = 10; export interface ChromedriverInfo { executable: string; version: string; minChromeVersion: string | null; } type ChromedriverSelectionSelf = ChromedriverCommandContext & { getDriversMapping: () => Promise<ChromedriverVersionMapping>; getChromedrivers: (mapping: ChromedriverVersionMapping) => Promise<ChromedriverInfo[]>; updateDriversMapping: (mapping: ChromedriverVersionMapping) => Promise<void>; getChromeVersion: () => Promise<semver.SemVer | null>; getCompatibleChromedriver: () => Promise<string>; }; /** * Loads and normalizes Chromedriver-to-Chrome version mapping. */ export async function getDriversMapping( this: ChromedriverCommandContext, ): Promise<ChromedriverVersionMapping> { let mapping = structuredClone(CHROMEDRIVER_CHROME_MAPPING); if (this.mappingPath) { this.log.debug(`Attempting to use Chromedriver->Chrome mapping from '${this.mappingPath}'`); if (!(await fs.exists(this.mappingPath))) { this.log.warn(`No file found at '${this.mappingPath}'`); this.log.info('Defaulting to the static Chromedriver->Chrome mapping'); } else { try { mapping = JSON.parse(await fs.readFile(this.mappingPath, 'utf8')); } catch (e) { const err = e as Error; this.log.warn(`Error parsing mapping from '${this.mappingPath}': ${err.message}`); this.log.info('Defaulting to the static Chromedriver->Chrome mapping'); } } } else { this.log.debug('Using the static Chromedriver->Chrome mapping'); } for (const [cdVersion, chromeVersion] of Object.entries(mapping)) { const coercedVersion = semver.coerce(chromeVersion); if (coercedVersion) { mapping[cdVersion] = coercedVersion.version; } else { this.log.info(`'${chromeVersion}' is not a valid version number. Skipping it`); } } return mapping; } /** * Discovers available Chromedriver binaries and parses their versions. */ export async function getChromedrivers( this: ChromedriverCommandContext, mapping: ChromedriverVersionMapping, ): Promise<ChromedriverInfo[]> { // enumerate available executables in configured chromedriver directory const executables = await fs.glob('*', { cwd: this.executableDir, nodir: true, absolute: true, }); this.log.debug( `Found ${util.pluralize('executable', executables.length, true)} ` + `in '${this.executableDir}'`, ); const cds = ( await asyncmap(executables, async (executable: string) => { const logError = ({ message, stdout, stderr, }: { message: string; stdout?: string; stderr?: string; }): null => { let errMsg = `Cannot retrieve version number from '${path.basename(executable)}' Chromedriver binary. ` + `Make sure it returns a valid version string in response to '--version' command line argument. ${message}`; if (stdout) { errMsg += `\nStdout: ${stdout}`; } if (stderr) { errMsg += `\nStderr: ${stderr}`; } this.log.warn(errMsg); return null; }; let stdout: string; let stderr: string | undefined; try { ({stdout, stderr} = await this._execFunc(executable, ['--version'], { timeout: CD_VERSION_TIMEOUT, })); } catch (e) { const err = e as ExecError; if ( !(err.message || '').includes('timed out') && !(err.stdout || '').includes('Starting ChromeDriver') ) { return logError(err); } // timeouts may still contain the version banner in stdout stdout = err.stdout; } const match = /ChromeDriver\s+\(?v?([\d.]+)\)?/i.exec(stdout); if (!match) { return logError({message: 'Cannot parse the version string', stdout, stderr}); } let version = match[1]; let minChromeVersion = mapping[version] || null; const coercedVersion = semver.coerce(version); if (coercedVersion) { if (coercedVersion.major < NEW_CD_VERSION_FORMAT_MAJOR_VERSION) { version = `${coercedVersion.major}.${coercedVersion.minor}`; minChromeVersion = mapping[version] || null; } if (!minChromeVersion && coercedVersion.major >= NEW_CD_VERSION_FORMAT_MAJOR_VERSION) { minChromeVersion = `${coercedVersion.major}`; } } return {executable, version, minChromeVersion}; }) ) .filter((cd): cd is ChromedriverInfo => !!cd) .sort((a, b) => compareVersions(b.version, a.version)); if (cds.length === 0) { this.log.info(`No Chromedrivers were found in '${this.executableDir}'`); return cds; } this.log.debug(`The following Chromedriver executables were found:`); for (const cd of cds) { this.log.debug( ` '${cd.executable}' (version '${cd.version}', minimum Chrome version '${ cd.minChromeVersion ? cd.minChromeVersion : 'Unknown' }')`, ); } return cds; } /** * Persists updated version mapping to disk or falls back to in-memory update. */ export async function updateDriversMapping( this: ChromedriverCommandContext, newMapping: ChromedriverVersionMapping, ): Promise<void> { let shouldUpdateStaticMapping = true; if (!this.mappingPath) { this.log.warn('No mapping path provided'); return; } if (await fs.exists(this.mappingPath)) { try { await fs.writeFile(this.mappingPath, JSON.stringify(newMapping, null, 2), 'utf8'); shouldUpdateStaticMapping = false; } catch (e) { const err = e as Error; this.log.warn( `Cannot store the updated chromedrivers mapping into '${this.mappingPath}'. ` + `This may reduce the performance of further executions. Original error: ${err.message}`, ); } } if (shouldUpdateStaticMapping) { Object.assign(CHROMEDRIVER_CHROME_MAPPING, newMapping); } } /** * Selects the most suitable Chromedriver binary for current environment. */ export async function getCompatibleChromedriver(this: ChromedriverCommandContext): Promise<string> { if (usesDesktopChromedriverDefault(this)) { return await getChromedriverBinaryPath(); } const ctx = this as ChromedriverSelectionSelf; const mapping = await ctx.getDriversMapping(); if (!util.isEmpty(mapping)) { ctx.log.debug(`The most recent known Chrome version: ${Object.values(mapping)[0]}`); } const syncState = {didStorageSync: false}; for (let iteration = 0; iteration < GET_COMPATIBLE_CHROMEDRIVER_MAX_ITERATIONS; iteration++) { const cds = await ctx.getChromedrivers(mapping); await mergeDiscoveredMappingGaps(ctx, cds, mapping); if (ctx.disableBuildCheck) { return pickChromedriverWithBuildCheckDisabled(ctx, cds); } const chromeVersion = await ctx.getChromeVersion(); if (!chromeVersion) { return pickChromedriverWhenChromeUnknown(ctx, cds); } ctx.log.debug(`Found Chrome bundle '${ctx.bundleId}' version '${chromeVersion}'`); const matchingDrivers = filterChromedriversMatchingChrome(cds, chromeVersion); if (matchingDrivers.length === 0) { if (ctx.storageClient && !syncState.didStorageSync) { try { if (await attemptChromedriverStorageSync(ctx, mapping, chromeVersion, syncState)) { continue; } } catch (e) { const err = e as Error; ctx.log.warn( `Cannot synchronize local chromedrivers with the remote storage: ${err.message}`, ); if (err.stack) { ctx.log.debug(err.stack); } } } throw makeNoMatchingChromedriverError(ctx, chromeVersion); } return logChosenMatchingChromedriver(ctx, matchingDrivers, chromeVersion); } throw new Error( `Exceeded ${GET_COMPATIBLE_CHROMEDRIVER_MAX_ITERATIONS} iterations while selecting a ` + `compatible Chromedriver.`, ); } /** * Resolves and verifies the effective Chromedriver executable path. */ export async function initChromedriverPath(this: ChromedriverCommandContext): Promise<string> { if (this.executableVerified && this.chromedriver) { return this.chromedriver; } let chromedriver = this.chromedriver; if (!chromedriver) { chromedriver = this.chromedriver = this.useSystemExecutable ? await getChromedriverBinaryPath() : await (this as ChromedriverSelectionSelf).getCompatibleChromedriver(); } if (!chromedriver) { throw new Error('Cannot determine a valid Chromedriver executable path'); } if (!(await fs.exists(chromedriver))) { throw new Error( `Trying to use a chromedriver binary at the path ${chromedriver}, but it doesn't exist!`, ); } this.executableVerified = true; this.log.info(`Set chromedriver binary as: ${chromedriver}`); return chromedriver; } function usesDesktopChromedriverDefault(ctx: ChromedriverCommandContext): boolean { return !ctx.adb && !ctx.isCustomExecutableDir; } async function mergeDiscoveredMappingGaps( ctx: ChromedriverSelectionSelf, cds: ChromedriverInfo[], mapping: ChromedriverVersionMapping, ): Promise<void> { const missingVersions: ChromedriverVersionMapping = {}; for (const {version, minChromeVersion} of cds) { if (!minChromeVersion || mapping[version]) { continue; } const coercedVer = semver.coerce(version); if (!coercedVer || coercedVer.major < NEW_CD_VERSION_FORMAT_MAJOR_VERSION) { continue; } missingVersions[version] = minChromeVersion; } const missingCount = Object.keys(missingVersions).length; if (missingCount === 0) { return; } ctx.log.info( `Found ${util.pluralize('Chromedriver', missingCount, true)}, ` + `which ${missingCount === 1 ? 'is' : 'are'} missing in the list of known versions: ` + JSON.stringify(missingVersions), ); await ctx.updateDriversMapping(Object.assign(mapping, missingVersions)); } function pickChromedriverWithBuildCheckDisabled( ctx: ChromedriverSelectionSelf, cds: ChromedriverInfo[], ): string { if (cds.length === 0) { throw ctx.log.errorWithException( `There must be at least one Chromedriver executable available for use if ` + `'chromedriverDisableBuildCheck' capability is set to 'true'`, ); } const {version, executable} = cds[0]; ctx.log.warn( `Chrome build check disabled. Using most recent Chromedriver version (${version}, at '${executable}')`, ); ctx.log.warn(`If this is wrong, set 'chromedriverDisableBuildCheck' capability to 'false'`); return executable; } function pickChromedriverWhenChromeUnknown( ctx: ChromedriverSelectionSelf, cds: ChromedriverInfo[], ): string { if (cds.length === 0) { throw ctx.log.errorWithException( `There must be at least one Chromedriver executable available for use if ` + `the current Chrome version cannot be determined`, ); } const {version, executable} = cds[0]; ctx.log.warn( `Unable to discover Chrome version. Using Chromedriver ${version} at '${executable}'`, ); return executable; } function filterChromedriversMatchingChrome( cds: ChromedriverInfo[], chromeVersion: semver.SemVer, ): ChromedriverInfo[] { return cds.filter(({minChromeVersion}) => { const minChromeVersionS = minChromeVersion && semver.coerce(minChromeVersion); if (!minChromeVersionS) { return false; } return chromeVersion.major > NEW_CD_VERSION_FORMAT_MAJOR_VERSION ? minChromeVersionS.major === chromeVersion.major : semver.gte(chromeVersion, minChromeVersionS); }); } /** * Syncs drivers from remote storage into `mapping` and persists when possible. * Sets `syncState.didStorageSync` before any early return so a second sync is not attempted. */ async function attemptChromedriverStorageSync( ctx: ChromedriverSelectionSelf, mapping: ChromedriverVersionMapping, chromeVersion: semver.SemVer, syncState: {didStorageSync: boolean}, ): Promise<boolean> { syncState.didStorageSync = true; if (!ctx.storageClient) { return false; } const retrievedMapping = await ctx.storageClient.retrieveMapping(); ctx.log.debug( 'Got chromedrivers mapping from the storage: ' + util.truncateString(JSON.stringify(retrievedMapping, null, 2), {length: 500}), ); const driverKeys = await ctx.storageClient.syncDrivers({ minBrowserVersion: chromeVersion.major, }); if (driverKeys.length === 0) { return false; } const synchronizedDriversMapping = driverKeys.reduce((acc, x) => { const {version, minBrowserVersion} = retrievedMapping[x]; acc[version] = minBrowserVersion; return acc; }, {} as ChromedriverVersionMapping); Object.assign(mapping, synchronizedDriversMapping); await ctx.updateDriversMapping(mapping); return true; } function makeNoMatchingChromedriverError( ctx: ChromedriverSelectionSelf, chromeVersion: semver.SemVer, ): Error { const autodownloadSuggestion = 'You could also try to enable automated chromedrivers download as a possible workaround.'; return new Error( `No Chromedriver found that can automate Chrome '${chromeVersion}'.` + (ctx.storageClient ? '' : ` ${autodownloadSuggestion}`), ); } function logChosenMatchingChromedriver( ctx: ChromedriverSelectionSelf, matchingDrivers: ChromedriverInfo[], chromeVersion: semver.SemVer, ): string { const binPath = matchingDrivers[0].executable; ctx.log.debug( `Found ${util.pluralize('executable', matchingDrivers.length, true)} ` + `capable of automating Chrome '${chromeVersion}'.\nChoosing the most recent, '${binPath}'.`, ); ctx.log.debug( `If a specific version is required, specify it with the 'chromedriverExecutable' capability.`, ); return binPath; }