appium-chromedriver
Version:
Node.js wrapper around chromedriver.
209 lines (195 loc) • 6.45 kB
text/typescript
import {select as xpathSelect} from 'xpath';
import {util, logger} from '@appium/support';
import {retrieveData} from '../utils';
import {asyncmap} from 'asyncbox';
import {STORAGE_REQ_TIMEOUT_MS, GOOGLEAPIS_CDN, ARCH, CPU, APPLE_ARM_SUFFIXES} from '../constants';
import {DOMParser} from '@xmldom/xmldom';
import path from 'node:path';
import type {
AdditionalDriverDetails,
ChromedriverDetails,
ChromedriverDetailsMapping,
} from '../types';
const log = logger.getLogger('ChromedriverGoogleapisStorageClient');
const MAX_PARALLEL_DOWNLOADS = 5;
/**
* Finds a child node in an XML node by name and/or text content
*
* @param parent - The parent XML node to search in
* @param childName - Optional child node name to match
* @param text - Optional text content to match
* @returns The matching child node or null if not found
*/
export function findChildNode(
parent: Node | Attr,
childName: string | null = null,
text: string | null = null,
): Node | Attr | null {
if (!childName && !text) {
return null;
}
if (!parent.hasChildNodes()) {
return null;
}
for (let childNodeIdx = 0; childNodeIdx < parent.childNodes.length; childNodeIdx++) {
const childNode = parent.childNodes[childNodeIdx] as Element | Attr;
if (childName && !text && childName === childNode.localName) {
return childNode;
}
if (text) {
const childText = extractNodeText(childNode);
if (!childText) {
continue;
}
if (childName && childName === childNode.localName && text === childText) {
return childNode;
}
if (!childName && text === childText) {
return childNode;
}
}
}
return null;
}
/**
* Gets additional chromedriver details from chromedriver
* release notes
*
* @param content - Release notes of the corresponding chromedriver
* @returns AdditionalDriverDetails
*/
export function parseNotes(content: string): AdditionalDriverDetails {
const result: AdditionalDriverDetails = {};
const versionMatch = /^\s*[-]+ChromeDriver[\D]+([\d.]+)/im.exec(content);
if (versionMatch) {
result.version = versionMatch[1];
}
const minBrowserVersionMatch = /^\s*Supports Chrome[\D]+(\d+)/im.exec(content);
if (minBrowserVersionMatch) {
result.minBrowserVersion = minBrowserVersionMatch[1];
}
return result;
}
/**
* Parses chromedriver storage XML and returns
* the parsed results
*
* @param xml - The chromedriver storage XML
* @param shouldParseNotes [true] - If set to `true`
* then additional drivers information is going to be parsed
* and assigned to `this.mapping`
* @returns Promise<ChromedriverDetailsMapping>
*/
export async function parseGoogleapiStorageXml(
xml: string,
shouldParseNotes = true,
): Promise<ChromedriverDetailsMapping> {
const doc = new DOMParser().parseFromString(xml, 'text/xml');
const driverNodes = xpathSelect(`//*[local-name(.)='Contents']`, doc as unknown as Node) as Array<
Node | Attr
>;
log.debug(`Parsed ${driverNodes.length} entries from storage XML`);
if (driverNodes.length === 0) {
throw new Error('Cannot retrieve any valid Chromedriver entries from the storage config');
}
const infoParsers: Array<() => Promise<void>> = [];
const mapping: ChromedriverDetailsMapping = {};
for (const driverNode of driverNodes) {
const k = extractNodeText(findChildNode(driverNode, 'Key'));
if (!String(k).includes('/chromedriver_')) {
continue;
}
const key = String(k);
const versionSegment = key.split('/')[0];
if (!versionSegment) {
continue;
}
const etag = extractNodeText(findChildNode(driverNode, 'ETag'));
if (!etag) {
log.debug(`The entry '${key}' does not contain the checksum. Skipping it`);
continue;
}
const filename = path.basename(key);
const osNameMatch = /_([a-z]+)/i.exec(filename);
if (!osNameMatch) {
log.debug(`The entry '${key}' does not contain valid OS name. Skipping it`);
continue;
}
const cdInfo: ChromedriverDetails = {
url: `${GOOGLEAPIS_CDN}/${key}`,
etag: etag.replace(/^"+|"+$/g, ''),
version: versionSegment,
minBrowserVersion: null,
os: {
name: osNameMatch[1],
arch: filename.includes(ARCH.X64) ? ARCH.X64 : ARCH.X86,
cpu: APPLE_ARM_SUFFIXES.some((suffix) => filename.includes(suffix)) ? CPU.ARM : CPU.INTEL,
},
};
mapping[key] = cdInfo;
const notesPath = `${cdInfo.version}/notes.txt`;
const isNotesPresent = !!driverNodes.reduce(
(acc, node) => Boolean(acc || findChildNode(node, 'Key', notesPath)),
false,
);
if (!isNotesPresent) {
cdInfo.minBrowserVersion = null;
if (shouldParseNotes) {
log.info(`The entry '${key}' does not contain any notes. Skipping it`);
}
continue;
} else if (!shouldParseNotes) {
continue;
}
infoParsers.push(async () => {
await retrieveAdditionalDriverInfo(key, `${GOOGLEAPIS_CDN}/${notesPath}`, cdInfo);
});
}
await asyncmap(
infoParsers,
async (parseInfo) => {
await parseInfo();
},
{concurrency: MAX_PARALLEL_DOWNLOADS},
);
log.info(`The total count of entries in the mapping: ${Object.keys(mapping).length}`);
return mapping;
}
/**
* Downloads chromedriver release notes and updates the driver info dictionary
*
* Mutates `infoDict` by setting `minBrowserVersion` if found in notes
* @param driverKey - Driver version plus archive name
* @param notesUrl - The URL of chromedriver notes
* @param infoDict - The dictionary containing driver info (will be mutated)
* @param timeout - Request timeout in milliseconds
*/
async function retrieveAdditionalDriverInfo(
driverKey: string,
notesUrl: string,
infoDict: ChromedriverDetails,
timeout = STORAGE_REQ_TIMEOUT_MS,
): Promise<void> {
const notes = await retrieveData(
notesUrl,
{
'user-agent': 'appium',
accept: '*/*',
},
{timeout},
);
const {minBrowserVersion} = parseNotes(notes);
if (!minBrowserVersion) {
log.debug(
`The driver '${driverKey}' does not contain valid release notes at ${notesUrl}. ` +
`Skipping it`,
);
return;
}
infoDict.minBrowserVersion = minBrowserVersion;
}
function extractNodeText(node: Node | null | undefined): string | null {
return !node?.firstChild || !util.hasValue(node.firstChild.nodeValue)
? null
: node.firstChild.nodeValue;
}