appium-chromedriver
Version:
Node.js wrapper around chromedriver.
448 lines (421 loc) • 15.6 kB
text/typescript
import {getChromedriverDir, retrieveData, getOsInfo, convertToInt, getCpuType} from '../utils';
import path from 'node:path';
import {asyncmap} from 'asyncbox';
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';
import type {
ChromedriverStorageClientOpts,
SyncOptions,
OSInfo,
ChromedriverDetailsMapping,
} from '../types';
const MAX_PARALLEL_DOWNLOADS = 5;
interface StorageInfo {
url: string;
accept: string;
}
const STORAGE_INFOS: 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');
export class ChromedriverStorageClient {
readonly chromedriverDir: string;
readonly timeout: number;
private mapping: ChromedriverDetailsMapping;
constructor(args: ChromedriverStorageClientOpts = {}) {
const {chromedriverDir = getChromedriverDir(), timeout = STORAGE_REQ_TIMEOUT_MS} = args;
this.chromedriverDir = chromedriverDir;
this.timeout = timeout;
this.mapping = {};
}
/**
* Retrieves chromedriver mapping from the storage
*
* @param 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): Promise<ChromedriverDetailsMapping> {
const retrieveResponseSafely = async ({
url,
accept,
}: StorageInfo): Promise<string | undefined> => {
try {
return await retrieveData(
url,
{
'user-agent': USER_AGENT,
accept: `${accept}, */*`,
},
{timeout: this.timeout},
);
} catch (e) {
const err = e as Error;
log.debug(err.stack);
log.warn(
`Cannot retrieve Chromedrivers info from ${url}. ` +
`Make sure this URL is accessible from your network. ` +
`Original error: ${err.message}`,
);
}
};
const [xmlStr, jsonStr] = await Promise.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 available ` +
`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;
}
/**
* Retrieves chromedrivers from the remote storage to the local file system
*
* @param opts - Synchronization options (versions, minBrowserVersion, osInfo)
* @throws {Error} if there was a problem while retrieving the drivers
* @returns The list of successfully synchronized driver keys
*/
async syncDrivers(opts: SyncOptions = {}): Promise<string[]> {
if (util.isEmpty(this.mapping)) {
await this.retrieveMapping(!!opts.minBrowserVersion);
}
if (util.isEmpty(this.mapping)) {
throw new Error('Cannot retrieve chromedrivers mapping from Google storage');
}
const driversToSync = this.selectMatchingDrivers(opts.osInfo ?? (await getOsInfo()), opts);
if (util.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),
);
const synchronizedDrivers: string[] = [];
const archivesRoot = await tempDir.openDir();
try {
await asyncmap(
[...driversToSync.entries()],
async ([idx, driverKey]) => {
if (await this.retrieveDriver(idx, driverKey, archivesRoot, !util.isEmpty(opts))) {
synchronizedDrivers.push(driverKey);
}
},
{concurrency: MAX_PARALLEL_DOWNLOADS},
);
} finally {
await fs.rimraf(archivesRoot);
}
if (!util.isEmpty(synchronizedDrivers)) {
log.info(
`Successfully synchronized ` +
`${util.pluralize('chromedriver', synchronizedDrivers.length, true)}`,
);
} else {
log.info(`No chromedrivers were synchronized`);
}
return synchronizedDrivers;
}
/**
* Returns the latest chromedriver version for Chrome for Testing
*
* @returns The latest stable chromedriver version string
* @throws {Error} if the version cannot be fetched from the remote API
*/
async getLatestKnownGoodVersion(): Promise<string> {
let jsonStr: string;
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 detail = e instanceof Error ? e.message : String(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 environment variable. Original error: ${detail}`,
{cause: e},
);
}
return parseLatestKnownGoodVersionsJson(jsonStr);
}
/**
* Filters `this.mapping` to only select matching chromedriver entries
* by operating system information and/or additional synchronization options
*
* @param osInfo - Operating system information to match against
* @param opts - Synchronization options (versions, minBrowserVersion)
* @returns The list of filtered chromedriver entry names (version/archive name)
*/
private selectMatchingDrivers(osInfo: OSInfo, opts: SyncOptions = {}): string[] {
const {minBrowserVersion, versions = []} = opts;
let driversToSync = Object.keys(this.mapping);
if (!util.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 (util.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 (util.isEmpty(driversToSync)) {
return [];
}
log.debug(
`Will select candidate ${util.pluralize('driver', driversToSync.length)} ` +
`versioned as '${util.uniq(driversToSync.map((cdName) => this.mapping[cdName].version))}'`,
);
}
if (!util.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 (util.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 (util.isEmpty(result) && (name === OS.MAC || name === OS.WINDOWS) && cpu === CPU.ARM) {
// Fallback to Intel (Rosetta on macOS, x64 emulation on Windows ARM):
// https://github.com/appium/appium-chromedriver/issues/562
result = driversToSync.filter((cdName) =>
this.doesMatchForOsInfo(cdName, {
name,
arch,
cpu: CPU.INTEL,
}),
);
}
driversToSync = result;
log.debug(`Got ${util.pluralize('item', driversToSync.length, true)}`);
}
if (!util.isEmpty(driversToSync)) {
log.debug('Excluding older patches if present');
const patchesMap: {[key: string]: string[]} = {};
// Older chromedrivers must not be excluded as they follow a different
// versioning pattern
const versionWithPatchPattern = /\d+\.\d+\.\d+\.\d+/;
const selectedVersions = new Set<string>();
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 (!Array.isArray(patchesMap[verObj.major])) {
patchesMap[verObj.major] = [];
}
patchesMap[verObj.major].push(cdVersion);
}
for (const majorVersion of Object.keys(patchesMap)) {
if (patchesMap[majorVersion].length <= 1) {
continue;
}
patchesMap[majorVersion].sort((a: string, b: string) => compareVersions(b, a));
}
if (!util.isEmpty(patchesMap)) {
log.debug('Versions mapping: ' + JSON.stringify(patchesMap, null, 2));
for (const sortedVersions of Object.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 cdName - The chromedriver entry key in the mapping
* @param osInfo - Operating system information to match against
* @returns True if the chromedriver matches the OS info
*/
private doesMatchForOsInfo(cdName: string, {name, arch, cpu}: OSInfo): boolean {
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 index - The unique driver index
* @param driverKey - The driver key in `this.mapping`
* @param archivesRoot - The temporary folder path to extract
* downloaded archives to
* @param 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 if `true` then the chromedriver is successfully
* downloaded and extracted.
*/
private async retrieveDriver(
index: number,
driverKey: string,
archivesRoot: string,
isStrict = false,
): Promise<boolean> {
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 = e as Error;
const msg = `Cannot download chromedriver archive. Original error: ${err.message}`;
if (isStrict) {
throw new Error(msg, {cause: e});
}
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 = e as Error;
if (isStrict) {
throw err;
}
log.error(err.message);
return false;
}
return true;
}
/**
* Extracts downloaded chromedriver archive
* into the given destination
*
* @param src - The source archive path
* @param dst - The destination chromedriver path
*/
private async unzipDriver(src: string, dst: string): Promise<void> {
const tmpRoot = await tempDir.openDir();
try {
await zip.extractAllTo(src, tmpRoot);
const chromedriverPath = await fs.walkDir(
tmpRoot,
true,
(itemPath, isDirectory) =>
!isDirectory && path.parse(itemPath).name.toLowerCase() === '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);
}
}
}
async function isCrcOk(src: string, checksum: string): Promise<boolean> {
const md5 = await fs.hash(src, 'md5');
return md5.toLowerCase() === checksum.toLowerCase();
}