import _ from 'lodash'; import {select as xpathSelect} from 'xpath'; import {util, logger} from '@appium/support'; import {retrieveData} from '../utils'; import B from 'bluebird'; 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 */ export async function parseGoogleapiStorageXml( xml: string, shouldParseNotes = true, ): Promise { 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 (_.isEmpty(driverNodes)) { throw new Error('Cannot retrieve any valid Chromedriver entries from the storage config'); } const promises: Promise[] = []; const chunk: Promise[] = []; const mapping: ChromedriverDetailsMapping = {}; for (const driverNode of driverNodes) { const k = extractNodeText(findChildNode(driverNode, 'Key')); if (!_.includes(k, '/chromedriver_')) { continue; } const key = String(k); 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: _.trim(etag, '"'), version: _.first(key.split('/')) as string, 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; } const promise = B.resolve( retrieveAdditionalDriverInfo(key, `${GOOGLEAPIS_CDN}/${notesPath}`, cdInfo), ); promises.push(promise); chunk.push(promise); if (chunk.length >= MAX_PARALLEL_DOWNLOADS) { await B.any(chunk); } _.remove(chunk, (p) => (p as B).isFulfilled()); } await B.all(promises); log.info(`The total count of entries in the mapping: ${_.size(mapping)}`); 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 { 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; }